summaryrefslogtreecommitdiffstats
path: root/library/Director
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 12:43:12 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-28 12:43:12 +0000
commitcd989f9c3aff968e19a3aeabc4eb9085787a6673 (patch)
treefbff2135e7013f196b891bbde54618eb050e4aaf /library/Director
parentInitial commit. (diff)
downloadicingaweb2-module-director-upstream.tar.xz
icingaweb2-module-director-upstream.zip
Adding upstream version 1.10.2.upstream/1.10.2upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'library/Director')
-rw-r--r--library/Director/Acl.php90
-rw-r--r--library/Director/Application/Dependency.php113
-rw-r--r--library/Director/Application/DependencyChecker.php73
-rw-r--r--library/Director/Application/MemoryLimit.php53
-rw-r--r--library/Director/CheckPlugin/Check.php59
-rw-r--r--library/Director/CheckPlugin/CheckResult.php31
-rw-r--r--library/Director/CheckPlugin/CheckResults.php150
-rw-r--r--library/Director/CheckPlugin/PluginState.php114
-rw-r--r--library/Director/CheckPlugin/Range.php101
-rw-r--r--library/Director/CheckPlugin/Threshold.php47
-rw-r--r--library/Director/Cli/Command.php115
-rw-r--r--library/Director/Cli/ObjectCommand.php517
-rw-r--r--library/Director/Cli/ObjectsCommand.php115
-rw-r--r--library/Director/Cli/PluginOutputBeautifier.php75
-rw-r--r--library/Director/ConfigDiff.php47
-rw-r--r--library/Director/Core/CoreApi.php940
-rw-r--r--library/Director/Core/DeploymentApiInterface.php75
-rw-r--r--library/Director/Core/Json.php34
-rw-r--r--library/Director/Core/LegacyDeploymentApi.php466
-rw-r--r--library/Director/Core/RestApiClient.php276
-rw-r--r--library/Director/Core/RestApiResponse.php149
-rw-r--r--library/Director/CoreBeta/ApiStream.php57
-rw-r--r--library/Director/CoreBeta/Stream.php17
-rw-r--r--library/Director/CoreBeta/StreamContext.php89
-rw-r--r--library/Director/CoreBeta/StreamContextSslOptions.php52
-rw-r--r--library/Director/CustomVariable/CustomVariable.php286
-rw-r--r--library/Director/CustomVariable/CustomVariableArray.php100
-rw-r--r--library/Director/CustomVariable/CustomVariableBoolean.php53
-rw-r--r--library/Director/CustomVariable/CustomVariableDictionary.php130
-rw-r--r--library/Director/CustomVariable/CustomVariableNull.php52
-rw-r--r--library/Director/CustomVariable/CustomVariableNumber.php73
-rw-r--r--library/Director/CustomVariable/CustomVariableString.php59
-rw-r--r--library/Director/CustomVariable/CustomVariables.php488
-rw-r--r--library/Director/Daemon/BackgroundDaemon.php235
-rw-r--r--library/Director/Daemon/DaemonDb.php365
-rw-r--r--library/Director/Daemon/DaemonProcessDetails.php122
-rw-r--r--library/Director/Daemon/DaemonProcessState.php85
-rw-r--r--library/Director/Daemon/DaemonUtil.php16
-rw-r--r--library/Director/Daemon/DbBasedComponent.php19
-rw-r--r--library/Director/Daemon/DeploymentChecker.php51
-rw-r--r--library/Director/Daemon/JobRunner.php234
-rw-r--r--library/Director/Daemon/JsonRpcLogWriter.php37
-rw-r--r--library/Director/Daemon/LogProxy.php76
-rw-r--r--library/Director/Daemon/Logger.php24
-rw-r--r--library/Director/Daemon/ProcessList.php125
-rw-r--r--library/Director/Daemon/RunningDaemonInfo.php154
-rw-r--r--library/Director/Daemon/SystemdLogWriter.php27
-rw-r--r--library/Director/Dashboard/AlertsDashboard.php19
-rw-r--r--library/Director/Dashboard/AutomationDashboard.php17
-rw-r--r--library/Director/Dashboard/BranchesDashboard.php36
-rw-r--r--library/Director/Dashboard/CommandsDashboard.php35
-rw-r--r--library/Director/Dashboard/Dashboard.php305
-rw-r--r--library/Director/Dashboard/Dashlet/ActivityLogDashlet.php35
-rw-r--r--library/Director/Dashboard/Dashlet/ApiUserObjectDashlet.php25
-rw-r--r--library/Director/Dashboard/Dashlet/BasketDashlet.php30
-rw-r--r--library/Director/Dashboard/Dashlet/CheckCommandsDashlet.php31
-rw-r--r--library/Director/Dashboard/Dashlet/ChoicesDashlet.php41
-rw-r--r--library/Director/Dashboard/Dashlet/CommandObjectDashlet.php25
-rw-r--r--library/Director/Dashboard/Dashlet/CommandTemplatesDashlet.php26
-rw-r--r--library/Director/Dashboard/Dashlet/CustomvarDashlet.php30
-rw-r--r--library/Director/Dashboard/Dashlet/Dashlet.php239
-rw-r--r--library/Director/Dashboard/Dashlet/DatafieldCategoryDashlet.php30
-rw-r--r--library/Director/Dashboard/Dashlet/DatafieldDashlet.php30
-rw-r--r--library/Director/Dashboard/Dashlet/DatalistDashlet.php30
-rw-r--r--library/Director/Dashboard/Dashlet/DependencyObjectDashlet.php26
-rw-r--r--library/Director/Dashboard/Dashlet/DeploymentDashlet.php114
-rw-r--r--library/Director/Dashboard/Dashlet/EndpointObjectDashlet.php63
-rw-r--r--library/Director/Dashboard/Dashlet/ExternalCheckCommandsDashlet.php26
-rw-r--r--library/Director/Dashboard/Dashlet/ExternalNotificationCommandsDashlet.php21
-rw-r--r--library/Director/Dashboard/Dashlet/HostChoicesDashlet.php7
-rw-r--r--library/Director/Dashboard/Dashlet/HostGroupsDashlet.php31
-rw-r--r--library/Director/Dashboard/Dashlet/HostObjectDashlet.php25
-rw-r--r--library/Director/Dashboard/Dashlet/HostTemplatesDashlet.php31
-rw-r--r--library/Director/Dashboard/Dashlet/HostsDashlet.php32
-rw-r--r--library/Director/Dashboard/Dashlet/ImportSourceDashlet.php65
-rw-r--r--library/Director/Dashboard/Dashlet/InfrastructureDashlet.php30
-rw-r--r--library/Director/Dashboard/Dashlet/JobDashlet.php65
-rw-r--r--library/Director/Dashboard/Dashlet/KickstartDashlet.php31
-rw-r--r--library/Director/Dashboard/Dashlet/NotificationApplyDashlet.php37
-rw-r--r--library/Director/Dashboard/Dashlet/NotificationCommandsDashlet.php21
-rw-r--r--library/Director/Dashboard/Dashlet/NotificationTemplateDashlet.php31
-rw-r--r--library/Director/Dashboard/Dashlet/NotificationsDashlet.php33
-rw-r--r--library/Director/Dashboard/Dashlet/ScheduledDowntimeApplyDashlet.php25
-rw-r--r--library/Director/Dashboard/Dashlet/SelfServiceDashlet.php31
-rw-r--r--library/Director/Dashboard/Dashlet/ServiceApplyRulesDashlet.php31
-rw-r--r--library/Director/Dashboard/Dashlet/ServiceChoicesDashlet.php7
-rw-r--r--library/Director/Dashboard/Dashlet/ServiceGroupsDashlet.php31
-rw-r--r--library/Director/Dashboard/Dashlet/ServiceObjectDashlet.php34
-rw-r--r--library/Director/Dashboard/Dashlet/ServiceSetsDashlet.php31
-rw-r--r--library/Director/Dashboard/Dashlet/ServiceTemplatesDashlet.php31
-rw-r--r--library/Director/Dashboard/Dashlet/SettingsDashlet.php30
-rw-r--r--library/Director/Dashboard/Dashlet/SingleServicesDashlet.php31
-rw-r--r--library/Director/Dashboard/Dashlet/SyncDashlet.php65
-rw-r--r--library/Director/Dashboard/Dashlet/TimeperiodObjectDashlet.php28
-rw-r--r--library/Director/Dashboard/Dashlet/TimeperiodTemplateDashlet.php31
-rw-r--r--library/Director/Dashboard/Dashlet/TimeperiodsDashlet.php25
-rw-r--r--library/Director/Dashboard/Dashlet/UserGroupsDashlet.php31
-rw-r--r--library/Director/Dashboard/Dashlet/UserObjectDashlet.php23
-rw-r--r--library/Director/Dashboard/Dashlet/UserTemplateDashlet.php31
-rw-r--r--library/Director/Dashboard/Dashlet/UsersDashlet.php25
-rw-r--r--library/Director/Dashboard/Dashlet/ZoneObjectDashlet.php25
-rw-r--r--library/Director/Dashboard/DataDashboard.php18
-rw-r--r--library/Director/Dashboard/DeploymentDashboard.php17
-rw-r--r--library/Director/Dashboard/DirectorDashboard.php17
-rw-r--r--library/Director/Dashboard/HostsDashboard.php35
-rw-r--r--library/Director/Dashboard/InfrastructureDashboard.php60
-rw-r--r--library/Director/Dashboard/NotificationsDashboard.php44
-rw-r--r--library/Director/Dashboard/ObjectsDashboard.php17
-rw-r--r--library/Director/Dashboard/ServicesDashboard.php39
-rw-r--r--library/Director/Dashboard/TimeperiodsDashboard.php33
-rw-r--r--library/Director/Dashboard/UsersDashboard.php36
-rw-r--r--library/Director/Data/AssignFilterHelper.php160
-rw-r--r--library/Director/Data/DataArrayHelper.php48
-rw-r--r--library/Director/Data/Db/DbConnection.php51
-rw-r--r--library/Director/Data/Db/DbDataFormatter.php26
-rw-r--r--library/Director/Data/Db/DbObject.php1487
-rw-r--r--library/Director/Data/Db/DbObjectStore.php169
-rw-r--r--library/Director/Data/Db/DbObjectTypeRegistry.php75
-rw-r--r--library/Director/Data/Db/DbObjectWithSettings.php168
-rw-r--r--library/Director/Data/Db/IcingaObjectFilterRenderer.php133
-rw-r--r--library/Director/Data/Db/IcingaObjectQuery.php255
-rw-r--r--library/Director/Data/Db/ServiceSetQueryBuilder.php158
-rw-r--r--library/Director/Data/Exporter.php303
-rw-r--r--library/Director/Data/FieldReferenceLoader.php51
-rw-r--r--library/Director/Data/HostServiceLoader.php170
-rw-r--r--library/Director/Data/ImportExportDeniedProperties.php52
-rw-r--r--library/Director/Data/InvalidDataException.php26
-rw-r--r--library/Director/Data/Json.php69
-rw-r--r--library/Director/Data/PropertiesFilter.php25
-rw-r--r--library/Director/Data/PropertiesFilter/ArrayCustomVariablesFilter.php20
-rw-r--r--library/Director/Data/PropertiesFilter/CustomVariablesFilter.php13
-rw-r--r--library/Director/Data/PropertyMangler.php60
-rw-r--r--library/Director/Data/RecursiveUtf8Validator.php59
-rw-r--r--library/Director/Data/Serializable.php10
-rw-r--r--library/Director/Data/SerializableValue.php90
-rw-r--r--library/Director/Data/ValueFilter.php10
-rw-r--r--library/Director/Data/ValueFilter/FilterBoolean.php19
-rw-r--r--library/Director/Data/ValueFilter/FilterInt.php21
-rw-r--r--library/Director/DataType/DataTypeArray.php14
-rw-r--r--library/Director/DataType/DataTypeBoolean.php30
-rw-r--r--library/Director/DataType/DataTypeDatalist.php159
-rw-r--r--library/Director/DataType/DataTypeDictionary.php107
-rw-r--r--library/Director/DataType/DataTypeDirectorObject.php87
-rw-r--r--library/Director/DataType/DataTypeNumber.php19
-rw-r--r--library/Director/DataType/DataTypeSqlQuery.php96
-rw-r--r--library/Director/DataType/DataTypeString.php35
-rw-r--r--library/Director/DataType/DataTypeTime.php16
-rw-r--r--library/Director/Db.php755
-rw-r--r--library/Director/Db/AppliedServiceSetLoader.php58
-rw-r--r--library/Director/Db/Branch/Branch.php216
-rw-r--r--library/Director/Db/Branch/BranchActivity.php390
-rw-r--r--library/Director/Db/Branch/BranchMerger.php157
-rw-r--r--library/Director/Db/Branch/BranchModificationInspection.php93
-rw-r--r--library/Director/Db/Branch/BranchSettings.php121
-rw-r--r--library/Director/Db/Branch/BranchStore.php240
-rw-r--r--library/Director/Db/Branch/BranchSupport.php91
-rw-r--r--library/Director/Db/Branch/BranchedObject.php404
-rw-r--r--library/Director/Db/Branch/MergeError.php37
-rw-r--r--library/Director/Db/Branch/MergeErrorDeleteMissingObject.php15
-rw-r--r--library/Director/Db/Branch/MergeErrorModificationForMissingObject.php15
-rw-r--r--library/Director/Db/Branch/MergeErrorRecreateOnMerge.php15
-rw-r--r--library/Director/Db/Branch/PlainObjectPropertyDiff.php50
-rw-r--r--library/Director/Db/Branch/UuidLookup.php141
-rw-r--r--library/Director/Db/Cache/CustomVariableCache.php84
-rw-r--r--library/Director/Db/Cache/GroupMembershipCache.php104
-rw-r--r--library/Director/Db/Cache/PrefetchCache.php166
-rw-r--r--library/Director/Db/DbSelectParenthesis.php24
-rw-r--r--library/Director/Db/DbUtil.php96
-rw-r--r--library/Director/Db/HostMembershipHousekeeping.php8
-rw-r--r--library/Director/Db/Housekeeping.php249
-rw-r--r--library/Director/Db/IcingaObjectFilterHelper.php133
-rw-r--r--library/Director/Db/MembershipHousekeeping.php135
-rw-r--r--library/Director/Db/Migration.php70
-rw-r--r--library/Director/Db/Migrations.php239
-rw-r--r--library/Director/Deployment/ConditionalConfigRenderer.php64
-rw-r--r--library/Director/Deployment/ConditionalDeployment.php190
-rw-r--r--library/Director/Deployment/DeploymentGracePeriod.php61
-rw-r--r--library/Director/Deployment/DeploymentInfo.php59
-rw-r--r--library/Director/Deployment/DeploymentStatus.php164
-rw-r--r--library/Director/DirectorObject/Automation/Basket.php232
-rw-r--r--library/Director/DirectorObject/Automation/BasketContent.php24
-rw-r--r--library/Director/DirectorObject/Automation/BasketSnapshot.php531
-rw-r--r--library/Director/DirectorObject/Automation/BasketSnapshotFieldResolver.php226
-rw-r--r--library/Director/DirectorObject/Automation/CompareBasketObject.php146
-rw-r--r--library/Director/DirectorObject/Automation/ExportInterface.php20
-rw-r--r--library/Director/DirectorObject/Automation/ImportExport.php149
-rw-r--r--library/Director/DirectorObject/Lookup/AppliedServiceInfo.php109
-rw-r--r--library/Director/DirectorObject/Lookup/AppliedServiceSetServiceInfo.php127
-rw-r--r--library/Director/DirectorObject/Lookup/InheritedServiceInfo.php94
-rw-r--r--library/Director/DirectorObject/Lookup/ServiceFinder.php79
-rw-r--r--library/Director/DirectorObject/Lookup/ServiceInfo.php46
-rw-r--r--library/Director/DirectorObject/Lookup/ServiceSetServiceInfo.php121
-rw-r--r--library/Director/DirectorObject/Lookup/SingleServiceInfo.php83
-rw-r--r--library/Director/DirectorObject/ObjectPurgeHelper.php149
-rw-r--r--library/Director/Exception/DuplicateKeyException.php9
-rw-r--r--library/Director/Exception/JsonEncodeException.php7
-rw-r--r--library/Director/Exception/JsonException.php55
-rw-r--r--library/Director/Exception/NestingError.php9
-rw-r--r--library/Director/Field/FieldSpec.php206
-rw-r--r--library/Director/Health.php285
-rw-r--r--library/Director/Hook/BranchSupportHook.php39
-rw-r--r--library/Director/Hook/DataTypeHook.php59
-rw-r--r--library/Director/Hook/DeploymentHook.php65
-rw-r--r--library/Director/Hook/HostFieldHook.php19
-rw-r--r--library/Director/Hook/IcingaObjectFormHook.php22
-rw-r--r--library/Director/Hook/ImportSourceHook.php136
-rw-r--r--library/Director/Hook/JobHook.php86
-rw-r--r--library/Director/Hook/PropertyModifierHook.php258
-rw-r--r--library/Director/Hook/ServiceFieldHook.php19
-rw-r--r--library/Director/Hook/ShipConfigFilesHook.php11
-rw-r--r--library/Director/IcingaConfig/AgentWizard.php337
-rw-r--r--library/Director/IcingaConfig/AssignRenderer.php268
-rw-r--r--library/Director/IcingaConfig/ExtensibleSet.php574
-rw-r--r--library/Director/IcingaConfig/IcingaConfig.php781
-rw-r--r--library/Director/IcingaConfig/IcingaConfigFile.php168
-rw-r--r--library/Director/IcingaConfig/IcingaConfigHelper.php430
-rw-r--r--library/Director/IcingaConfig/IcingaConfigRendered.php34
-rw-r--r--library/Director/IcingaConfig/IcingaConfigRenderer.php10
-rw-r--r--library/Director/IcingaConfig/IcingaLegacyConfigHelper.php110
-rw-r--r--library/Director/IcingaConfig/StateFilterSet.php31
-rw-r--r--library/Director/IcingaConfig/TypeFilterSet.php39
-rw-r--r--library/Director/Import/Import.php481
-rw-r--r--library/Director/Import/ImportSourceCoreApi.php92
-rw-r--r--library/Director/Import/ImportSourceDirectorObject.php120
-rw-r--r--library/Director/Import/ImportSourceLdap.php90
-rw-r--r--library/Director/Import/ImportSourceRestApi.php380
-rw-r--r--library/Director/Import/ImportSourceSql.php70
-rw-r--r--library/Director/Import/PurgeStrategy/ImportRunBasedPurgeStrategy.php90
-rw-r--r--library/Director/Import/PurgeStrategy/PurgeNothingPurgeStrategy.php11
-rw-r--r--library/Director/Import/PurgeStrategy/PurgeStrategy.php31
-rw-r--r--library/Director/Import/Sync.php942
-rw-r--r--library/Director/Import/SyncUtils.php153
-rw-r--r--library/Director/Job/ConfigJob.php75
-rw-r--r--library/Director/Job/HousekeepingJob.php39
-rw-r--r--library/Director/Job/ImportJob.php122
-rw-r--r--library/Director/Job/SyncJob.php128
-rw-r--r--library/Director/KickstartHelper.php555
-rw-r--r--library/Director/Monitoring.php149
-rw-r--r--library/Director/Objects/DirectorActivityLog.php232
-rw-r--r--library/Director/Objects/DirectorDatafield.php344
-rw-r--r--library/Director/Objects/DirectorDatafieldCategory.php64
-rw-r--r--library/Director/Objects/DirectorDatalist.php225
-rw-r--r--library/Director/Objects/DirectorDatalistEntry.php112
-rw-r--r--library/Director/Objects/DirectorDeploymentLog.php199
-rw-r--r--library/Director/Objects/DirectorJob.php314
-rw-r--r--library/Director/Objects/DynamicApplyMatches.php14
-rw-r--r--library/Director/Objects/Extension/Arguments.php61
-rw-r--r--library/Director/Objects/Extension/FlappingSupport.php54
-rw-r--r--library/Director/Objects/Extension/PriorityColumn.php40
-rw-r--r--library/Director/Objects/GroupMembershipResolver.php689
-rw-r--r--library/Director/Objects/HostApplyMatches.php8
-rw-r--r--library/Director/Objects/HostGroupMembershipResolver.php8
-rw-r--r--library/Director/Objects/IcingaApiUser.php31
-rw-r--r--library/Director/Objects/IcingaArguments.php442
-rw-r--r--library/Director/Objects/IcingaCommand.php365
-rw-r--r--library/Director/Objects/IcingaCommandArgument.php263
-rw-r--r--library/Director/Objects/IcingaCommandField.php17
-rw-r--r--library/Director/Objects/IcingaDependency.php631
-rw-r--r--library/Director/Objects/IcingaEndpoint.php157
-rw-r--r--library/Director/Objects/IcingaFlatVar.php61
-rw-r--r--library/Director/Objects/IcingaHost.php668
-rw-r--r--library/Director/Objects/IcingaHostField.php17
-rw-r--r--library/Director/Objects/IcingaHostGroup.php42
-rw-r--r--library/Director/Objects/IcingaHostGroupAssignment.php20
-rw-r--r--library/Director/Objects/IcingaHostVar.php29
-rw-r--r--library/Director/Objects/IcingaNotification.php254
-rw-r--r--library/Director/Objects/IcingaNotificationField.php17
-rw-r--r--library/Director/Objects/IcingaObject.php3258
-rw-r--r--library/Director/Objects/IcingaObjectField.php26
-rw-r--r--library/Director/Objects/IcingaObjectGroup.php76
-rw-r--r--library/Director/Objects/IcingaObjectGroups.php408
-rw-r--r--library/Director/Objects/IcingaObjectImports.php439
-rw-r--r--library/Director/Objects/IcingaObjectLegacyAssignments.php79
-rw-r--r--library/Director/Objects/IcingaObjectMultiRelations.php454
-rw-r--r--library/Director/Objects/IcingaRanges.php321
-rw-r--r--library/Director/Objects/IcingaRelatedObject.php211
-rw-r--r--library/Director/Objects/IcingaScheduledDowntime.php135
-rw-r--r--library/Director/Objects/IcingaScheduledDowntimeRange.php88
-rw-r--r--library/Director/Objects/IcingaScheduledDowntimeRanges.php18
-rw-r--r--library/Director/Objects/IcingaService.php828
-rw-r--r--library/Director/Objects/IcingaServiceAssignment.php20
-rw-r--r--library/Director/Objects/IcingaServiceField.php17
-rw-r--r--library/Director/Objects/IcingaServiceGroup.php42
-rw-r--r--library/Director/Objects/IcingaServiceSet.php591
-rw-r--r--library/Director/Objects/IcingaServiceSetAssignment.php20
-rw-r--r--library/Director/Objects/IcingaServiceVar.php29
-rw-r--r--library/Director/Objects/IcingaTemplateChoice.php321
-rw-r--r--library/Director/Objects/IcingaTemplateChoiceHost.php14
-rw-r--r--library/Director/Objects/IcingaTemplateChoiceService.php14
-rw-r--r--library/Director/Objects/IcingaTemplateResolver.php479
-rw-r--r--library/Director/Objects/IcingaTimePeriod.php190
-rw-r--r--library/Director/Objects/IcingaTimePeriodRange.php88
-rw-r--r--library/Director/Objects/IcingaTimePeriodRanges.php35
-rw-r--r--library/Director/Objects/IcingaUser.php92
-rw-r--r--library/Director/Objects/IcingaUserField.php17
-rw-r--r--library/Director/Objects/IcingaUserGroup.php29
-rw-r--r--library/Director/Objects/IcingaVar.php72
-rw-r--r--library/Director/Objects/IcingaZone.php110
-rw-r--r--library/Director/Objects/ImportExportHelper.php68
-rw-r--r--library/Director/Objects/ImportRowModifier.php91
-rw-r--r--library/Director/Objects/ImportRun.php159
-rw-r--r--library/Director/Objects/ImportSource.php537
-rw-r--r--library/Director/Objects/InstantiatedViaHook.php14
-rw-r--r--library/Director/Objects/ObjectApplyMatches.php239
-rw-r--r--library/Director/Objects/ObjectWithArguments.php18
-rw-r--r--library/Director/Objects/ServiceGroupMembershipResolver.php8
-rw-r--r--library/Director/Objects/SyncProperty.php48
-rw-r--r--library/Director/Objects/SyncRule.php553
-rw-r--r--library/Director/Objects/SyncRun.php46
-rw-r--r--library/Director/PlainObjectRenderer.php130
-rw-r--r--library/Director/PropertyModifier/PropertyModifierArrayElementByPosition.php172
-rw-r--r--library/Director/PropertyModifier/PropertyModifierArrayFilter.php152
-rw-r--r--library/Director/PropertyModifier/PropertyModifierArrayToRow.php68
-rw-r--r--library/Director/PropertyModifier/PropertyModifierArrayUnique.php37
-rw-r--r--library/Director/PropertyModifier/PropertyModifierBitmask.php39
-rw-r--r--library/Director/PropertyModifier/PropertyModifierCombine.php40
-rw-r--r--library/Director/PropertyModifier/PropertyModifierDictionaryToRow.php94
-rw-r--r--library/Director/PropertyModifier/PropertyModifierDnsRecords.php112
-rw-r--r--library/Director/PropertyModifier/PropertyModifierExtractFromDN.php99
-rw-r--r--library/Director/PropertyModifier/PropertyModifierFromAdSid.php36
-rw-r--r--library/Director/PropertyModifier/PropertyModifierFromLatin1.php23
-rw-r--r--library/Director/PropertyModifier/PropertyModifierGetHostByAddr.php53
-rw-r--r--library/Director/PropertyModifier/PropertyModifierGetHostByName.php54
-rw-r--r--library/Director/PropertyModifier/PropertyModifierGetPropertyFromOtherImportSource.php129
-rw-r--r--library/Director/PropertyModifier/PropertyModifierJoin.php34
-rw-r--r--library/Director/PropertyModifier/PropertyModifierJsonDecode.php68
-rw-r--r--library/Director/PropertyModifier/PropertyModifierLConfCustomVar.php48
-rw-r--r--library/Director/PropertyModifier/PropertyModifierListToObject.php95
-rw-r--r--library/Director/PropertyModifier/PropertyModifierLowercase.php17
-rw-r--r--library/Director/PropertyModifier/PropertyModifierMakeBoolean.php90
-rw-r--r--library/Director/PropertyModifier/PropertyModifierMap.php97
-rw-r--r--library/Director/PropertyModifier/PropertyModifierNegateBoolean.php26
-rw-r--r--library/Director/PropertyModifier/PropertyModifierParseURL.php81
-rw-r--r--library/Director/PropertyModifier/PropertyModifierRegexReplace.php45
-rw-r--r--library/Director/PropertyModifier/PropertyModifierRegexSplit.php51
-rw-r--r--library/Director/PropertyModifier/PropertyModifierRejectOrSelect.php147
-rw-r--r--library/Director/PropertyModifier/PropertyModifierRenameColumn.php34
-rw-r--r--library/Director/PropertyModifier/PropertyModifierReplace.php36
-rw-r--r--library/Director/PropertyModifier/PropertyModifierReplaceNull.php33
-rw-r--r--library/Director/PropertyModifier/PropertyModifierSimpleGroupBy.php68
-rw-r--r--library/Director/PropertyModifier/PropertyModifierSkipDuplicates.php26
-rw-r--r--library/Director/PropertyModifier/PropertyModifierSplit.php51
-rw-r--r--library/Director/PropertyModifier/PropertyModifierStripDomain.php38
-rw-r--r--library/Director/PropertyModifier/PropertyModifierSubstring.php54
-rw-r--r--library/Director/PropertyModifier/PropertyModifierToInt.php31
-rw-r--r--library/Director/PropertyModifier/PropertyModifierTrim.php54
-rw-r--r--library/Director/PropertyModifier/PropertyModifierURLEncode.php19
-rw-r--r--library/Director/PropertyModifier/PropertyModifierUpperCaseFirst.php44
-rw-r--r--library/Director/PropertyModifier/PropertyModifierUppercase.php17
-rw-r--r--library/Director/PropertyModifier/PropertyModifierUuidBinToHex.php19
-rw-r--r--library/Director/PropertyModifier/PropertyModifierXlsNumericIp.php26
-rw-r--r--library/Director/ProvidedHook/CubeLinks.php65
-rw-r--r--library/Director/ProvidedHook/IcingaDbCubeLinks.php66
-rw-r--r--library/Director/ProvidedHook/Monitoring/HostActions.php73
-rw-r--r--library/Director/ProvidedHook/Monitoring/ServiceActions.php87
-rw-r--r--library/Director/Repository/IcingaTemplateRepository.php122
-rw-r--r--library/Director/Repository/RepositoryByObjectHelper.php99
-rw-r--r--library/Director/Resolver/CommandUsage.php104
-rw-r--r--library/Director/Resolver/HostServiceBlacklist.php91
-rw-r--r--library/Director/Resolver/IcingaHostObjectResolver.php44
-rw-r--r--library/Director/Resolver/IcingaObjectResolver.php558
-rw-r--r--library/Director/Resolver/OverriddenVarsResolver.php74
-rw-r--r--library/Director/Resolver/OverrideHelper.php38
-rw-r--r--library/Director/Resolver/TemplateTree.php491
-rw-r--r--library/Director/RestApi/IcingaObjectHandler.php196
-rw-r--r--library/Director/RestApi/IcingaObjectsHandler.php144
-rw-r--r--library/Director/RestApi/RequestHandler.php86
-rw-r--r--library/Director/RestApi/RestApiClient.php311
-rw-r--r--library/Director/RestApi/RestApiParams.php29
-rw-r--r--library/Director/Restriction/FilterByNameRestriction.php64
-rw-r--r--library/Director/Restriction/HostgroupRestriction.php171
-rw-r--r--library/Director/Restriction/MatchingFilter.php40
-rw-r--r--library/Director/Restriction/ObjectRestriction.php84
-rw-r--r--library/Director/Settings.php219
-rw-r--r--library/Director/StartupLogRenderer.php126
-rw-r--r--library/Director/Test/BaseTestCase.php127
-rw-r--r--library/Director/Test/Bootstrap.php28
-rw-r--r--library/Director/Test/IcingaObjectTestCase.php92
-rw-r--r--library/Director/Test/ImportSourceDummy.php52
-rw-r--r--library/Director/Test/SyncTest.php105
-rw-r--r--library/Director/Test/TestProcess.php116
-rw-r--r--library/Director/Test/TestSuite.php68
-rw-r--r--library/Director/Test/TestSuiteLint.php56
-rw-r--r--library/Director/Test/TestSuiteStyle.php66
-rw-r--r--library/Director/Test/TestSuiteUnit.php26
-rw-r--r--library/Director/Test/Web/Form/TestDirectorObjectForm.php19
-rw-r--r--library/Director/TranslationDummy.php20
-rw-r--r--library/Director/Util.php175
-rw-r--r--library/Director/Web/ActionBar/AutomationObjectActionBar.php65
-rw-r--r--library/Director/Web/ActionBar/ChoicesActionBar.php27
-rw-r--r--library/Director/Web/ActionBar/DirectorBaseActionBar.php67
-rw-r--r--library/Director/Web/ActionBar/ObjectsActionBar.php27
-rw-r--r--library/Director/Web/ActionBar/TemplateActionBar.php42
-rw-r--r--library/Director/Web/Controller/ActionController.php253
-rw-r--r--library/Director/Web/Controller/BranchHelper.php76
-rw-r--r--library/Director/Web/Controller/Extension/CoreApi.php46
-rw-r--r--library/Director/Web/Controller/Extension/DirectorDb.php160
-rw-r--r--library/Director/Web/Controller/Extension/ObjectRestrictions.php48
-rw-r--r--library/Director/Web/Controller/Extension/RestApi.php114
-rw-r--r--library/Director/Web/Controller/Extension/SingleObjectApiHandler.php236
-rw-r--r--library/Director/Web/Controller/ObjectController.php733
-rw-r--r--library/Director/Web/Controller/ObjectsController.php548
-rw-r--r--library/Director/Web/Controller/TemplateController.php243
-rw-r--r--library/Director/Web/Form/ClickHereForm.php31
-rw-r--r--library/Director/Web/Form/CloneImportSourceForm.php72
-rw-r--r--library/Director/Web/Form/CloneSyncRuleForm.php76
-rw-r--r--library/Director/Web/Form/CsrfToken.php53
-rw-r--r--library/Director/Web/Form/DbSelectorForm.php85
-rw-r--r--library/Director/Web/Form/Decorator/ViewHelperRaw.php14
-rw-r--r--library/Director/Web/Form/DirectorForm.php58
-rw-r--r--library/Director/Web/Form/DirectorObjectForm.php1734
-rw-r--r--library/Director/Web/Form/Element/Boolean.php90
-rw-r--r--library/Director/Web/Form/Element/DataFilter.php361
-rw-r--r--library/Director/Web/Form/Element/ExtensibleSet.php89
-rw-r--r--library/Director/Web/Form/Element/FormElement.php9
-rw-r--r--library/Director/Web/Form/Element/InstanceSummary.php51
-rw-r--r--library/Director/Web/Form/Element/OptionalYesNo.php22
-rw-r--r--library/Director/Web/Form/Element/SimpleNote.php34
-rw-r--r--library/Director/Web/Form/Element/StoredPassword.php62
-rw-r--r--library/Director/Web/Form/Element/Text.php16
-rw-r--r--library/Director/Web/Form/Element/YesNo.php14
-rw-r--r--library/Director/Web/Form/Filter/QueryColumnsFromSql.php48
-rw-r--r--library/Director/Web/Form/FormLoader.php43
-rw-r--r--library/Director/Web/Form/IcingaObjectFieldLoader.php628
-rw-r--r--library/Director/Web/Form/IconHelper.php89
-rw-r--r--library/Director/Web/Form/IplElement/ExtensibleSetElement.php570
-rw-r--r--library/Director/Web/Form/QuickBaseForm.php177
-rw-r--r--library/Director/Web/Form/QuickForm.php641
-rw-r--r--library/Director/Web/Form/QuickSubForm.php36
-rw-r--r--library/Director/Web/Form/Validate/IsDataListEntry.php55
-rw-r--r--library/Director/Web/Form/Validate/NamePattern.php38
-rw-r--r--library/Director/Web/Navigation/Renderer/ConfigHealthItemRenderer.php196
-rw-r--r--library/Director/Web/ObjectPreview.php182
-rw-r--r--library/Director/Web/SelfService.php311
-rw-r--r--library/Director/Web/Table/ActivityLogTable.php294
-rw-r--r--library/Director/Web/Table/ApplyRulesTable.php240
-rw-r--r--library/Director/Web/Table/BasketSnapshotTable.php125
-rw-r--r--library/Director/Web/Table/BasketTable.php50
-rw-r--r--library/Director/Web/Table/BranchActivityTable.php116
-rw-r--r--library/Director/Web/Table/BranchedIcingaCommandArgumentTable.php78
-rw-r--r--library/Director/Web/Table/ChoicesTable.php65
-rw-r--r--library/Director/Web/Table/ConfigFileDiffTable.php140
-rw-r--r--library/Director/Web/Table/CoreApiFieldsTable.php106
-rw-r--r--library/Director/Web/Table/CoreApiObjectsTable.php60
-rw-r--r--library/Director/Web/Table/CoreApiPrototypesTable.php43
-rw-r--r--library/Director/Web/Table/CustomvarTable.php102
-rw-r--r--library/Director/Web/Table/CustomvarVariantsTable.php125
-rw-r--r--library/Director/Web/Table/DatafieldCategoryTable.php64
-rw-r--r--library/Director/Web/Table/DatafieldTable.php118
-rw-r--r--library/Director/Web/Table/DatalistEntryTable.php73
-rw-r--r--library/Director/Web/Table/DatalistTable.php41
-rw-r--r--library/Director/Web/Table/DbHelper.php67
-rw-r--r--library/Director/Web/Table/Dependency/DependencyInfoTable.php101
-rw-r--r--library/Director/Web/Table/Dependency/Html.php74
-rw-r--r--library/Director/Web/Table/DependencyTemplateUsageTable.php22
-rw-r--r--library/Director/Web/Table/DeploymentLogTable.php90
-rw-r--r--library/Director/Web/Table/FilterableByUsage.php10
-rw-r--r--library/Director/Web/Table/GeneratedConfigFileTable.php120
-rw-r--r--library/Director/Web/Table/GroupMemberTable.php201
-rw-r--r--library/Director/Web/Table/HostTemplateUsageTable.php22
-rw-r--r--library/Director/Web/Table/IcingaAppliedServiceTable.php49
-rw-r--r--library/Director/Web/Table/IcingaCommandArgumentTable.php89
-rw-r--r--library/Director/Web/Table/IcingaHostAppliedForServiceTable.php117
-rw-r--r--library/Director/Web/Table/IcingaHostAppliedServicesTable.php207
-rw-r--r--library/Director/Web/Table/IcingaHostsMatchingFilterTable.php71
-rw-r--r--library/Director/Web/Table/IcingaObjectDatafieldTable.php87
-rw-r--r--library/Director/Web/Table/IcingaScheduledDowntimeRangeTable.php67
-rw-r--r--library/Director/Web/Table/IcingaServiceSetHostTable.php64
-rw-r--r--library/Director/Web/Table/IcingaServiceSetServiceTable.php259
-rw-r--r--library/Director/Web/Table/IcingaTimePeriodRangeTable.php61
-rw-r--r--library/Director/Web/Table/ImportedrowsTable.php103
-rw-r--r--library/Director/Web/Table/ImportrunTable.php90
-rw-r--r--library/Director/Web/Table/ImportsourceHookTable.php107
-rw-r--r--library/Director/Web/Table/ImportsourceTable.php63
-rw-r--r--library/Director/Web/Table/JobTable.php82
-rw-r--r--library/Director/Web/Table/NotificationTemplateUsageTable.php22
-rw-r--r--library/Director/Web/Table/ObjectSetTable.php211
-rw-r--r--library/Director/Web/Table/ObjectsTable.php315
-rw-r--r--library/Director/Web/Table/ObjectsTableApiUser.php13
-rw-r--r--library/Director/Web/Table/ObjectsTableCommand.php67
-rw-r--r--library/Director/Web/Table/ObjectsTableEndpoint.php86
-rw-r--r--library/Director/Web/Table/ObjectsTableHost.php40
-rw-r--r--library/Director/Web/Table/ObjectsTableHostTemplateChoice.php27
-rw-r--r--library/Director/Web/Table/ObjectsTableService.php219
-rw-r--r--library/Director/Web/Table/ObjectsTableZone.php13
-rw-r--r--library/Director/Web/Table/PropertymodifierTable.php145
-rw-r--r--library/Director/Web/Table/QuickTable.php547
-rw-r--r--library/Director/Web/Table/ReadOnlyFormAvpTable.php113
-rw-r--r--library/Director/Web/Table/ServiceTemplateUsageTable.php27
-rw-r--r--library/Director/Web/Table/SyncRunTable.php90
-rw-r--r--library/Director/Web/Table/SyncpropertyTable.php97
-rw-r--r--library/Director/Web/Table/SyncruleTable.php67
-rw-r--r--library/Director/Web/Table/TableLoader.php34
-rw-r--r--library/Director/Web/Table/TableWithBranchSupport.php69
-rw-r--r--library/Director/Web/Table/TemplateUsageTable.php157
-rw-r--r--library/Director/Web/Table/TemplatesTable.php156
-rw-r--r--library/Director/Web/Tabs/DataTabs.php34
-rw-r--r--library/Director/Web/Tabs/ImportTabs.php30
-rw-r--r--library/Director/Web/Tabs/ImportsourceTabs.php58
-rw-r--r--library/Director/Web/Tabs/InfraTabs.php49
-rw-r--r--library/Director/Web/Tabs/MainTabs.php85
-rw-r--r--library/Director/Web/Tabs/ObjectTabs.php160
-rw-r--r--library/Director/Web/Tabs/ObjectsTabs.php85
-rw-r--r--library/Director/Web/Tabs/SyncRuleTabs.php54
-rw-r--r--library/Director/Web/Tree/InspectTreeRenderer.php97
-rw-r--r--library/Director/Web/Tree/TemplateTreeRenderer.php91
-rw-r--r--library/Director/Web/Widget/AbstractList.php40
-rw-r--r--library/Director/Web/Widget/ActivityLogInfo.php634
-rw-r--r--library/Director/Web/Widget/AdditionalTableActions.php158
-rw-r--r--library/Director/Web/Widget/BackgroundDaemonDetails.php131
-rw-r--r--library/Director/Web/Widget/BranchedObjectHint.php69
-rw-r--r--library/Director/Web/Widget/BranchedObjectsHint.php27
-rw-r--r--library/Director/Web/Widget/Daemon/BackgroundDaemonState.php57
-rw-r--r--library/Director/Web/Widget/DeployedConfigInfoHeader.php101
-rw-r--r--library/Director/Web/Widget/DeploymentInfo.php169
-rw-r--r--library/Director/Web/Widget/Documentation.php97
-rw-r--r--library/Director/Web/Widget/HealthCheckPluginOutput.php94
-rw-r--r--library/Director/Web/Widget/IcingaConfigDiff.php58
-rw-r--r--library/Director/Web/Widget/IcingaObjectInspection.php254
-rw-r--r--library/Director/Web/Widget/ImportSourceDetails.php83
-rw-r--r--library/Director/Web/Widget/InspectPackages.php174
-rw-r--r--library/Director/Web/Widget/JobDetails.php69
-rw-r--r--library/Director/Web/Widget/ListItem.php26
-rw-r--r--library/Director/Web/Widget/NotInBranchedHint.php23
-rw-r--r--library/Director/Web/Widget/OrderedList.php8
-rw-r--r--library/Director/Web/Widget/ShowConfigFile.php106
-rw-r--r--library/Director/Web/Widget/SyncRunDetails.php129
-rw-r--r--library/Director/Web/Widget/UnorderedList.php8
-rw-r--r--library/Director/Web/Window.php13
529 files changed, 68905 insertions, 0 deletions
diff --git a/library/Director/Acl.php b/library/Director/Acl.php
new file mode 100644
index 0000000..4aa2bd2
--- /dev/null
+++ b/library/Director/Acl.php
@@ -0,0 +1,90 @@
+<?php
+
+namespace Icinga\Module\Director;
+
+use Icinga\Authentication\Auth;
+use Icinga\Authentication\Role;
+use Icinga\Exception\AuthenticationException;
+
+class Acl
+{
+ /** @var Auth */
+ protected $auth;
+
+ /** @var self */
+ private static $instance;
+
+ /**
+ * @return self
+ */
+ public static function instance()
+ {
+ if (self::$instance === null) {
+ self::$instance = new static(Auth::getInstance());
+ }
+
+ return self::$instance;
+ }
+
+ /**
+ * Acl constructor
+ *
+ * @param Auth $auth
+ */
+ public function __construct(Auth $auth)
+ {
+ $this->auth = $auth;
+ }
+
+ /**
+ * Whether the given permission is available
+ *
+ * @param $name
+ *
+ * @return bool
+ */
+ public function hasPermission($name)
+ {
+ return $this->auth->hasPermission($name);
+ }
+
+ /**
+ * List all given roles
+ *
+ * @return array
+ */
+ public function listRoleNames()
+ {
+ return array_map(
+ array($this, 'getNameForRole'),
+ $this->getUser()->getRoles()
+ );
+ }
+
+ /**
+ * Get our user object, throws auth error if not available
+ *
+ * @return \Icinga\User
+ * @throws AuthenticationException
+ */
+ protected function getUser()
+ {
+ if (null === ($user = $this->auth->getUser())) {
+ throw new AuthenticationException('Authenticated user required');
+ }
+
+ return $user;
+ }
+
+ /**
+ * Get the name for a given role
+ *
+ * @param Role $role
+ *
+ * @return string
+ */
+ protected function getNameForRole(Role $role)
+ {
+ return $role->getName();
+ }
+}
diff --git a/library/Director/Application/Dependency.php b/library/Director/Application/Dependency.php
new file mode 100644
index 0000000..0100e69
--- /dev/null
+++ b/library/Director/Application/Dependency.php
@@ -0,0 +1,113 @@
+<?php
+
+namespace Icinga\Module\Director\Application;
+
+class Dependency
+{
+ /** @var string */
+ protected $name;
+
+ /** @var string|null */
+ protected $installedVersion;
+
+ /** @var bool|null */
+ protected $enabled;
+
+ /** @var string */
+ protected $operator;
+
+ /** @var string */
+ protected $requiredVersion;
+
+ /** @var string */
+ protected $requirement;
+
+ /**
+ * Dependency constructor.
+ * @param string $name Usually a module name
+ * @param string $requirement e.g. >=1.7.0
+ * @param string $installedVersion
+ * @param bool $enabled
+ */
+ public function __construct($name, $requirement, $installedVersion = null, $enabled = null)
+ {
+ $this->name = $name;
+ $this->setRequirement($requirement);
+ if ($installedVersion !== null) {
+ $this->setInstalledVersion($installedVersion);
+ }
+ if ($enabled !== null) {
+ $this->setEnabled($enabled);
+ }
+ }
+
+ public function setRequirement($requirement)
+ {
+ if (preg_match('/^([<>=]+)\s*v?(\d+\.\d+\.\d+)$/', $requirement, $match)) {
+ $this->operator = $match[1];
+ $this->requiredVersion = $match[2];
+ $this->requirement = $requirement;
+ } else {
+ throw new \InvalidArgumentException("'$requirement' is not a valid version constraint");
+ }
+ }
+
+ /**
+ * @return bool
+ */
+ public function isInstalled()
+ {
+ return $this->installedVersion !== null;
+ }
+
+ /**
+ * @return string|null
+ */
+ public function getInstalledVersion()
+ {
+ return $this->installedVersion;
+ }
+
+ /**
+ * @param string $version
+ */
+ public function setInstalledVersion($version)
+ {
+ $this->installedVersion = ltrim($version, 'v'); // v0.6.0 VS 0.6.0
+ }
+
+ /**
+ * @return bool
+ */
+ public function isEnabled()
+ {
+ return $this->enabled === true;
+ }
+
+ /**
+ * @param bool $enabled
+ */
+ public function setEnabled($enabled = true)
+ {
+ $this->enabled = $enabled;
+ }
+
+ public function isSatisfied()
+ {
+ if (! $this->isInstalled() || ! $this->isEnabled()) {
+ return false;
+ }
+
+ return version_compare($this->installedVersion, $this->requiredVersion, $this->operator);
+ }
+
+ public function getName()
+ {
+ return $this->name;
+ }
+
+ public function getRequirement()
+ {
+ return $this->requirement;
+ }
+}
diff --git a/library/Director/Application/DependencyChecker.php b/library/Director/Application/DependencyChecker.php
new file mode 100644
index 0000000..d726b0b
--- /dev/null
+++ b/library/Director/Application/DependencyChecker.php
@@ -0,0 +1,73 @@
+<?php
+
+namespace Icinga\Module\Director\Application;
+
+use Icinga\Application\ApplicationBootstrap;
+use Icinga\Application\Modules\Module;
+use Icinga\Application\Version;
+
+class DependencyChecker
+{
+ /** @var ApplicationBootstrap */
+ protected $app;
+
+ /** @var \Icinga\Application\Modules\Manager */
+ protected $modules;
+
+ public function __construct(ApplicationBootstrap $app)
+ {
+ $this->app = $app;
+ $this->modules = $app->getModuleManager();
+ }
+
+ /**
+ * @param Module $module
+ * @return Dependency[]
+ */
+ public function getDependencies(Module $module)
+ {
+ $dependencies = [];
+ $isV290 = version_compare(Version::VERSION, '2.9.0', '>=');
+ foreach ($module->getDependencies() as $moduleName => $required) {
+ if ($isV290 && in_array($moduleName, ['ipl', 'reactbundle'], true)) {
+ continue;
+ }
+ $dependency = new Dependency($moduleName, $required);
+ $dependency->setEnabled($this->modules->hasEnabled($moduleName));
+ if ($this->modules->hasInstalled($moduleName)) {
+ $dependency->setInstalledVersion($this->modules->getModule($moduleName, false)->getVersion());
+ }
+ $dependencies[] = $dependency;
+ }
+ if ($isV290) {
+ $libs = $this->app->getLibraries();
+ foreach ($module->getRequiredLibraries() as $libraryName => $required) {
+ $dependency = new Dependency($libraryName, $required);
+ if ($libs->has($libraryName)) {
+ $dependency->setInstalledVersion($libs->get($libraryName)->getVersion());
+ $dependency->setEnabled();
+ }
+ $dependencies[] = $dependency;
+ }
+ }
+
+ return $dependencies;
+ }
+
+ // if (version_compare(Version::VERSION, '2.9.0', 'ge')) {
+ // }
+ /**
+ * @param Module $module
+ * @return bool
+ */
+ public function satisfiesDependencies(Module $module)
+ {
+ foreach ($this->getDependencies($module) as $dependency) {
+ if (! $dependency->isSatisfied()) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/library/Director/Application/MemoryLimit.php b/library/Director/Application/MemoryLimit.php
new file mode 100644
index 0000000..beb0460
--- /dev/null
+++ b/library/Director/Application/MemoryLimit.php
@@ -0,0 +1,53 @@
+<?php
+
+namespace Icinga\Module\Director\Application;
+
+class MemoryLimit
+{
+ public static function raiseTo($string)
+ {
+ $current = static::getBytes();
+ $desired = static::parsePhpIniByteString($string);
+ if ($current !== -1 && $current < $desired) {
+ ini_set('memory_limit', $string);
+ }
+ }
+
+ public static function getBytes()
+ {
+ return static::parsePhpIniByteString((string) ini_get('memory_limit'));
+ }
+
+ /**
+ * Return Bytes from PHP shorthand bytes notation
+ *
+ * http://www.php.net/manual/en/faq.using.php#faq.using.shorthandbytes
+ *
+ * > The available options are K (for Kilobytes), M (for Megabytes) and G
+ * > (for Gigabytes), and are all case-insensitive. Anything else assumes
+ * > bytes.
+ *
+ * @param $string
+ * @return int
+ */
+ public static function parsePhpIniByteString($string)
+ {
+ $val = trim($string);
+
+ if (preg_match('/^(\d+)([KMG])$/', $val, $m)) {
+ $val = $m[1];
+ switch ($m[2]) {
+ case 'G':
+ $val *= 1024;
+ // Intentional fall-through
+ case 'M':
+ $val *= 1024;
+ // Intentional fall-through
+ case 'K':
+ $val *= 1024;
+ }
+ }
+
+ return intval($val);
+ }
+}
diff --git a/library/Director/CheckPlugin/Check.php b/library/Director/CheckPlugin/Check.php
new file mode 100644
index 0000000..d05f5a7
--- /dev/null
+++ b/library/Director/CheckPlugin/Check.php
@@ -0,0 +1,59 @@
+<?php
+
+namespace Icinga\Module\Director\CheckPlugin;
+
+use Exception;
+
+class Check extends CheckResults
+{
+ public function call(callable $check, $errorState = 'CRITICAL')
+ {
+ try {
+ $check();
+ } catch (Exception $e) {
+ $this->fail($e, $errorState);
+ }
+
+ return $this;
+ }
+
+ public function assertTrue($check, $message, $errorState = 'CRITICAL')
+ {
+ if ($this->makeBool($check, $message) === true) {
+ $this->succeed($message);
+ } else {
+ $this->fail($message, $errorState);
+ }
+
+ return $this;
+ }
+
+ public function assertFalse($check, $message, $errorState = 'CRITICAL')
+ {
+ if ($this->makeBool($check, $message) === false) {
+ $this->succeed($message);
+ } else {
+ $this->fail($message, $errorState);
+ }
+
+ return $this;
+ }
+
+ protected function makeBool($check, &$message)
+ {
+ if (is_callable($check)) {
+ try {
+ $check = $check();
+ } catch (Exception $e) {
+ $message .= ': ' . $e->getMessage();
+ return false;
+ }
+ }
+
+ if (! is_bool($check)) {
+ return null;
+ }
+
+ return $check;
+ }
+}
diff --git a/library/Director/CheckPlugin/CheckResult.php b/library/Director/CheckPlugin/CheckResult.php
new file mode 100644
index 0000000..cdf9b0d
--- /dev/null
+++ b/library/Director/CheckPlugin/CheckResult.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Icinga\Module\Director\CheckPlugin;
+
+class CheckResult
+{
+ protected $state;
+
+ protected $output;
+
+ public function __construct($output, $state = 0)
+ {
+ if ($state instanceof PluginState) {
+ $this->state = $state;
+ } else {
+ $this->state = new PluginState($state);
+ }
+
+ $this->output = $output;
+ }
+
+ public function getState()
+ {
+ return $this->state;
+ }
+
+ public function getOutput()
+ {
+ return $this->output;
+ }
+}
diff --git a/library/Director/CheckPlugin/CheckResults.php b/library/Director/CheckPlugin/CheckResults.php
new file mode 100644
index 0000000..7e20225
--- /dev/null
+++ b/library/Director/CheckPlugin/CheckResults.php
@@ -0,0 +1,150 @@
+<?php
+
+namespace Icinga\Module\Director\CheckPlugin;
+
+use Exception;
+
+class CheckResults
+{
+ /** @var string */
+ protected $name;
+
+ /** @var PluginState */
+ protected $state;
+
+ /** @var CheckResult[] */
+ protected $results = [];
+
+ protected $stateCounters = [
+ 0 => 0,
+ 1 => 0,
+ 2 => 0,
+ 3 => 0,
+ ];
+
+ public function __construct($name)
+ {
+ $this->name = $name;
+ $this->state = new PluginState(0);
+ }
+
+ public function getName()
+ {
+ return $this->name;
+ }
+
+ public function add(CheckResult $result)
+ {
+ $this->results[] = $result;
+ $this->state->raise($result->getState());
+ $this->stateCounters[$result->getState()->getNumeric()]++;
+
+ return $this;
+ }
+
+ public function getStateCounters()
+ {
+ return $this->stateCounters;
+ }
+
+ public function getProblemSummary()
+ {
+ $summary = [];
+ for ($i = 1; $i <= 3; $i++) {
+ $count = $this->stateCounters[$i];
+ if ($count === 0) {
+ continue;
+ }
+ $summary[PluginState::create($i)->getName()] = $count;
+ }
+
+ return $summary;
+ }
+
+ public function getStateSummaryString()
+ {
+ $summary = [sprintf(
+ '%d tests OK',
+ $this->stateCounters[0]
+ )];
+
+ for ($i = 1; $i <= 3; $i++) {
+ $count = $this->stateCounters[$i];
+ if ($count === 0) {
+ continue;
+ }
+ $summary[] = sprintf(
+ '%dx %s',
+ $count,
+ PluginState::create($i)->getName()
+ );
+ }
+
+ return implode(', ', $summary);
+ }
+
+ public function getOutput()
+ {
+ $output = sprintf(
+ "%s: %s\n",
+ $this->name,
+ $this->getStateSummaryString()
+ );
+
+ foreach ($this->results as $result) {
+ $output .= sprintf(
+ "[%s] %s\n",
+ $result->getState()->getName(),
+ $result->getOutput()
+ );
+ }
+
+ return $output;
+ }
+
+ public function getResults()
+ {
+ return $this->results;
+ }
+
+ public function getState()
+ {
+ return $this->state;
+ }
+
+ public function hasProblems()
+ {
+ return $this->getState()->getNumeric() !== 0;
+ }
+
+ public function hasErrors()
+ {
+ $state = $this->getState()->getNumeric();
+ return $state !== 0 && $state !== 1;
+ }
+
+ public function succeed($message)
+ {
+ $this->add(new CheckResult($message));
+
+ return $this;
+ }
+
+ public function warn($message)
+ {
+ $this->add(new CheckResult($message, 1));
+
+ return $this;
+ }
+
+ public function fail($message, $errorState = 'CRITICAL')
+ {
+ if ($message instanceof Exception) {
+ $message = $message->getMessage();
+ }
+
+ $this->add(new CheckResult($message, $errorState));
+
+ return $this;
+ }
+}
diff --git a/library/Director/CheckPlugin/PluginState.php b/library/Director/CheckPlugin/PluginState.php
new file mode 100644
index 0000000..d68ec70
--- /dev/null
+++ b/library/Director/CheckPlugin/PluginState.php
@@ -0,0 +1,114 @@
+<?php
+
+namespace Icinga\Module\Director\CheckPlugin;
+
+use Icinga\Exception\ProgrammingError;
+
+class PluginState
+{
+ protected static $stateCodes = [
+ 'UNKNOWN' => 3,
+ 'CRITICAL' => 2,
+ 'WARNING' => 1,
+ 'OK' => 0,
+ ];
+
+ protected static $stateNames = [
+ 'OK',
+ 'WARNING',
+ 'CRITICAL',
+ 'UNKNOWN',
+ ];
+
+ protected static $sortSeverity = [0, 1, 3, 2];
+
+ /** @var int */
+ protected $state;
+
+ public function __construct($state)
+ {
+ $this->set($state);
+ }
+
+ public function isProblem()
+ {
+ return $this->state > 0;
+ }
+
+ public function set($state)
+ {
+ $this->state = $this->getNumericStateFor($state);
+ }
+
+ public function getNumeric()
+ {
+ return $this->state;
+ }
+
+ public function getSortSeverity()
+ {
+ return static::getSortSeverityFor($this->getNumeric());
+ }
+
+ public function getName()
+ {
+ return self::$stateNames[$this->getNumeric()];
+ }
+
+ public function raise(PluginState $state)
+ {
+ if ($this->getSortSeverity() < $state->getSortSeverity()) {
+ $this->state = $state->getNumeric();
+ }
+
+ return $this;
+ }
+
+ public static function create($state)
+ {
+ return new static($state);
+ }
+
+ public static function ok()
+ {
+ return new static(0);
+ }
+
+ public static function warning()
+ {
+ return new static(1);
+ }
+
+ public static function critical()
+ {
+ return new static(2);
+ }
+
+ public static function unknown()
+ {
+ return new static(3);
+ }
+
+ protected static function getNumericStateFor($state)
+ {
+ if ((is_int($state) || ctype_digit($state)) && $state >= 0 && $state <= 3) {
+ return (int) $state;
+ } elseif (is_string($state) && array_key_exists($state, self::$stateCodes)) {
+ return self::$stateCodes[$state];
+ } else {
+ throw new ProgrammingError('Expected valid state, got: %s', $state);
+ }
+ }
+
+ protected static function getSortSeverityFor($state)
+ {
+ if (array_key_exists($state, self::$sortSeverity)) {
+ return self::$sortSeverity[$state];
+ } else {
+ throw new ProgrammingError(
+ 'Unable to retrieve sort severity for invalid state: %s',
+ $state
+ );
+ }
+ }
+}
diff --git a/library/Director/CheckPlugin/Range.php b/library/Director/CheckPlugin/Range.php
new file mode 100644
index 0000000..d7b582e
--- /dev/null
+++ b/library/Director/CheckPlugin/Range.php
@@ -0,0 +1,101 @@
+<?php
+
+namespace Icinga\Module\Director\CheckPlugin;
+
+use Icinga\Exception\ConfigurationError;
+
+class Range
+{
+ /** @var float|null */
+ protected $start = 0;
+
+ /** @var float|null */
+ protected $end = null;
+
+ /** @var bool */
+ protected $mustBeWithinRange = true;
+
+ public function __construct($start = 0, $end = null, $mustBeWithinRange = true)
+ {
+ $this->start = $start;
+ $this->end = $end;
+ $this->mustBeWithinRange = $mustBeWithinRange;
+ }
+
+ public function valueIsValid($value)
+ {
+ if ($this->valueIsWithinRange($value)) {
+ return $this->valueMustBeWithinRange();
+ } else {
+ return ! $this->valueMustBeWithinRange();
+ }
+ }
+
+ public function valueIsWithinRange($value)
+ {
+ if ($this->start !== null && $value < $this->start) {
+ return false;
+ }
+ if ($this->end !== null && $value > $this->end) {
+ return false;
+ }
+
+ return true;
+ }
+
+ public function valueMustBeWithinRange()
+ {
+ return $this->mustBeWithinRange;
+ }
+
+ /**
+ * @param $any
+ * @return static
+ */
+ public static function wantRange($any)
+ {
+ if ($any instanceof static) {
+ return $any;
+ } else {
+ return static::parse($any);
+ }
+ }
+
+ /**
+ * @param $string
+ * @return static
+ * @throws ConfigurationError
+ */
+ public static function parse($string)
+ {
+ $string = str_replace(' ', '', $string);
+ $value = '[-+]?[\d\.]+';
+ $valueRe = "$value(?:e$value)?";
+ $regex = "/^(@)?($valueRe|~)(:$valueRe|~)?/";
+ if (! preg_match($regex, $string, $match)) {
+ throw new ConfigurationError('Invalid range definition: %s', $string);
+ }
+
+ $inside = $match[1] === '@';
+
+ if (strlen($match[3]) === 0) {
+ $start = 0;
+ $end = static::parseValue($match[2]);
+ } else {
+ $start = static::parseValue($match[2]);
+ $end = static::parseValue($match[3]);
+ }
+ $range = new static($start, $end, $inside);
+
+ return $range;
+ }
+
+ protected static function parseValue($value)
+ {
+ if ($value === '~') {
+ return null;
+ } else {
+ return $value;
+ }
+ }
+}
diff --git a/library/Director/CheckPlugin/Threshold.php b/library/Director/CheckPlugin/Threshold.php
new file mode 100644
index 0000000..76aac4e
--- /dev/null
+++ b/library/Director/CheckPlugin/Threshold.php
@@ -0,0 +1,47 @@
+<?php
+
+namespace Icinga\Module\Director\CheckPlugin;
+
+class Threshold
+{
+ /** @var Range */
+ protected $warning;
+
+ /** @var Range */
+ protected $critical;
+
+ public function __construct($warning = null, $critical = null)
+ {
+ if ($warning !== null) {
+ $this->warning = Range::wantRange($warning);
+ }
+
+ if ($critical !== null) {
+ $this->critical = Range::wantRange($critical);
+ }
+ }
+
+ public static function check($value, $message, $warning = null, $critical = null)
+ {
+ $threshold = new static($warning, $critical);
+ $state = $threshold->checkValue($value);
+ return new CheckResult($message, $state);
+ }
+
+ public function checkValue($value)
+ {
+ if ($this->critical !== null) {
+ if (! $this->critical->valueIsValid($value)) {
+ return PluginState::critical();
+ }
+ }
+
+ if ($this->warning !== null) {
+ if (! $this->warning->valueIsValid($value)) {
+ return PluginState::warning();
+ }
+ }
+
+ return PluginState::ok();
+ }
+}
diff --git a/library/Director/Cli/Command.php b/library/Director/Cli/Command.php
new file mode 100644
index 0000000..69d61b1
--- /dev/null
+++ b/library/Director/Cli/Command.php
@@ -0,0 +1,115 @@
+<?php
+
+namespace Icinga\Module\Director\Cli;
+
+use gipfl\Json\JsonDecodeException;
+use gipfl\Json\JsonString;
+use Icinga\Cli\Command as CliCommand;
+use Icinga\Module\Director\Application\MemoryLimit;
+use Icinga\Module\Director\Core\CoreApi;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Objects\IcingaEndpoint;
+use Icinga\Application\Config;
+use RuntimeException;
+
+class Command extends CliCommand
+{
+ /** @var Db */
+ protected $db;
+
+ /** @var CoreApi */
+ private $api;
+
+ protected function renderJson($object, $pretty = true)
+ {
+ return JsonString::encode($object, $pretty ? JSON_PRETTY_PRINT : null) . "\n";
+ }
+
+ /**
+ * @param $json
+ * @return mixed
+ */
+ protected function parseJson($json)
+ {
+ try {
+ return JsonString::decode($json);
+ } catch (JsonDecodeException $e) {
+ $this->fail('Invalid JSON: %s', $e->getMessage());
+ }
+ }
+
+ /**
+ * @param string $msg
+ * @return never-return
+ */
+ public function fail($msg)
+ {
+ $args = func_get_args();
+ array_shift($args);
+ if (count($args)) {
+ $msg = vsprintf($msg, $args);
+ }
+ echo $this->screen->colorize("ERROR", 'red') . ": $msg\n";
+ exit(1);
+ }
+
+ /**
+ * @param null $endpointName
+ * @return CoreApi|\Icinga\Module\Director\Core\LegacyDeploymentApi
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ protected function api($endpointName = null)
+ {
+ if ($this->api === null) {
+ if ($endpointName === null) {
+ $endpoint = $this->db()->getDeploymentEndpoint();
+ } else {
+ $endpoint = IcingaEndpoint::load($endpointName, $this->db());
+ }
+
+ $this->api = $endpoint->api();
+ }
+
+ return $this->api;
+ }
+
+ /**
+ * Raise PHP resource limits
+ *
+ * @return self;
+ */
+ protected function raiseLimits()
+ {
+ MemoryLimit::raiseTo('1024M');
+
+ ini_set('max_execution_time', 0);
+ if (version_compare(PHP_VERSION, '7.0.0') < 0) {
+ ini_set('zend.enable_gc', 0);
+ }
+
+ return $this;
+ }
+
+ /**
+ * @return Db
+ */
+ protected function db()
+ {
+ if ($this->db === null) {
+ $resourceName = $this->params->get('dbResourceName');
+
+ if ($resourceName === null) {
+ // Hint: not using $this->Config() intentionally. This allows
+ // CLI commands in other modules to use this as a base class.
+ $resourceName = Config::module('director')->get('db', 'resource');
+ }
+ if ($resourceName) {
+ $this->db = Db::fromResourceName($resourceName);
+ } else {
+ throw new RuntimeException('Director is not configured correctly');
+ }
+ }
+
+ return $this->db;
+ }
+}
diff --git a/library/Director/Cli/ObjectCommand.php b/library/Director/Cli/ObjectCommand.php
new file mode 100644
index 0000000..ca68213
--- /dev/null
+++ b/library/Director/Cli/ObjectCommand.php
@@ -0,0 +1,517 @@
+<?php
+
+namespace Icinga\Module\Director\Cli;
+
+use Icinga\Cli\Params;
+use Icinga\Exception\MissingParameterException;
+use Icinga\Module\Director\Data\Db\DbObject;
+use Icinga\Module\Director\Data\Exporter;
+use Icinga\Module\Director\Data\PropertyMangler;
+use Icinga\Module\Director\IcingaConfig\IcingaConfig;
+use Icinga\Module\Director\Objects\IcingaHost;
+use Icinga\Module\Director\Objects\IcingaObject;
+use InvalidArgumentException;
+
+class ObjectCommand extends Command
+{
+ protected $type;
+
+ private $name;
+
+ private $object;
+
+ private $experimentalFlags = array();
+
+ public function init()
+ {
+ $this->shiftExperimentalFlags();
+ }
+
+ /**
+ * Show a specific object
+ *
+ * Use this command to show single objects rendered as Icinga 2
+ * config or in JSON format.
+ *
+ * USAGE
+ *
+ * icingacli director <type> show <name> [options]
+ *
+ * OPTIONS
+ *
+ * --resolved Resolve all inherited properties and show a flat
+ * object
+ * --json Use JSON format
+ * --no-pretty JSON is pretty-printed per default. Use this flag
+ * to enforce un-formatted JSON
+ * --no-defaults Per default JSON output ships null or default
+ * values. This flag skips those properties
+ * --with-services For hosts only, also shows attached services
+ * --all-services For hosts only, show applied and inherited services
+ * too
+ */
+ public function showAction()
+ {
+ $db = $this->db();
+ $object = $this->getObject();
+ $exporter = new Exporter($db);
+ $resolve = (bool) $this->params->shift('resolved');
+ $withServices = (bool) $this->params->get('with-services');
+ $allServices = (bool) $this->params->get('all-services');
+ if ($withServices) {
+ if (!$object instanceof IcingaHost) {
+ $this->fail('--with-services is available for Hosts only');
+ }
+ $exporter->enableHostServices();
+ }
+ if ($allServices) {
+ if (!$object instanceof IcingaHost) {
+ $this->fail('--all-services is available for Hosts only');
+ }
+ $exporter->serviceLoader()->resolveHostServices();
+ }
+
+ $exporter->resolveObjects($resolve);
+ $exporter->showDefaults($this->params->shift('no-defaults', false));
+
+ if ($this->params->shift('json')) {
+ echo $this->renderJson($exporter->export($object), !$this->params->shift('no-pretty'));
+ } else {
+ $config = new IcingaConfig($db);
+ if ($resolve) {
+ $object = $object::fromPlainObject($object->toPlainObject(true, false, null, false), $db);
+ }
+ $object->renderToConfig($config);
+ if ($withServices) {
+ foreach ($exporter->serviceLoader()->fetchServicesForHost($object) as $service) {
+ $service->renderToConfig($config);
+ }
+ }
+ foreach ($config->getFiles() as $filename => $content) {
+ printf("/** %s **/\n\n", $filename);
+ echo $content;
+ }
+ }
+ }
+
+ /**
+ * Create a new object
+ *
+ * Use this command to create a new Icinga object
+ *
+ * USAGE
+ *
+ * icingacli director <type> create [<name>] [options]
+ *
+ * OPTIONS
+ *
+ * --<key> <value> Provide all properties as single command line
+ * options
+ * --json Otherwise provide all options as a JSON string
+ *
+ * EXAMPLES
+ *
+ * icingacli director host create localhost \
+ * --imports generic-host \
+ * --address 127.0.0.1 \
+ * --vars.location 'My datacenter'
+ *
+ * icingacli director host create localhost \
+ * --json '{ "address": "127.0.0.1" }'
+ */
+ public function createAction()
+ {
+ $type = $this->getType();
+ $props = $this->getObjectProperties();
+ $name = $props['object_name'];
+ $object = IcingaObject::createByType($type, $props, $this->db());
+
+ if ($object->store()) {
+ printf("%s '%s' has been created\n", $type, $name);
+ if ($this->hasExperimental('live-creation')) {
+ if ($this->api()->createObjectAtRuntime($object)) {
+ echo "Live creation for '$name' succeeded\n";
+ } else {
+ echo "Live creation for '$name' succeeded\n";
+ exit(1);
+ }
+
+ if ($type === 'Host' && $this->hasExperimental('immediate-check')) {
+ echo "Waiting for check result...";
+ flush();
+ if ($res = $this->api()->checkHostAndWaitForResult($name)) {
+ echo " done\n" . $res->output . "\n";
+ } else {
+ echo "TIMEOUT\n";
+ }
+ }
+ }
+
+ exit(0);
+ } else {
+ printf("%s '%s' has not been created\n", $type, $name);
+ exit(1);
+ }
+ }
+
+ /**
+ * Modify an existing objects properties
+ *
+ * Use this command to modify specific properties of an existing
+ * Icinga object
+ *
+ * USAGE
+ *
+ * icingacli director <type> set <name> [options]
+ *
+ * OPTIONS
+ *
+ * --<key> <value> Provide all properties as single command line
+ * options
+ * --append-<key> <value> Appends to array values, like `imports`,
+ * `groups` or `vars.system_owners`
+ * --remove-<key> [<value>] Remove a specific property, eventually only
+ * when matching `value`. In case the property is an
+ * array it will remove just `value` when given
+ * --json Otherwise provide all options as a JSON string
+ * --replace Replace all object properties with the given ones
+ * --auto-create Create the object in case it does not exist
+ *
+ * EXAMPLES
+ *
+ * icingacli director host set localhost \
+ * --address 127.0.0.2 \
+ * --vars.location 'Somewhere else'
+ *
+ * icingacli director host set localhost \
+ * --json '{ "address": "127.0.0.2" }'
+ */
+ public function setAction()
+ {
+ $name = $this->getName();
+ $type = $this->getType();
+
+ if ($this->params->shift('auto-create') && ! $this->exists($name)) {
+ $action = 'created';
+ $object = $this->create($type, $name);
+ } else {
+ $action = 'modified';
+ $object = $this->getObject();
+ }
+
+ $appends = self::stripPrefixedProperties($this->params, 'append-');
+ $remove = self::stripPrefixedProperties($this->params, 'remove-');
+
+ if ($this->params->shift('replace')) {
+ $object->replaceWith($this->create($type, $name, $this->remainingParams()));
+ } else {
+ $object->setProperties($this->remainingParams());
+ }
+
+ PropertyMangler::appendToArrayProperties($object, $appends);
+ PropertyMangler::removeProperties($object, $remove);
+ $this->persistChanges($object, $type, $name, $action);
+ }
+
+ protected function persistChanges(DbObject $object, $type, $name, $action)
+ {
+ if ($object->hasBeenModified() && $object->store()) {
+ printf("%s '%s' has been %s\n", $type, $name, $action);
+ exit(0);
+ }
+
+ printf("%s '%s' has not been modified\n", $type, $name);
+ exit(0);
+ }
+
+ /**
+ * Delete a specific object
+ *
+ * Use this command to delete a single Icinga object
+ *
+ * USAGE
+ *
+ * icingacli director <type> delete <name>
+ *
+ * EXAMPLES
+ *
+ * icingacli director host delete localhost2
+ *
+ * icingacli director host delete localhost{3..8}
+ */
+ public function deleteAction()
+ {
+ $type = $this->getType();
+
+ foreach ($this->shiftOneOrMoreNames() as $name) {
+ if ($this->load($name)->delete()) {
+ printf("%s '%s' has been deleted\n", $type, $name);
+ } else {
+ printf("Something went wrong while deleting %s '%s'\n", $type, $name);
+ exit(1);
+ }
+
+ $this->object = null;
+ }
+ exit(0);
+ }
+
+ /**
+ * Whether a specific object exists
+ *
+ * Use this command to find out whether a single Icinga object exists
+ *
+ * USAGE
+ *
+ * icingacli director <type> exists <name>
+ */
+ public function existsAction()
+ {
+ $name = $this->getName();
+ $type = $this->getType();
+ if ($this->exists($name)) {
+ printf("%s '%s' exists\n", $type, $name);
+ exit(0);
+ } else {
+ printf("%s '%s' does not exist\n", $type, $name);
+ exit(1);
+ }
+ }
+
+ /**
+ * Clone an existing object
+ *
+ * Use this command to clone a specific object
+ *
+ * USAGE
+ *
+ * icingacli director <type> clone <name> --from <original> [options]
+ *
+ * OPTIONS
+ * --from <original> The name of the object you want to clone
+ * --<key> <value> Override specific properties while cloning
+ * --replace In case an object <name> already exists replace
+ * it with the clone
+ * --flat Do no keep inherited properties but create a flat
+ * object with all resolved/inherited properties
+ *
+ * EXAMPLES
+ *
+ * icingacli director host clone localhost2 --from localhost
+ *
+ * icingacli director host clone localhost{3..8} --from localhost2
+ *
+ * icingacli director host clone localhost3 --from localhost \
+ * --address 127.0.0.3
+ */
+ public function cloneAction()
+ {
+ $fromName = $this->params->shiftRequired('from');
+ $from = $this->load($fromName);
+
+ // $name = $this->getName();
+ $type = $this->getType();
+
+ $resolve = $this->params->shift('flat');
+ $replace = $this->params->shift('replace');
+
+ $from->setProperties($this->remainingParams());
+
+ foreach ($this->shiftOneOrMoreNames() as $name) {
+ $object = $from::fromPlainObject(
+ $from->toPlainObject($resolve),
+ $from->getConnection()
+ );
+
+ $object->set('object_name', $name);
+
+ if ($replace && $this->exists($name)) {
+ $object = $this->load($name)->replaceWith($object);
+ }
+
+ if ($object->hasBeenModified() && $object->store()) {
+ printf("%s '%s' has been cloned from %s\n", $type, $name, $fromName);
+ } else {
+ printf("%s '%s' has not been modified\n", $this->getType(), $name);
+ }
+ }
+
+ exit(0);
+ }
+
+ protected static function stripPrefixedProperties(Params $params, $prefix = 'append-')
+ {
+ $appends = [];
+ $len = strlen($prefix);
+
+ foreach ($params->getParams() as $key => $value) {
+ if (substr($key, 0, $len) === $prefix) {
+ $appends[substr($key, $len)] = $value;
+ }
+ }
+
+ foreach ($appends as $key => $value) {
+ $params->shift("$prefix$key");
+ }
+
+ return $appends;
+ }
+
+ protected function getObjectProperties()
+ {
+ $name = $this->params->shift();
+
+ $props = $this->remainingParams();
+ if (! array_key_exists('object_type', $props)) {
+ $props['object_type'] = 'object';
+ }
+
+ // Normalize object_name, compare to given name
+ if ($name) {
+ if (array_key_exists('object_name', $props)) {
+ if ($name !== $props['object_name']) {
+ $this->fail(sprintf(
+ "Name '%s' conflicts with object_name '%s'\n",
+ $name,
+ $props['object_name']
+ ));
+ }
+ } else {
+ $props['object_name'] = $name;
+ }
+ } else {
+ if (! array_key_exists('object_name', $props)) {
+ $this->fail('Cannot create an object with at least an object name');
+ }
+ }
+
+ return $props;
+ }
+
+ protected function shiftOneOrMoreNames()
+ {
+ $names = array();
+ while ($name = $this->params->shift()) {
+ $names[] = $name;
+ }
+
+ if (empty($names)) {
+ throw new MissingParameterException('Required object name is missing');
+ }
+
+ return $names;
+ }
+
+ protected function remainingParams()
+ {
+ if ($json = $this->params->shift('json')) {
+ if ($json === true) {
+ $json = $this->readFromStdin();
+ if ($json === null) {
+ $this->fail('Please pass JSON either via STDIN or via --json');
+ }
+ }
+ return (array) $this->parseJson($json);
+ } else {
+ return $this->params->getParams();
+ }
+ }
+
+ protected function readFromStdin()
+ {
+ if (!defined('STDIN')) {
+ define('STDIN', fopen('php://stdin', 'r'));
+ }
+ $inputIsTty = function_exists('posix_isatty') && posix_isatty(STDIN);
+ if ($inputIsTty) {
+ return null;
+ }
+
+ $stdin = file_get_contents('php://stdin');
+ if (strlen($stdin) === 0) {
+ return null;
+ }
+
+ return $stdin;
+ }
+
+ protected function exists($name)
+ {
+ return IcingaObject::existsByType(
+ $this->getType(),
+ $name,
+ $this->db()
+ );
+ }
+
+ protected function load($name)
+ {
+ return IcingaObject::loadByType(
+ $this->getType(),
+ $name,
+ $this->db()
+ );
+ }
+
+ protected function create($type, $name, $properties = [])
+ {
+ return IcingaObject::createByType($type, $properties + [
+ 'object_type' => 'object',
+ 'object_name' => $name
+ ], $this->db());
+ }
+
+ /**
+ * @return IcingaObject
+ */
+ protected function getObject()
+ {
+ if ($this->object === null) {
+ $this->object = $this->load($this->getName());
+ }
+
+ return $this->object;
+ }
+
+ protected function getType()
+ {
+ if ($this->type === null) {
+ // Extract the command class name...
+ $className = substr(strrchr(get_class($this), '\\'), 1);
+ // ...and strip the Command extension
+ $this->type = substr($className, 0, -7);
+ }
+
+ return $this->type;
+ }
+
+ protected function getName()
+ {
+ if ($this->name === null) {
+ $name = $this->params->shift();
+ if (! $name) {
+ throw new InvalidArgumentException('Object name parameter is required');
+ }
+
+ $this->name = $name;
+ }
+
+ return $this->name;
+ }
+
+ protected function hasExperimental($flag)
+ {
+ return array_key_exists($flag, $this->experimentalFlags);
+ }
+
+ protected function shiftExperimentalFlags()
+ {
+ if ($flags = $this->params->shift('experimental')) {
+ foreach (preg_split('/,/', $flags) as $flag) {
+ $this->experimentalFlags[$flag] = true;
+ }
+ }
+
+ return $this;
+ }
+}
diff --git a/library/Director/Cli/ObjectsCommand.php b/library/Director/Cli/ObjectsCommand.php
new file mode 100644
index 0000000..3e0844a
--- /dev/null
+++ b/library/Director/Cli/ObjectsCommand.php
@@ -0,0 +1,115 @@
+<?php
+
+namespace Icinga\Module\Director\Cli;
+
+use Icinga\Module\Director\Objects\IcingaObject;
+
+class ObjectsCommand extends Command
+{
+ protected $type;
+
+ private $objects;
+
+ /**
+ * List all objects of this type
+ *
+ * Use this command to get a list of all matching objects
+ *
+ * USAGE
+ *
+ * icingacli director <types> list [options]
+ *
+ * OPTIONS
+ *
+ * --json Use JSON format
+ * --no-pretty JSON is pretty-printed per default (for PHP >= 5.4)
+ * Use this flag to enforce unformatted JSON
+ */
+ public function listAction()
+ {
+ $db = $this->db();
+ $result = array();
+ foreach ($this->getObjects() as $o) {
+ $result[] = $o->getObjectName();
+ }
+
+ sort($result);
+
+ if ($this->params->shift('json')) {
+ echo $this->renderJson($result, !$this->params->shift('no-pretty'));
+ } else {
+ foreach ($result as $name) {
+ echo $name . "\n";
+ }
+ }
+ }
+
+ /**
+ * Fetch all objects of this type
+ *
+ * Use this command to fetch all matching objects
+ *
+ * USAGE
+ *
+ * icingacli director <types> fetch [options]
+ *
+ * OPTIONS
+ *
+ * --resolved Resolve all inherited properties and show a flat
+ * object
+ * --json Use JSON format
+ * --no-pretty JSON is pretty-printed per default (for PHP >= 5.4)
+ * Use this flag to enforce unformatted JSON
+ * --no-defaults Per default JSON output ships null or default values
+ * With this flag you will skip those properties
+ */
+ public function fetchAction()
+ {
+ $resolved = $this->params->shift('resolved');
+
+ if ($this->params->shift('json')) {
+ $noDefaults = $this->params->shift('no-defaults', false);
+ } else {
+ $this->fail('Currently only json is supported when fetching objects');
+ }
+
+ $db = $this->db();
+ $res = array();
+ foreach ($this->getObjects() as $object) {
+ if ($resolved) {
+ $object = $object::fromPlainObject($object->toPlainObject(true), $db);
+ }
+
+ $res[$object->getObjectName()] = $object->toPlainObject(false, $noDefaults);
+ }
+
+ echo $this->renderJson($res, !$this->params->shift('no-pretty'));
+ }
+
+ /**
+ * @return IcingaObject[]
+ */
+ protected function getObjects()
+ {
+ if ($this->objects === null) {
+ $this->objects = IcingaObject::loadAllByType(
+ $this->getType(),
+ $this->db()
+ );
+ }
+
+ return $this->objects;
+ }
+
+ protected function getType()
+ {
+ if ($this->type === null) {
+ // Extract the command class name...
+ $className = substr(strrchr(get_class($this), '\\'), 1);
+ // ...and strip the Command extension
+ $this->type = rtrim(substr($className, 0, -7), 's');
+ }
+
+ return $this->type;
+ }
+}
diff --git a/library/Director/Cli/PluginOutputBeautifier.php b/library/Director/Cli/PluginOutputBeautifier.php
new file mode 100644
index 0000000..18a18f2
--- /dev/null
+++ b/library/Director/Cli/PluginOutputBeautifier.php
@@ -0,0 +1,75 @@
+<?php
+
+namespace Icinga\Module\Director\Cli;
+
+use Icinga\Cli\Screen;
+
+class PluginOutputBeautifier
+{
+ /** @var Screen */
+ protected $screen;
+
+ protected $isTty;
+
+ protected $colorized;
+
+ public function __construct(Screen $screen)
+ {
+ $this->screen = $screen;
+ }
+
+ public static function beautify($string, Screen $screen)
+ {
+ $self = new static($screen);
+ if ($self->isTty()) {
+ return $self->colorizeStates($string);
+ } else {
+ return $string;
+ }
+ }
+
+ protected function colorizeStates($string)
+ {
+ $string = preg_replace_callback(
+ "/'([^']+)'/",
+ [$this, 'highlightNames'],
+ $string
+ );
+
+ $string = preg_replace_callback(
+ '/(OK|WARNING|CRITICAL|UNKNOWN)/',
+ [$this, 'getColorized'],
+ $string
+ );
+
+ return $string;
+ }
+
+ protected function isTty()
+ {
+ if ($this->isTty === null) {
+ $this->isTty = function_exists('posix_isatty') && posix_isatty(STDOUT);
+ }
+
+ return $this->isTty;
+ }
+
+ protected function highlightNames($match)
+ {
+ return "'" . $this->screen->colorize($match[1], 'darkgray') . "'";
+ }
+
+ protected function getColorized($match)
+ {
+ if ($this->colorized === null) {
+ $this->colorized = [
+ 'OK' => $this->screen->colorize('OK', 'lightgreen'),
+ 'WARNING' => $this->screen->colorize('WARNING', 'yellow'),
+ 'CRITICAL' => $this->screen->colorize('CRITICAL', 'lightred'),
+ 'UNKNOWN' => $this->screen->colorize('UNKNOWN', 'lightpurple'),
+ ];
+ }
+
+ return $this->colorized[$match[1]];
+ }
+}
diff --git a/library/Director/ConfigDiff.php b/library/Director/ConfigDiff.php
new file mode 100644
index 0000000..acf5f7b
--- /dev/null
+++ b/library/Director/ConfigDiff.php
@@ -0,0 +1,47 @@
+<?php
+
+namespace Icinga\Module\Director;
+
+use gipfl\Diff\HtmlRenderer\InlineDiff;
+use gipfl\Diff\HtmlRenderer\SideBySideDiff;
+use gipfl\Diff\PhpDiff;
+use ipl\Html\ValidHtml;
+use InvalidArgumentException;
+
+/**
+ * @deprecated will be removed with v1.9 - please use gipfl\Diff
+ */
+class ConfigDiff implements ValidHtml
+{
+ protected $renderClass;
+
+ /** @var PhpDiff */
+ protected $phpDiff;
+
+ public function __construct($a, $b)
+ {
+ $this->phpDiff = new PhpDiff($a, $b);
+ }
+
+ public function render()
+ {
+ $class = $this->renderClass;
+ return (new $class($this->phpDiff))->render();
+ }
+
+ public function setHtmlRenderer($name)
+ {
+ switch ($name) {
+ case 'SideBySide':
+ $this->renderClass = SideBySideDiff::class;
+ break;
+ case 'Inline':
+ $this->renderClass = InlineDiff::class;
+ break;
+ default:
+ throw new InvalidArgumentException("There is no known '$name' renderer");
+ }
+
+ return $this;
+ }
+}
diff --git a/library/Director/Core/CoreApi.php b/library/Director/Core/CoreApi.php
new file mode 100644
index 0000000..ea10916
--- /dev/null
+++ b/library/Director/Core/CoreApi.php
@@ -0,0 +1,940 @@
+<?php
+
+namespace Icinga\Module\Director\Core;
+
+use Exception;
+use Icinga\Exception\NotFoundError;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Hook\DeploymentHook;
+use Icinga\Module\Director\IcingaConfig\IcingaConfig;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Icinga\Module\Director\Objects\IcingaCommand;
+use Icinga\Module\Director\Objects\DirectorDeploymentLog;
+use Icinga\Module\Director\Objects\IcingaZone;
+use Icinga\Web\Hook;
+use RuntimeException;
+
+class CoreApi implements DeploymentApiInterface
+{
+ protected $client;
+
+ protected $initialized = false;
+
+ /** @var Db */
+ protected $db;
+
+ public function __construct(RestApiClient $client)
+ {
+ $this->client = $client;
+ }
+
+ // Todo: type
+ public function setDb(Db $db)
+ {
+ $this->db = $db;
+ return $this;
+ }
+
+ /**
+ * @return string|null
+ */
+ public function getVersion()
+ {
+ return $this->parseVersion($this->getRawVersion());
+ }
+
+ public function enableWorkaroundForConnectionIssues()
+ {
+ $version = $this->getVersion();
+
+ if ($version === null ||
+ ((version_compare($version, '2.8.2', '>=') && version_compare($version, '2.10.2', '<')))
+ ) {
+ $this->client->disconnect();
+ $this->client->setKeepAlive(false);
+ }
+ }
+
+ /**
+ * @return string|null
+ */
+ public function getRawVersion()
+ {
+ try {
+ return $this->client()->get('')->getRaw('version');
+ } catch (Exception $exception) {
+ return null;
+ }
+ }
+
+ /**
+ * @param $version
+ * @return string|null
+ */
+ protected function parseVersion($version)
+ {
+ if ($version === null) {
+ return null;
+ }
+
+ if (preg_match('/^[rv]?(\d\.\d+\.\d+)/', $version, $match)) {
+ return $match[1];
+ } else {
+ return null;
+ }
+ }
+
+ public function getObjects($pluralType, $attrs = array(), $ignorePackage = null)
+ {
+ $params = (object) [];
+ if ($ignorePackage) {
+ $params->filter = 'obj.package!="' . $ignorePackage . '"';
+ }
+
+ if (! empty($attrs)) {
+ $params->attrs = $attrs;
+ }
+
+ return $this->client()->get(
+ 'objects/' . urlencode(strtolower($pluralType)),
+ $params
+ )->getResult('name');
+ }
+
+ public function onEvent($callback, $raw = false)
+ {
+ $this->client->onEvent($callback, $raw);
+
+ return $this;
+ }
+
+ public function getObject($name, $pluraltype, $attrs = array())
+ {
+ $params = (object) array(
+ );
+
+ if (! empty($attrs)) {
+ $params->attrs = $attrs;
+ }
+ $url = 'objects/' . urlencode(strtolower($pluraltype)) . '/' . rawurlencode($name) . '?all_joins=1';
+ $res = $this->client()->get($url, $params)->getResult('name');
+
+ // TODO: check key, throw
+ return $res[$name];
+ }
+
+ /**
+ * Get a PKI ticket for CSR auto-signing
+ *
+ * @param string $cn The host’s common name for which the ticket should be generated
+ *
+ * @return string|null
+ */
+ public function getTicket($cn)
+ {
+ $r = $this->client()->post(
+ 'actions/generate-ticket',
+ ['cn' => $cn]
+ );
+ if (! $r->succeeded()) {
+ throw new RuntimeException($r->getErrorMessage());
+ }
+
+ $ticket = $r->getRaw('ticket');
+ if ($ticket === null) {
+ // RestApiResponse::succeeded() returns true if Icinga 2 reports an error in the results key, e.g.
+ // {
+ // "results": [
+ // {
+ // "code": 500.0,
+ // "status": "Ticket salt is not configured in ApiListener object"
+ // }
+ // ]
+ // }
+ throw new RuntimeException($r->getRaw('status', 'Ticket is empty'));
+ }
+
+ return $ticket;
+ }
+
+ public function checkHostNow($host)
+ {
+ $filter = 'host.name == "' . $host . '"';
+
+ return $this->client()->post(
+ 'actions/reschedule-check?filter=' . rawurlencode($filter),
+ (object) array(
+ 'type' => 'Host'
+ )
+ );
+ }
+
+ public function checkServiceNow($host, $service)
+ {
+ $filter = 'host.name == "' . $host . '" && service.name == "' . $service . '"';
+ $this->client()->post(
+ 'actions/reschedule-check?filter=' . rawurlencode($filter),
+ (object) array(
+ 'type' => 'Service'
+ )
+ );
+ }
+
+ public function acknowledgeHostProblem($host, $author, $comment)
+ {
+ $filter = 'host.name == "' . $host . '"';
+ return $this->client()->post(
+ 'actions/acknowledge-problem?type=Host&filter=' . rawurlencode($filter),
+ (object) array(
+ 'author' => $author,
+ 'comment' => $comment
+ )
+ );
+ }
+
+ public function removeHostAcknowledgement($host)
+ {
+ $filter = 'host.name == "' . $host . '"';
+ return $this->client()->post(
+ 'actions/remove-acknowledgement?type=Host&filter=' . rawurlencode($filter)
+ );
+ }
+
+ public function reloadNow()
+ {
+ try {
+ $this->client()->post('actions/restart-process');
+
+ return true;
+ } catch (Exception $e) {
+ return $e->getMessage();
+ }
+ }
+
+ public function getHostOutput($host)
+ {
+ try {
+ $object = $this->getObject($host, 'hosts');
+ } catch (Exception $e) {
+ return 'Unable to fetch the requested object';
+ }
+ if (isset($object->attrs->last_check_result)) {
+ return $object->attrs->last_check_result->output;
+ } else {
+ return '(no check result available)';
+ }
+ }
+
+ public function checkHostAndWaitForResult($host, $timeout = 10)
+ {
+ $object = $this->getObject($host, 'hosts');
+ if (isset($object->attrs->last_check_result)) {
+ $oldOutput = $object->attrs->last_check_result->output;
+ } else {
+ $oldOutput = '';
+ }
+
+ $now = microtime(true);
+ $this->checkHostNow($host);
+
+ while (true) {
+ try {
+ $object = $this->getObject($host, 'hosts');
+ if (isset($object->attrs->last_check_result)) {
+ $res = $object->attrs->last_check_result;
+ if ($res->execution_start > $now || $res->output !== $oldOutput) {
+ return $res;
+ }
+ } else {
+ // no check result available
+ }
+ } catch (Exception $e) {
+ // Unable to fetch the requested object
+ throw new RuntimeException(sprintf(
+ 'Unable to fetch the requested host "%s"',
+ $host
+ ));
+ }
+ if (microtime(true) > ($now + $timeout)) {
+ break;
+ }
+
+ usleep(50000);
+ }
+
+ return false;
+ }
+
+ public function checkServiceAndWaitForResult($host, $service, $timeout = 10)
+ {
+ $now = microtime(true);
+ $this->checkServiceNow($host, $service);
+
+ while (true) {
+ try {
+ $object = $this->getObject("$host!$service", 'services');
+ if (isset($object->attrs->last_check_result)) {
+ $res = $object->attrs->last_check_result;
+ if ($res->execution_start > $now) {
+ return $res;
+ }
+ } else {
+ // no check result available
+ }
+ } catch (Exception $e) {
+ // Unable to fetch the requested object
+ throw new RuntimeException(sprintf(
+ 'Unable to fetch the requested service "%s" on "%s"',
+ $service,
+ $host
+ ));
+ }
+ if (microtime(true) > ($now + $timeout)) {
+ break;
+ }
+
+ usleep(150000);
+ }
+
+ return false;
+ }
+
+ public function getServiceOutput($host, $service)
+ {
+ try {
+ $object = $this->getObject($host . '!' . $service, 'services');
+ } catch (\Exception $e) {
+ return 'Unable to fetch the requested object';
+ }
+ if (isset($object->attrs->last_check_result)) {
+ return $object->attrs->last_check_result->output;
+ } else {
+ return '(no check result available)';
+ }
+ }
+
+ public function supportsRuntimeCreationFor(IcingaObject $object)
+ {
+ $valid = array('host');
+ return in_array($object->getShortTableName(), $valid);
+ }
+
+ protected function assertRuntimeCreationSupportFor(IcingaObject $object)
+ {
+ if (!$this->supportsRuntimeCreationFor($object)) {
+ throw new RuntimeException(sprintf(
+ 'Object creation at runtime is not supported for "%s"',
+ $object->getShortTableName()
+ ));
+ }
+ }
+
+ // Note: this is for testing purposes only, NOT production-ready
+ public function createObjectAtRuntime(IcingaObject $object)
+ {
+ $this->assertRuntimeCreationSupportFor($object);
+
+ $key = $object->getShortTableName();
+
+ $command = sprintf(
+ "f = function() {\n"
+ . ' existing = get_%s("%s")'
+ . "\n if (existing) { return false }"
+ . "\n%s\n}\nInternal.run_with_activation_context(f)\n",
+ $key,
+ $object->get('object_name'),
+ (string) $object
+ );
+
+ return $this->runConsoleCommand($command)->getSingleResult();
+ }
+
+ public function getConstants()
+ {
+ $constants = array();
+ $command = 'var constants = [];
+for (k => v in globals) {
+ if (typeof(v) in [String, Number, Boolean]) {
+ res = { name = k, value = v }
+ constants.add({name = k, value = v})
+ }
+};
+constants
+';
+
+ foreach ($this->runConsoleCommand($command)->getSingleResult() as $row) {
+ $constants[$row->name] = $row->value;
+ }
+
+ return $constants;
+ }
+
+ public function runConsoleCommand($command)
+ {
+ return $this->client()->post(
+ 'console/execute-script',
+ array('command' => $command)
+ );
+ }
+
+ public function getConstant($name)
+ {
+ $constants = $this->getConstants();
+ if (array_key_exists($name, $constants)) {
+ return $constants[$name];
+ }
+
+ return null;
+ }
+
+ public function getTypes()
+ {
+ return $this->client()->get('types')->getResult('name');
+ }
+
+ public function getType($type)
+ {
+ $res = $this->client()->get('types', array('name' => $type))->getResult('name');
+ return $res[$type]; // TODO: error checking
+ }
+
+ public function getStatus()
+ {
+ return $this->client()->get('status')->getResult('name');
+ }
+
+ public function listObjects($type, $pluralType)
+ {
+ // TODO: more abstraction needed
+ // TODO: autofetch and cache pluraltypes
+ try {
+ $result = $this->client()->get(
+ 'objects/' . $pluralType,
+ array(
+ 'attrs' => array('__name')
+ )
+ )->getResult('name');
+ } catch (NotFoundError $e) {
+ $result = [];
+ }
+
+ return array_keys($result);
+ }
+
+ public function getPackages()
+ {
+ return $this->client()->get('config/packages')->getResult('name');
+ }
+
+ public function getActiveStageName()
+ {
+ return current($this->listPackageStages($this->getPackageName(), true));
+ }
+
+ protected function getPackageName()
+ {
+ return $this->db->settings()->get('icinga_package_name');
+ }
+
+ public function getActiveChecksum(Db $conn)
+ {
+ $db = $conn->getDbAdapter();
+ $stage = $this->getActiveStageName();
+ if (! $stage) {
+ return null;
+ }
+
+ $query = $db->select()->from(
+ array('l' => 'director_deployment_log'),
+ array('checksum' => $conn->dbHexFunc('l.config_checksum'))
+ )->where('l.stage_name = ?', $stage);
+
+ return $db->fetchOne($query);
+ }
+
+ protected function getDirectorObjects($type, $plural, $map)
+ {
+ $attrs = array_merge(
+ array_keys($map),
+ array('package', 'templates', 'active')
+ );
+
+ $objects = array();
+ $result = $this->getObjects($plural, $attrs, $this->getPackageName());
+ foreach ($result as $name => $row) {
+ $attrs = $row->attrs;
+
+ $properties = array(
+ 'object_name' => $name,
+ 'object_type' => 'external_object'
+ );
+
+ foreach ($map as $key => $prop) {
+ if (property_exists($attrs, $key)) {
+ $properties[$prop] = $attrs->$key;
+ }
+ }
+
+ $objects[$name] = IcingaObject::createByType($type, $properties, $this->db);
+ }
+
+ return $objects;
+ }
+
+ /**
+ * @return IcingaZone[]
+ */
+ public function getZoneObjects()
+ {
+ return $this->getDirectorObjects('Zone', 'zones', [
+ 'parent' => 'parent',
+ 'global' => 'is_global',
+ ]);
+ }
+
+ public function getUserObjects()
+ {
+ return $this->getDirectorObjects('User', 'users', [
+ 'display_name' => 'display_name',
+ 'email' => 'email',
+ 'groups' => 'groups',
+ 'vars' => 'vars',
+ ]);
+ }
+
+ protected function buildEndpointZoneMap()
+ {
+ $zones = $this->getObjects('zones', ['endpoints'], $this->getPackageName());
+ $zoneMap = array();
+
+ foreach ($zones as $name => $zone) {
+ if (! is_array($zone->attrs->endpoints)) {
+ continue;
+ }
+ foreach ($zone->attrs->endpoints as $endpoint) {
+ $zoneMap[$endpoint] = $name;
+ }
+ }
+
+ return $zoneMap;
+ }
+
+ public function getEndpointObjects()
+ {
+ $zoneMap = $this->buildEndpointZoneMap();
+ $objects = $this->getDirectorObjects('Endpoint', 'endpoints', [
+ 'host' => 'host',
+ 'port' => 'port',
+ 'log_duration' => 'log_duration',
+ ]);
+
+ foreach ($objects as $object) {
+ $name = $object->object_name;
+ if (array_key_exists($name, $zoneMap)) {
+ $object->zone = $zoneMap[$name];
+ }
+ }
+
+ return $objects;
+ }
+
+ public function getHostObjects()
+ {
+ $params = [
+ 'display_name' => 'display_name',
+ 'address' => 'address',
+ 'address6' => 'address6',
+ 'templates' => 'imports',
+ 'groups' => 'groups',
+ 'vars' => 'vars',
+ 'check_command' => 'check_command',
+ 'max_check_attempts' => 'max_check_attempts',
+ 'check_period' => 'check_period',
+ 'check_interval' => 'check_interval',
+ 'retry_interval' => 'retry_interval',
+ 'enable_notifications' => 'enable_notifications',
+ 'enable_active_checks' => 'enable_active_checks',
+ 'enable_passive_checks' => 'enable_passive_checks',
+ 'enable_event_handler' => 'enable_event_handler',
+ 'enable_flapping' => 'enable_flapping',
+ 'enable_perfdata' => 'enable_perfdata',
+ 'event_command' => 'event_command',
+ 'volatile' => 'volatile',
+ 'zone' => 'zone',
+ 'command_endpoint' => 'command_endpoint',
+ 'notes' => 'notes',
+ 'notes_url' => 'notes_url',
+ 'action_url' => 'action_url',
+ 'icon_image' => 'icon_image',
+ 'icon_image_alt' => 'icon_image_alt',
+ ];
+
+ if (version_compare($this->getVersion(), '2.8.0', '>=')) {
+ $params['flapping_threshold_high'] = 'flapping_threshold_high';
+ $params['flapping_threshold_low'] = 'flapping_threshold_low';
+ }
+
+ return $this->getDirectorObjects('Host', 'hosts', $params);
+ }
+
+ public function getHostGroupObjects()
+ {
+ return $this->getDirectorObjects('HostGroup', 'hostgroups', [
+ 'display_name' => 'display_name',
+ ]);
+ }
+
+ public function getUserGroupObjects()
+ {
+ return $this->getDirectorObjects('UserGroup', 'usergroups', [
+ 'display_name' => 'display_name',
+ ]);
+ }
+
+ /**
+ * @return IcingaCommand[]
+ */
+ public function getCheckCommandObjects()
+ {
+ return $this->getSpecificCommandObjects('Check');
+ }
+
+ /**
+ * @return IcingaCommand[]
+ */
+ public function getNotificationCommandObjects()
+ {
+ return $this->getSpecificCommandObjects('Notification');
+ }
+
+ /**
+ * @return IcingaCommand[]
+ */
+ public function getEventCommandObjects()
+ {
+ return $this->getSpecificCommandObjects('Event');
+ }
+
+ /**
+ * @return IcingaCommand[]
+ */
+ public function getSpecificCommandObjects($type)
+ {
+ IcingaCommand::setPluginDir($this->getConstant('PluginDir'));
+
+ $objects = $this->getDirectorObjects('Command', "${type}Commands", [
+ 'arguments' => 'arguments',
+ // 'env' => 'env',
+ 'timeout' => 'timeout',
+ 'command' => 'command',
+ 'vars' => 'vars',
+ ]);
+ foreach ($objects as $obj) {
+ $obj->methods_execute = "Plugin$type";
+ }
+
+ return $objects;
+ }
+
+ public function listPackageStages($name, $active = null)
+ {
+ $packages = $this->getPackages();
+ $found = array();
+
+ if (array_key_exists($name, $packages)) {
+ $package = $packages[$name];
+ $current = $package->{'active-stage'};
+ foreach ($package->stages as $stage) {
+ if ($active === null) {
+ $found[] = $stage;
+ } elseif ($active === true) {
+ if ($current === $stage) {
+ $found[] = $stage;
+ }
+ } elseif ($active === false) {
+ if ($current !== $stage) {
+ $found[] = $stage;
+ }
+ }
+ }
+ }
+
+ return $found;
+ }
+
+ public function collectLogFiles(Db $db)
+ {
+ $existing = $this->listPackageStages($this->getPackageName());
+ $missing = [];
+ $empty = [];
+ foreach (DirectorDeploymentLog::getUncollected($db) as $deployment) {
+ $stage = $deployment->get('stage_name');
+ if (! in_array($stage, $existing)) {
+ $missing[] = $deployment;
+ continue;
+ }
+
+ try {
+ $availableFiles = $this->listStageFiles($stage);
+ } catch (Exception $e) {
+ // Could not collect stage files. Doesn't matter, let's try next time
+ continue;
+ }
+
+ if (in_array('startup.log', $availableFiles)
+ && in_array('status', $availableFiles)
+ ) {
+ if ($this->getStagedFile($stage, 'status') === '0') {
+ $deployment->set('startup_succeeded', 'y');
+ } else {
+ $deployment->set('startup_succeeded', 'n');
+ }
+ $deployment->set('startup_log', $this->shortenStartupLog(
+ $this->getStagedFile($stage, 'startup.log')
+ ));
+ } else {
+ // Stage seems to be incomplete, let's try again next time
+ $empty[] = $deployment;
+ continue;
+ }
+ $deployment->set('stage_collected', 'y');
+
+ $deployment->store();
+
+ /** @var DeploymentHook[] $hooks */
+ $hooks = Hook::all('director/Deployment');
+ foreach ($hooks as $hook) {
+ $hook->onCollect($deployment);
+ }
+ }
+
+ foreach ($missing as $deployment) {
+ $deployment->set('stage_collected', 'n');
+ $deployment->store();
+ }
+
+ $running = DirectorDeploymentLog::getRelatedToActiveStage($this, $db);
+ if ($running !== null) {
+ foreach ($empty as $deployment) {
+ if ($deployment->getDeploymentTimestamp() < $running->getDeploymentTimestamp()) {
+ $deployment->set('stage_collected', 'n');
+ $deployment->store();
+ $this->deleteStage($this->getPackageName(), $deployment->get('stage_name'));
+ }
+ }
+ }
+ }
+
+ public function wipeInactiveStages(Db $db)
+ {
+ $uncollected = DirectorDeploymentLog::getUncollected($db);
+ $packageName = $this->getPackageName();
+ foreach ($this->listPackageStages($packageName, false) as $stage) {
+ if (array_key_exists($stage, $uncollected)) {
+ continue;
+ }
+ $this->client()->delete($this->prepareStageUrl($packageName, $stage));
+ }
+ }
+
+ public function listStageFiles($stage, $packageName = null)
+ {
+ if ($packageName === null) {
+ $packageName = $this->getPackageName();
+ }
+ return array_keys(
+ $this->client()
+ ->get($this->prepareStageUrl($packageName, $stage))
+ ->getResult('name', ['type' => 'file'])
+ );
+ }
+
+ public function getStagedFile($stage, $file, $packageName = null)
+ {
+ if ($packageName === null) {
+ $packageName = $this->getPackageName();
+ }
+ return $this->client()
+ ->getRaw($this->prepareFileUrl($packageName, $stage, $file));
+ }
+
+ public function hasPackage($name)
+ {
+ $modules = $this->getPackages();
+ return \array_key_exists($name, $modules);
+ }
+
+ public function createPackage($name)
+ {
+ return $this->client()->post($this->preparePackageUrl($name))->succeeded();
+ }
+
+ public function deletePackage($name)
+ {
+ return $this->client()->delete($this->preparePackageUrl($name))->succeeded();
+ }
+
+ public function assertPackageExists($name)
+ {
+ if (! $this->hasPackage($name)) {
+ if (! $this->createPackage($name)) {
+ throw new RuntimeException(sprintf(
+ 'Failed to create the package "%s" through the REST API',
+ $name
+ ));
+ }
+ }
+
+ return $this;
+ }
+
+ public function deleteStage($packageName, $stageName)
+ {
+ $this->client()->delete(
+ $this->prepareStageUrl($packageName, $stageName)
+ )->succeeded();
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function stream()
+ {
+ $allTypes = array(
+ 'CheckResult',
+ 'StateChange',
+ 'Notification',
+ 'AcknowledgementSet',
+ 'AcknowledgementCleared',
+ 'CommentAdded',
+ 'CommentRemoved',
+ 'DowntimeAdded',
+ 'DowntimeRemoved',
+ 'DowntimeTriggered'
+ );
+
+ $queue = 'director-rand';
+
+ $url = sprintf('events?queue=%s&types=%s', $queue, implode('&types=', $allTypes));
+
+ $this->client()->request('post', $url, null, false, true);
+ }
+
+ /**
+ * @param IcingaConfig $config
+ * @param Db $db
+ * @param null $packageName
+ * @return DirectorDeploymentLog
+ * @throws \Icinga\Module\Director\Exception\DuplicateKeyException
+ */
+ public function dumpConfig(IcingaConfig $config, Db $db, $packageName = null)
+ {
+ if ($packageName === null) {
+ $packageName = $db->settings()->get('icinga_package_name');
+ }
+ $start = microtime(true);
+ /** @var DirectorDeploymentLog $deployment */
+ $deployment = DirectorDeploymentLog::create(array(
+ // 'config_id' => $config->id,
+ // 'peer_identity' => $endpoint->object_name,
+ 'peer_identity' => $this->client->getPeerIdentity(),
+ 'start_time' => date('Y-m-d H:i:s'),
+ 'config_checksum' => $config->getChecksum(),
+ 'last_activity_checksum' => $config->getLastActivityChecksum()
+ // 'triggered_by' => Util::getUsername(),
+ // 'username' => Util::getUsername(),
+ // 'module_name' => $moduleName,
+ ));
+
+ /** @var DeploymentHook[] $hooks */
+ $hooks = Hook::all('director/Deployment');
+ foreach ($hooks as $hook) {
+ $hook->beforeDeploy($deployment);
+ }
+
+ $this->assertPackageExists($packageName);
+
+ $response = $this->client()->post('config/stages/' . \rawurlencode($packageName), [
+ 'files' => $config->getFileContents()
+ ]);
+
+ $duration = (int) ((microtime(true) - $start) * 1000);
+ // $deployment->duration_ms = $duration;
+ $deployment->set('duration_dump', $duration);
+
+ $succeeded = 'n';
+ if ($response->succeeded()) {
+ if ($stage = $response->getResult('stage', ['package' => $packageName])) { // Status?
+ $deployment->set('stage_name', key($stage));
+ $succeeded = 'y';
+ }
+ }
+ $deployment->set('dump_succeeded', $succeeded);
+ $deployment->store($db);
+
+ if ($succeeded === 'y') {
+ foreach ($hooks as $hook) {
+ $hook->triggerSuccessfulDump($deployment);
+ }
+ }
+
+ return $deployment;
+ }
+
+ protected function shortenStartupLog($log)
+ {
+ $logLen = strlen($log);
+ if ($logLen < 1024 * 60) {
+ return $log;
+ }
+
+ $part = substr($log, 0, 1024 * 20);
+ $parts = explode("\n", $part);
+ array_pop($parts);
+ $begin = implode("\n", $parts) . "\n\n";
+
+ $part = substr($log, -1024 * 20);
+ $parts = explode("\n", $part);
+ array_shift($parts);
+ $end = "\n\n" . implode("\n", $parts);
+
+ return $begin . sprintf(
+ '[..] %d bytes removed by Director [..]',
+ $logLen - (strlen($begin) + strlen($end))
+ ) . $end;
+ }
+
+ protected function preparePackageUrl($packageName)
+ {
+ return 'config/packages/' . \rawurlencode($packageName);
+ }
+
+ protected function prepareStageUrl($packageName, $stage)
+ {
+ return \sprintf(
+ 'config/stages/%s/%s',
+ \rawurlencode($packageName),
+ \rawurlencode($stage)
+ );
+ }
+
+ protected function prepareFileUrl($packageName, $stage, $file)
+ {
+ return \sprintf(
+ 'config/files/%s/%s/%s',
+ \rawurlencode($packageName),
+ \rawurlencode($stage),
+ \rawurlencode($file)
+ );
+ }
+
+ protected function client()
+ {
+ if ($this->initialized === false) {
+ $this->initialized = true;
+ $this->enableWorkaroundForConnectionIssues();
+ }
+
+ return $this->client;
+ }
+}
diff --git a/library/Director/Core/DeploymentApiInterface.php b/library/Director/Core/DeploymentApiInterface.php
new file mode 100644
index 0000000..026f0fd
--- /dev/null
+++ b/library/Director/Core/DeploymentApiInterface.php
@@ -0,0 +1,75 @@
+<?php
+
+namespace Icinga\Module\Director\Core;
+
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\IcingaConfig\IcingaConfig;
+
+/**
+ * Interface to a deployment API of the monitoring configuration
+ *
+ * @package Icinga\Module\Director\Core
+ */
+interface DeploymentApiInterface
+{
+ /**
+ * Collecting log files from the deployment system
+ * and write them into the database.
+ *
+ * @param Db $db
+ */
+ public function collectLogFiles(Db $db);
+
+ /**
+ * Cleanup old stages that are collected and not active
+ *
+ * @param Db $db
+ */
+ public function wipeInactiveStages(Db $db);
+
+ /**
+ * Returns the active configuration stage
+ *
+ * @return string
+ */
+ public function getActiveStageName();
+
+ /**
+ * List files in a named stage
+ *
+ * @param string $stage name of the stage
+ * @return string[]
+ */
+ public function listStageFiles($stage);
+
+ /**
+ * Retrieve a raw file from the named stage
+ *
+ * @param string $stage Stage name
+ * @param string $file Relative file path
+ *
+ * @return string
+ */
+ public function getStagedFile($stage, $file);
+
+ /**
+ * Explicitly delete a stage
+ *
+ * @param string $packageName
+ * @param string $stageName
+ *
+ * @return bool
+ */
+ public function deleteStage($packageName, $stageName);
+
+ /**
+ * Deploy the config and activate it
+ *
+ * @param IcingaConfig $config
+ * @param Db $db
+ * @param string $packageName
+ *
+ * @return mixed
+ */
+ public function dumpConfig(IcingaConfig $config, Db $db, $packageName = null);
+}
diff --git a/library/Director/Core/Json.php b/library/Director/Core/Json.php
new file mode 100644
index 0000000..507349c
--- /dev/null
+++ b/library/Director/Core/Json.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Icinga\Module\Director\Core;
+
+use Icinga\Module\Director\Exception\JsonEncodeException;
+
+class Json
+{
+ public static function encode($mixed, $flags = null)
+ {
+ if ($flags === null) {
+ $result = \json_encode($mixed);
+ } else {
+ $result = \json_encode($mixed, $flags);
+ }
+
+ if ($result === false && json_last_error() !== JSON_ERROR_NONE) {
+ throw JsonEncodeException::forLastJsonError();
+ }
+
+ return $result;
+ }
+
+ public static function decode($string)
+ {
+ $result = \json_decode($string);
+
+ if ($result === null && json_last_error() !== JSON_ERROR_NONE) {
+ throw JsonEncodeException::forLastJsonError();
+ }
+
+ return $result;
+ }
+}
diff --git a/library/Director/Core/LegacyDeploymentApi.php b/library/Director/Core/LegacyDeploymentApi.php
new file mode 100644
index 0000000..7287c4a
--- /dev/null
+++ b/library/Director/Core/LegacyDeploymentApi.php
@@ -0,0 +1,466 @@
+<?php
+
+namespace Icinga\Module\Director\Core;
+
+use Exception;
+use Icinga\Exception\IcingaException;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\IcingaConfig\IcingaConfig;
+use Icinga\Module\Director\Objects\DirectorDeploymentLog;
+
+/**
+ * Legacy DeploymentApi for Icinga 1.x configuration deployment
+ *
+ * @package Icinga\Module\Director\Core
+ */
+class LegacyDeploymentApi implements DeploymentApiInterface
+{
+ protected $db;
+ protected $deploymentPath;
+ protected $activationScript;
+
+ protected $dir_mode;
+ protected $file_mode;
+
+ public function __construct(Db $db)
+ {
+ $this->db = $db;
+ $settings = $this->db->settings();
+ $this->deploymentPath = $settings->deployment_path_v1;
+ $this->activationScript = $settings->activation_script_v1;
+
+ $this->dir_mode = base_convert($settings->get('deployment_file_mode_v1', '2775'), 8, 10);
+ $this->file_mode = base_convert($settings->get('deployment_dir_mode_v1', '0664'), 8, 10);
+ }
+
+ /**
+ * TODO: merge in common class
+ * @inheritdoc
+ */
+ public function collectLogFiles(Db $db)
+ {
+ $packageName = $db->settings()->get('icinga_package_name');
+ $existing = $this->listPackageStages($packageName);
+
+ foreach (DirectorDeploymentLog::getUncollected($db) as $deployment) {
+ $stage = $deployment->get('stage_name');
+ if (! in_array($stage, $existing)) {
+ continue;
+ }
+
+ try {
+ $availableFiles = $this->listStageFiles($stage);
+ } catch (Exception $e) {
+ // Could not collect stage files. Doesn't matter, let's try next time
+ continue;
+ }
+
+ if (in_array('startup.log', $availableFiles)
+ && in_array('status', $availableFiles)
+ ) {
+ $status = $this->getStagedFile($stage, 'status');
+ $status = trim($status);
+ if ($status === '0') {
+ $deployment->set('startup_succeeded', 'y');
+ } else {
+ $deployment->set('startup_succeeded', 'n');
+ }
+ $deployment->set('startup_log', $this->shortenStartupLog(
+ $this->getStagedFile($stage, 'startup.log')
+ ));
+ } else {
+ // Stage seems to be incomplete, let's try again next time
+ continue;
+ }
+ $deployment->set('stage_collected', 'y');
+
+ $deployment->store();
+ }
+ }
+
+ /**
+ * TODO: merge in common class
+ * @inheritdoc
+ */
+ public function wipeInactiveStages(Db $db)
+ {
+ $uncollected = DirectorDeploymentLog::getUncollected($db);
+ $packageName = $db->settings()->get('icinga_package_name');
+ $currentStage = $this->getActiveStageName();
+
+ // try to expire old deployments
+ foreach ($uncollected as $name => $deployment) {
+ /** @var DirectorDeploymentLog $deployment */
+ if ($deployment->get('dump_succeeded') === 'n'
+ || $deployment->get('startup_succeeded') === null
+ ) {
+ $start_time = strtotime($deployment->start_time);
+
+ // older than an hour and no startup
+ if ($start_time + 3600 < time()) {
+ $deployment->set('startup_succeeded', 'n');
+ $deployment->set('startup_log', 'Activation timed out...');
+ $deployment->store();
+ }
+ }
+ }
+
+ foreach ($this->listPackageStages($packageName) as $stage) {
+ if (array_key_exists($stage, $uncollected)
+ && $uncollected[$stage]->get('startup_succeeded') === null
+ ) {
+ continue;
+ } elseif ($stage === $currentStage) {
+ continue;
+ } else {
+ $this->deleteStage($packageName, $stage);
+ }
+ }
+ }
+
+ /** @inheritdoc */
+ public function getActiveStageName()
+ {
+ $this->assertDeploymentPath();
+
+ $path = $this->deploymentPath . DIRECTORY_SEPARATOR . 'active';
+
+ if (file_exists($path)) {
+ if (is_link($path)) {
+ $linkTarget = readlink($path);
+ $linkTargetDir = dirname($linkTarget);
+ $linkTargetName = basename($linkTarget);
+
+ if ($linkTargetDir === $this->deploymentPath || $linkTargetDir === '.') {
+ return $linkTargetName;
+ } else {
+ throw new IcingaException(
+ 'Active stage link pointing to a invalid target: %s -> %s',
+ $path,
+ $linkTarget
+ );
+ }
+ } else {
+ throw new IcingaException('Active stage is not a symlink: %s', $path);
+ }
+ } else {
+ return false;
+ }
+ }
+
+ /** @inheritdoc */
+ public function listStageFiles($stage)
+ {
+ $path = $this->getStagePath($stage);
+ if (! is_dir($path)) {
+ throw new IcingaException('Deployment stage "%s" does not exist at: %s', $stage, $path);
+ }
+ return $this->listDirectoryContents($path);
+ }
+
+ /** @inheritdoc */
+ public function listPackageStages($packageName)
+ {
+ $this->assertPackageName($packageName);
+ $this->assertDeploymentPath();
+
+ $dh = @opendir($this->deploymentPath);
+ if ($dh === null) {
+ throw new IcingaException('Can not list contents of %s', $this->deploymentPath);
+ }
+
+ $stages = array();
+ while ($file = readdir($dh)) {
+ if ($file === '.' || $file === '..') {
+ continue;
+ } elseif (is_dir($this->deploymentPath . DIRECTORY_SEPARATOR . $file)
+ && substr($file, 0, 9) === 'director-'
+ ) {
+ $stages[] = $file;
+ }
+ }
+
+ return $stages;
+ }
+
+ /** @inheritdoc */
+ public function getStagedFile($stage, $file)
+ {
+ $path = $this->getStagePath($stage);
+
+ $filePath = $path . DIRECTORY_SEPARATOR . $file;
+
+ if (! file_exists($filePath)) {
+ throw new IcingaException('Could not find file %s', $filePath);
+ } else {
+ return file_get_contents($filePath);
+ }
+ }
+
+ /** @inheritdoc */
+ public function deleteStage($packageName, $stageName)
+ {
+ $this->assertPackageName($packageName);
+ $this->assertDeploymentPath();
+
+ $path = $this->getStagePath($stageName);
+
+ static::rrmdir($path);
+ }
+
+ /** @inheritdoc */
+ public function dumpConfig(IcingaConfig $config, Db $db, $packageName = null)
+ {
+ if ($packageName === null) {
+ $packageName = $db->settings()->get('icinga_package_name');
+ }
+ $this->assertPackageName($packageName);
+ $this->assertDeploymentPath();
+
+ $start = microtime(true);
+ $deployment = DirectorDeploymentLog::create(array(
+ // 'config_id' => $config->id,
+ // 'peer_identity' => $endpoint->object_name,
+ 'peer_identity' => $this->deploymentPath,
+ 'start_time' => date('Y-m-d H:i:s'),
+ 'config_checksum' => $config->getChecksum(),
+ 'last_activity_checksum' => $config->getLastActivityChecksum()
+ // 'triggered_by' => Util::getUsername(),
+ // 'username' => Util::getUsername(),
+ // 'module_name' => $moduleName,
+ ));
+
+ $stage_name = 'director-' .date('Ymd-His');
+ $deployment->set('stage_name', $stage_name);
+
+ try {
+ $succeeded = $this->deployStage($stage_name, $config->getFileContents());
+ if ($succeeded === true) {
+ $succeeded = $this->activateStage($stage_name);
+ }
+ } catch (Exception $e) {
+ $deployment->set('dump_succeeded', 'n');
+ $deployment->set('startup_log', $e->getMessage());
+ $deployment->set('startup_succeeded', 'n');
+ $deployment->store($db);
+ throw $e;
+ }
+
+ $duration = (int) ((microtime(true) - $start) * 1000);
+ $deployment->set('duration_dump', $duration);
+
+ $deployment->set('dump_succeeded', $succeeded === true ? 'y' : 'n');
+
+ $deployment->store($db);
+ return $succeeded;
+ }
+
+ /**
+ * Deploy a new stage, and write all files to it
+ *
+ * @param string $stage Name of the stage
+ * @param array $files Array of files, $fileName => $content
+ *
+ * @return bool Success status
+ *
+ * @throws IcingaException When something could not be accessed
+ */
+ protected function deployStage($stage, $files)
+ {
+ $path = $this->deploymentPath . DIRECTORY_SEPARATOR . $stage;
+
+ if (file_exists($path)) {
+ throw new IcingaException('Stage "%s" does already exist at: ', $stage, $path);
+ } else {
+ $this->mkdir($path);
+
+ foreach ($files as $file => $content) {
+ $fullPath = $path . DIRECTORY_SEPARATOR . $file;
+ $this->mkdir(dirname($fullPath), true);
+
+ $fh = @fopen($fullPath, 'w');
+ if ($fh === null) {
+ throw new IcingaException('Could not open file "%s" for writing.', $fullPath);
+ }
+ chmod($fullPath, $this->file_mode);
+
+ fwrite($fh, $content);
+ fclose($fh);
+ }
+
+ return true;
+ }
+ }
+
+ /**
+ * Starts activation of
+ *
+ * Note: script should probably fork to background?
+ *
+ * @param string $stage Stage to activate
+ *
+ * @return bool
+ *
+ * @throws IcingaException For an execution error
+ */
+ protected function activateStage($stage)
+ {
+ if ($this->activationScript === null || trim($this->activationScript) === '') {
+ // skip activation, could be done by external cron worker
+ return true;
+ } else {
+ $command = sprintf('%s %s 2>&1', escapeshellcmd($this->activationScript), escapeshellarg($stage));
+ $output = null;
+ $rc = null;
+ exec($command, $output, $rc);
+ $output = join("\n", $output);
+ if ($rc !== 0) {
+ throw new IcingaException("Activation script did exit with return code %d:\n\n%s", $rc, $output);
+ }
+ return true;
+ }
+ }
+
+ /**
+ * Recursively dump directory contents, with relative path
+ *
+ * @param string $path Absolute path to read from
+ * @param int $depth Internal counter
+ *
+ * @return string[]
+ *
+ * @throws IcingaException When directory could not be opened
+ */
+ protected function listDirectoryContents($path, $depth = 0)
+ {
+ $dh = @opendir($path);
+ if ($dh === null) {
+ throw new IcingaException('Can not list contents of %s', $path);
+ }
+
+ $files = array();
+ while ($file = readdir($dh)) {
+ $fullPath = $path . DIRECTORY_SEPARATOR . $file;
+ if ($file === '.' || $file === '..') {
+ continue;
+ } elseif (is_dir($fullPath)) {
+ $subdirFiles = $this->listDirectoryContents($fullPath, $depth + 1);
+ foreach ($subdirFiles as $subFile) {
+ $files[] = $file . DIRECTORY_SEPARATOR . $subFile;
+ }
+ } else {
+ $files[] = $file;
+ }
+ }
+
+ if ($depth === 0) {
+ sort($files);
+ }
+
+ return $files;
+ }
+
+ /**
+ * Assert that only the director module is interacted with
+ *
+ * @param string $packageName
+ * @throws IcingaException When another module is requested
+ */
+ protected function assertPackageName($packageName)
+ {
+ if ($packageName !== 'director') {
+ throw new IcingaException('Does not supported different modules!');
+ }
+ }
+
+ /**
+ * Assert the deployment path to be configured, existing, and writeable
+ *
+ * @throws IcingaException
+ */
+ protected function assertDeploymentPath()
+ {
+ if ($this->deploymentPath === null) {
+ throw new IcingaException('Deployment path is not configured for legacy config!');
+ } elseif (! is_dir($this->deploymentPath)) {
+ throw new IcingaException('Deployment path is not a directory: %s', $this->deploymentPath);
+ } elseif (! is_writeable($this->deploymentPath)) {
+ throw new IcingaException('Deployment path is not a writeable: %s', $this->deploymentPath);
+ }
+ }
+
+ /**
+ * TODO: avoid code duplication: copied from CoreApi
+ *
+ * @param string $log The log contents to shorten
+ * @return string
+ */
+ protected function shortenStartupLog($log)
+ {
+ $logLen = strlen($log);
+ if ($logLen < 1024 * 60) {
+ return $log;
+ }
+
+ $part = substr($log, 0, 1024 * 20);
+ $parts = explode("\n", $part);
+ array_pop($parts);
+ $begin = implode("\n", $parts) . "\n\n";
+
+ $part = substr($log, -1024 * 20);
+ $parts = explode("\n", $part);
+ array_shift($parts);
+ $end = "\n\n" . implode("\n", $parts);
+
+ return $begin . sprintf(
+ '[..] %d bytes removed by Director [..]',
+ $logLen - (strlen($begin) + strlen($end))
+ ) . $end;
+ }
+
+ /**
+ * Return the full path of a stage
+ *
+ * @param string $stage Name of the stage
+ *
+ * @return string
+ */
+ public function getStagePath($stage)
+ {
+ $this->assertDeploymentPath();
+ return $this->deploymentPath . DIRECTORY_SEPARATOR . $stage;
+ }
+
+ /**
+ * @from https://php.net/manual/de/function.rmdir.php#108113
+ * @param $dir
+ */
+ protected static function rrmdir($dir)
+ {
+ foreach (glob($dir . '/*') as $file) {
+ if (is_dir($file)) {
+ static::rrmdir($file);
+ } else {
+ unlink($file);
+ }
+ }
+
+ rmdir($dir);
+ }
+
+ protected function mkdir($path, $recursive = false)
+ {
+ if (! file_exists($path)) {
+ if ($recursive) {
+ $this->mkdir(dirname($path));
+ }
+
+ try {
+ mkdir($path);
+ chmod($path, $this->dir_mode);
+ } catch (Exception $e) {
+ throw new IcingaException('Could not create path "%s": %s', $path, $e->getMessage());
+ }
+ }
+ }
+}
diff --git a/library/Director/Core/RestApiClient.php b/library/Director/Core/RestApiClient.php
new file mode 100644
index 0000000..b0854ff
--- /dev/null
+++ b/library/Director/Core/RestApiClient.php
@@ -0,0 +1,276 @@
+<?php
+
+namespace Icinga\Module\Director\Core;
+
+use Icinga\Application\Benchmark;
+use RuntimeException;
+
+class RestApiClient
+{
+ protected $version = 'v1';
+
+ protected $peer;
+
+ protected $port;
+
+ protected $user;
+
+ protected $pass;
+
+ protected $curl;
+
+ protected $readBuffer = '';
+
+ protected $onEvent;
+
+ protected $onEventWantsRaw;
+
+ protected $keepAlive = true;
+
+ public function __construct($peer, $port = 5665, $cn = null)
+ {
+ $this->peer = $peer;
+ $this->port = $port;
+ }
+
+ // TODO: replace with Web2 CA trust resource plus cert and get rid
+ // of user/pass or at least strongly advise against using it
+ public function setCredentials($user, $pass)
+ {
+ $this->user = $user;
+ $this->pass = $pass;
+
+ return $this;
+ }
+
+ public function onEvent($callback, $raw = false)
+ {
+ $this->onEventWantsRaw = $raw;
+ $this->onEvent = $callback;
+
+ return $this;
+ }
+
+ public function getPeerIdentity()
+ {
+ return $this->peer;
+ }
+
+ public function setKeepAlive($keepAlive = true)
+ {
+ $this->keepAlive = (bool) $keepAlive;
+
+ return $this;
+ }
+
+ protected function url($url)
+ {
+ return sprintf('https://%s:%d/%s/%s', $this->peer, $this->port, $this->version, $url);
+ }
+
+ /**
+ * @param $method
+ * @param $url
+ * @param null $body
+ * @param bool $raw
+ * @param bool $stream
+ * @return RestApiResponse
+ */
+ public function request($method, $url, $body = null, $raw = false, $stream = false)
+ {
+ if (function_exists('curl_version')) {
+ return $this->curlRequest($method, $url, $body, $raw, $stream);
+ } else {
+ throw new RuntimeException(
+ 'No CURL extension detected, it must be installed and enabled'
+ );
+ }
+ }
+
+ protected function curlRequest($method, $url, $body = null, $raw = false, $stream = false)
+ {
+ $auth = sprintf('%s:%s', $this->user, $this->pass);
+ $headers = [
+ 'Host: ' . $this->getPeerIdentity(),
+ ];
+
+ if (! $this->keepAlive) {
+ // This fails on Icinga 2.9:
+ // $headers[] = 'Connection: close';
+ }
+
+ if (! $raw) {
+ $headers[] = 'Accept: application/json';
+ }
+
+ if ($body !== null) {
+ $body = Json::encode($body);
+ $headers[] = 'Content-Type: application/json';
+ }
+
+ $curl = $this->curl();
+ $opts = [
+ CURLOPT_URL => $this->url($url),
+ CURLOPT_HTTPHEADER => $headers,
+ CURLOPT_USERPWD => $auth,
+ CURLOPT_CUSTOMREQUEST => strtoupper($method),
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_CONNECTTIMEOUT => 3,
+
+ // TODO: Fix this!
+ CURLOPT_SSL_VERIFYHOST => false,
+ CURLOPT_SSL_VERIFYPEER => false,
+ ];
+
+ if ($body !== null) {
+ $opts[CURLOPT_POSTFIELDS] = $body;
+ }
+
+ if ($stream) {
+ $opts[CURLOPT_WRITEFUNCTION] = [$this, 'readPart'];
+ $opts[CURLOPT_TCP_NODELAY] = 1;
+ }
+
+ curl_setopt_array($curl, $opts);
+ // TODO: request headers, validate status code
+
+ Benchmark::measure('Rest Api, sending ' . $url);
+ $res = curl_exec($curl);
+ if ($res === false) {
+ $error = curl_error($curl);
+ $this->disconnect();
+
+ throw new RuntimeException("CURL ERROR: $error");
+ }
+
+ $statusCode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
+ if ($statusCode === 401) {
+ $this->disconnect();
+ throw new RuntimeException(
+ 'Unable to authenticate, please check your API credentials'
+ );
+ }
+
+ Benchmark::measure('Rest Api, got response');
+ if (! $this->keepAlive) {
+ $this->disconnect();
+ }
+
+ if ($stream) {
+ return $this;
+ }
+
+ if ($raw) {
+ return $res;
+ } else {
+ return RestApiResponse::fromJsonResult($res);
+ }
+ }
+
+ /**
+ * @param resource $curl
+ * @param $data
+ * @return int
+ */
+ protected function readPart($curl, &$data)
+ {
+ $length = strlen($data);
+ $this->readBuffer .= $data;
+ $this->processEvents();
+ return $length;
+ }
+
+ public function get($url, $body = null)
+ {
+ return $this->request('get', $url, $body);
+ }
+
+ public function getRaw($url, $body = null)
+ {
+ return $this->request('get', $url, $body, true);
+ }
+
+ public function post($url, $body = null)
+ {
+ return $this->request('post', $url, $body);
+ }
+
+ public function put($url, $body = null)
+ {
+ return $this->request('put', $url, $body);
+ }
+
+ public function delete($url, $body = null)
+ {
+ return $this->request('delete', $url, $body);
+ }
+
+ /**
+ * @return resource
+ */
+ protected function curl()
+ {
+ if ($this->curl === null) {
+ $this->curl = curl_init(sprintf('https://%s:%d', $this->peer, $this->port));
+ if (! $this->curl) {
+ throw new RuntimeException('CURL INIT ERROR: ' . curl_error($this->curl));
+ }
+ }
+
+ return $this->curl;
+ }
+
+ protected function processEvents()
+ {
+ $offset = 0;
+ while (false !== ($pos = strpos($this->readBuffer, "\n", $offset))) {
+ if ($pos === $offset) {
+ // echo "Got empty line $offset / $pos\n";
+ $offset = $pos + 1;
+ continue;
+ }
+ $this->processReadBuffer($offset, $pos);
+
+ $offset = $pos + 1;
+ }
+
+ if ($offset > 0) {
+ $this->readBuffer = substr($this->readBuffer, $offset + 1);
+ }
+
+ // echo "REMAINING: " . strlen($this->readBuffer) . "\n";
+ }
+
+ protected function processReadBuffer($offset, $pos)
+ {
+ if ($this->onEvent === null) {
+ return;
+ }
+
+ $func = $this->onEvent;
+ $str = substr($this->readBuffer, $offset, $pos);
+ // printf("Processing %s bytes\n", strlen($str));
+
+ if ($this->onEventWantsRaw) {
+ $func($str);
+ } else {
+ $func(Json::decode($str));
+ }
+ }
+
+ public function disconnect()
+ {
+ if ($this->curl !== null) {
+ if (is_resource($this->curl)) {
+ @curl_close($this->curl);
+ }
+
+ $this->curl = null;
+ }
+ }
+
+ public function __destruct()
+ {
+ $this->disconnect();
+ }
+}
diff --git a/library/Director/Core/RestApiResponse.php b/library/Director/Core/RestApiResponse.php
new file mode 100644
index 0000000..523ed35
--- /dev/null
+++ b/library/Director/Core/RestApiResponse.php
@@ -0,0 +1,149 @@
+<?php
+
+namespace Icinga\Module\Director\Core;
+
+use Icinga\Exception\IcingaException;
+use Icinga\Exception\NotFoundError;
+
+class RestApiResponse
+{
+ protected $errorMessage;
+
+ protected $results;
+
+ protected function __construct()
+ {
+ }
+
+ public static function fromJsonResult($json)
+ {
+ $response = new static;
+ return $response->parseJsonResult($json);
+ }
+
+ public static function fromErrorMessage($error)
+ {
+ $response = new static;
+ $response->errorMessage = $error;
+
+ return $response;
+ }
+
+ public function getResult($desiredKey, $filter = array())
+ {
+ return $this->extractResult($this->results, $desiredKey, $filter);
+ }
+
+ public function getRaw($key = null, $default = null)
+ {
+ if ($key === null) {
+ return $this->results;
+ } elseif (isset($this->results[0]) && property_exists($this->results[0], $key)) {
+ return $this->results[0]->$key;
+ } else {
+ return $default;
+ }
+ }
+
+ public function getSingleResult()
+ {
+ if ($this->isErrorCode($this->results[0]->code)) {
+ throw new IcingaException(
+ $this->results[0]->status
+ );
+ } else {
+ return $this->results[0]->result;
+ }
+ }
+
+ protected function isErrorCode($code)
+ {
+ $code = (int) ceil($code);
+ return $code >= 400;
+ }
+
+ protected function extractResult($results, $desiredKey, $filter = array())
+ {
+ $response = array();
+ foreach ($results as $result) {
+ foreach ($filter as $key => $val) {
+ if (! property_exists($result, $key)) {
+ continue;
+ }
+ if ($result->$key !== $val) {
+ continue;
+ }
+ }
+ if (! property_exists($result, $desiredKey)) {
+ continue;
+ }
+
+ $response[$result->$desiredKey] = $result;
+ }
+ return $response;
+ }
+
+ public function getErrorMessage()
+ {
+ return $this->errorMessage;
+ }
+
+ public function succeeded()
+ {
+ return $this->errorMessage === null;
+ }
+
+ protected function parseJsonResult($json)
+ {
+ $result = @json_decode($json);
+ if ($result === null) {
+ $this->setJsonError();
+ // <h1>Bad Request</h1><p><pre>bad version</pre></p>
+ throw new IcingaException(
+ 'Parsing JSON result failed: '
+ . $this->errorMessage
+ . ' (Got: ' . substr($json, 0, 60) . ')'
+ );
+ }
+ if (property_exists($result, 'error')) {
+ if (property_exists($result, 'status')) {
+ if ((int) $result->error === 404) {
+ throw new NotFoundError($result->status);
+ } else {
+ throw new IcingaException('API request failed: ' . $result->status);
+ }
+ } else {
+ throw new IcingaException('API request failed: ' . var_export($result, 1));
+ }
+ }
+
+ $this->results = $result->results; // TODO: Check if set
+ return $this;
+ }
+
+ // TODO: just return json_last_error_msg() for PHP >= 5.5.0
+ protected function setJsonError()
+ {
+ switch (json_last_error()) {
+ case JSON_ERROR_DEPTH:
+ $this->errorMessage = 'The maximum stack depth has been exceeded';
+ break;
+ case JSON_ERROR_CTRL_CHAR:
+ $this->errorMessage = 'Control character error, possibly incorrectly encoded';
+ break;
+ case JSON_ERROR_STATE_MISMATCH:
+ $this->errorMessage = 'Invalid or malformed JSON';
+ break;
+ case JSON_ERROR_SYNTAX:
+ $this->errorMessage = 'Syntax error';
+ break;
+ case JSON_ERROR_UTF8:
+ $this->errorMessage = 'Malformed UTF-8 characters, possibly incorrectly encoded';
+ break;
+ default:
+ $this->errorMessage = 'An error occured when parsing a JSON string';
+ }
+
+ return $this;
+ }
+}
diff --git a/library/Director/CoreBeta/ApiStream.php b/library/Director/CoreBeta/ApiStream.php
new file mode 100644
index 0000000..478fd40
--- /dev/null
+++ b/library/Director/CoreBeta/ApiStream.php
@@ -0,0 +1,57 @@
+<?php
+
+namespace Icinga\Module\Director\CoreBeta;
+
+use Exception;
+
+class ApiClient extends Stream
+{
+ protected $port;
+
+ public static function create($peer, $port = 5665)
+ {
+ $stream = new static();
+ }
+
+ protected function createClientConnection()
+ {
+ $context = $this->createSslContext();
+ if ($context === false) {
+ echo "Unable to set SSL options\n";
+ return false;
+ }
+
+ $conn = stream_socket_client(
+ 'ssl://' . $this->peername . ':' . $this->peerport,
+ $errno,
+ $errstr,
+ 15,
+ STREAM_CLIENT_CONNECT,
+ $context
+ );
+
+ return $conn;
+ }
+
+ protected function createSslContext()
+ {
+ $local = 'ssl://' . $this->local;
+ $context = stream_context_create();
+
+ // Hack, we need key and cert:
+ $certfile = preg_replace('~\..+$~', '', $this->certname) . '.combi';
+
+ $options = array(
+ 'ssl' => array(
+ 'verify_host' => true,
+ 'cafile' => $this->ssldir . '/ca.crt',
+ 'local_cert' => $this->ssldir . '/' . $certfile,
+ 'CN_match' => 'monitor1',
+ )
+ );
+
+ $result = stream_context_set_option($context, $options);
+
+ return $context;
+ }
+}
diff --git a/library/Director/CoreBeta/Stream.php b/library/Director/CoreBeta/Stream.php
new file mode 100644
index 0000000..5add9a3
--- /dev/null
+++ b/library/Director/CoreBeta/Stream.php
@@ -0,0 +1,17 @@
+<?php
+
+namespace Icinga\Module\Director\CoreBeta;
+
+abstract class Stream
+{
+ protected $stream;
+
+ protected $buffer = '';
+
+ protected $bufferLength = 0;
+
+ protected function __construct($stream)
+ {
+ $this->stream = $stream;
+ }
+}
diff --git a/library/Director/CoreBeta/StreamContext.php b/library/Director/CoreBeta/StreamContext.php
new file mode 100644
index 0000000..4844b79
--- /dev/null
+++ b/library/Director/CoreBeta/StreamContext.php
@@ -0,0 +1,89 @@
+<?php
+
+namespace Icinga\Module\Director\CoreBeta;
+
+use Icinga\Exception\ProgrammingError;
+
+class StreamContext
+{
+ protected $options = array();
+
+ public function ssl()
+ {
+ if ($this->ssl === null) {
+ $this->ssl = new StreamContextSslOptions();
+ }
+
+ return $this->ssl;
+ }
+
+ public function isSsl()
+ {
+ return $this->ssl !== null;
+ }
+
+ public function setCA(CA $ca)
+ {
+ // $this->options
+ }
+
+ protected function createSslContext()
+ {
+ $local = 'ssl://' . $this->local;
+ $context = stream_context_create();
+
+ // Hack, we need key and cert:
+ $certfile = preg_replace('~\..+$~', '', $this->certname) . '.combi';
+
+ $options = array(
+ 'ssl' => array(
+ 'verify_host' => true,
+ 'cafile' => $this->ssldir . '/ca.crt',
+ 'local_cert' => $this->ssldir . '/' . $certfile,
+ 'CN_match' => 'monitor1',
+ )
+ );
+
+ $result = stream_context_set_option($context, $options);
+
+ return $context;
+ }
+
+ public function setContextOptions($options)
+ {
+ if (array_key_exists('ssl', $options)) {
+ throw new ProgrammingError('Direct access to ssl options is not allowed');
+ }
+ }
+
+ protected function reallySetContextOptions($options)
+ {
+ if ($this->context === null) {
+ $this->options = $options;
+ } else {
+ stream_context_set_option($this->context, $options);
+ }
+ }
+
+ protected function lazyContext()
+ {
+ if ($this->context === null) {
+ $this->context = stream_context_create();
+ $this->setContextOptions($this->getOptions());
+
+ // stream_context_set_option($this->context
+ if ($this->isSsl()) {
+ $this->options['ssl'] = $this->ssl()->getOptions();
+ }
+
+ $result = stream_context_set_option($this->context, $this->options);
+ }
+
+ return $this->context;
+ }
+
+ public function getRawContext()
+ {
+ return $this->lazyContext();
+ }
+}
diff --git a/library/Director/CoreBeta/StreamContextSslOptions.php b/library/Director/CoreBeta/StreamContextSslOptions.php
new file mode 100644
index 0000000..d01d4a5
--- /dev/null
+++ b/library/Director/CoreBeta/StreamContextSslOptions.php
@@ -0,0 +1,52 @@
+<?php
+
+namespace Icinga\Module\Director\CoreBeta;
+
+use Icinga\Exception\ProgrammingError;
+
+class StreamContextSslOptions
+{
+ protected $options = array(
+ 'verify_peer' => true,
+ );
+
+ public function setCA(CA $ca)
+ {
+ $this->ca = $ca;
+ }
+
+ public function capturePeerCert($capture = true)
+ {
+ $this->options['capture_peer_cert'] = (bool) $capture;
+ return $this;
+ }
+
+ public function capturePeerChain($capture = true)
+ {
+ $this->options['capture_peer_chain'] = (bool) $capture;
+ return $this;
+ }
+
+ public function setCiphers($ciphers)
+ {
+ $this->options['ciphers'] = $ciphers;
+ return $this;
+ }
+
+ public function setPeerName($name)
+ {
+ if (version_compare(PHP_VERSION, '5.6.0') >= 0) {
+ $this->options['peer_name'] = $name;
+ $this->options['verify_peer_name'] = true;
+ } else {
+ $this->options['CN_match'] = $name;
+ }
+ return $this;
+ }
+
+ public function getOptions()
+ {
+ // TODO: Fail on missing cert
+ return $this->options;
+ }
+}
diff --git a/library/Director/CustomVariable/CustomVariable.php b/library/Director/CustomVariable/CustomVariable.php
new file mode 100644
index 0000000..98eda84
--- /dev/null
+++ b/library/Director/CustomVariable/CustomVariable.php
@@ -0,0 +1,286 @@
+<?php
+
+namespace Icinga\Module\Director\CustomVariable;
+
+use Exception;
+use Icinga\Module\Director\Db\Cache\PrefetchCache;
+use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c;
+use Icinga\Module\Director\IcingaConfig\IcingaConfigRenderer;
+use InvalidArgumentException;
+use LogicException;
+
+abstract class CustomVariable implements IcingaConfigRenderer
+{
+ protected $key;
+
+ protected $value;
+
+ protected $storedValue;
+
+ protected $type;
+
+ protected $modified = false;
+
+ protected $loadedFromDb = false;
+
+ protected $deleted = false;
+
+ protected $checksum;
+
+ protected function __construct($key, $value = null)
+ {
+ $this->key = $key;
+ $this->setValue($value);
+ }
+
+ public function is($type)
+ {
+ return $this->getType() === $type;
+ }
+
+ public function getType()
+ {
+ if ($this->type === null) {
+ $parts = explode('\\', get_class($this));
+ $class = end($parts);
+ // strlen('CustomVariable') === 14
+ $this->type = substr($class, 14);
+ }
+
+ return $this->type;
+ }
+
+ // TODO: implement delete()
+ public function hasBeenDeleted()
+ {
+ return $this->deleted;
+ }
+
+ public function delete()
+ {
+ $this->deleted = true;
+ return $this;
+ }
+
+ // TODO: abstract
+ public function getDbValue()
+ {
+ return $this->getValue();
+ }
+
+ public function toJson()
+ {
+ if ($this->getDbFormat() === 'string') {
+ return json_encode($this->getDbValue());
+ } else {
+ return $this->getDbValue();
+ }
+ }
+
+ // TODO: abstract
+ public function getDbFormat()
+ {
+ return 'string';
+ }
+
+ public function getKey()
+ {
+ return $this->key;
+ }
+
+ /**
+ * @param $value
+ * @return $this
+ */
+ abstract public function setValue($value);
+
+ abstract public function getValue();
+
+ /**
+ * @param bool $renderExpressions
+ * @return string
+ */
+ public function toConfigString($renderExpressions = false)
+ {
+ // TODO: this should be an abstract method once we deprecate PHP < 5.3.9
+ throw new LogicException(sprintf(
+ '%s has no toConfigString() implementation',
+ get_class($this)
+ ));
+ }
+
+ public function flatten(array &$flat, $prefix)
+ {
+ $flat[$prefix] = $this->getDbValue();
+ }
+
+ public function render($renderExpressions = false)
+ {
+ return c::renderKeyValue(
+ $this->renderKeyName($this->getKey()),
+ $this->toConfigStringPrefetchable($renderExpressions)
+ );
+ }
+
+ protected function renderKeyName($key)
+ {
+ if (preg_match('/^[a-z][a-z0-9_]*$/i', $key)) {
+ return 'vars.' . c::escapeIfReserved($key);
+ } else {
+ return 'vars[' . c::renderString($key) . ']';
+ }
+ }
+
+ public function checksum()
+ {
+ // TODO: remember checksum, invalidate on change
+ return sha1($this->getKey() . '=' . $this->toJson(), true);
+ }
+
+ public function isNew()
+ {
+ return ! $this->loadedFromDb;
+ }
+
+ public function hasBeenModified()
+ {
+ return $this->modified;
+ }
+
+ public function toConfigStringPrefetchable($renderExpressions = false)
+ {
+ if (PrefetchCache::shouldBeUsed()) {
+ return PrefetchCache::instance()->renderVar($this, $renderExpressions);
+ } else {
+ return $this->toConfigString($renderExpressions);
+ }
+ }
+
+ public function setModified($modified = true)
+ {
+ $this->modified = $modified;
+ if (! $this->modified) {
+ if (is_object($this->value)) {
+ $this->storedValue = clone($this->value);
+ } else {
+ $this->storedValue = $this->value;
+ }
+ }
+
+ return $this;
+ }
+
+ public function setUnmodified()
+ {
+ return $this->setModified(false);
+ }
+
+ public function setLoadedFromDb($loaded = true)
+ {
+ $this->loadedFromDb = $loaded;
+ return $this;
+ }
+
+ abstract public function equals(CustomVariable $var);
+
+ public function differsFrom(CustomVariable $var)
+ {
+ return ! $this->equals($var);
+ }
+
+ protected function setChecksum($checksum)
+ {
+ $this->checksum = $checksum;
+ return $this;
+ }
+
+ public function getChecksum()
+ {
+ return $this->checksum;
+ }
+
+ public static function wantCustomVariable($key, $value)
+ {
+ if ($value instanceof CustomVariable) {
+ return $value;
+ }
+
+ return self::create($key, $value);
+ }
+
+ public static function create($key, $value)
+ {
+ if (is_null($value)) {
+ return new CustomVariableNull($key, $value);
+ }
+
+ if (is_bool($value)) {
+ return new CustomVariableBoolean($key, $value);
+ }
+
+ if (is_int($value) || is_float($value)) {
+ return new CustomVariableNumber($key, $value);
+ }
+
+ if (is_string($value)) {
+ return new CustomVariableString($key, $value);
+ } elseif (is_array($value)) {
+ foreach (array_keys($value) as $k) {
+ if (! (is_int($k) || ctype_digit($k))) {
+ return new CustomVariableDictionary($key, $value);
+ }
+ }
+
+ return new CustomVariableArray($key, array_values($value));
+ } elseif (is_object($value)) {
+ // TODO: check for specific class/stdClass/interface?
+ return new CustomVariableDictionary($key, $value);
+ } else {
+ throw new LogicException(sprintf('WTF (%s): %s', $key, var_export($value, 1)));
+ }
+ }
+
+ public static function fromDbRow($row)
+ {
+ switch ($row->format) {
+ case 'string':
+ $var = new CustomVariableString($row->varname, $row->varvalue);
+ break;
+ case 'json':
+ $var = self::create($row->varname, json_decode($row->varvalue));
+ break;
+ case 'expression':
+ throw new InvalidArgumentException(
+ 'Icinga code expressions are not yet supported'
+ );
+ default:
+ throw new InvalidArgumentException(sprintf(
+ '%s is not a supported custom variable format',
+ $row->format
+ ));
+ }
+ if (property_exists($row, 'checksum')) {
+ $var->setChecksum($row->checksum);
+ }
+
+ $var->loadedFromDb = true;
+ $var->setUnmodified();
+ return $var;
+ }
+
+ public function __toString()
+ {
+ try {
+ return $this->toConfigString();
+ } catch (Exception $e) {
+ trigger_error($e);
+ $previousHandler = set_exception_handler(
+ function () {
+ }
+ );
+ restore_error_handler();
+ call_user_func($previousHandler, $e);
+ die();
+ }
+ }
+}
diff --git a/library/Director/CustomVariable/CustomVariableArray.php b/library/Director/CustomVariable/CustomVariableArray.php
new file mode 100644
index 0000000..7e430a4
--- /dev/null
+++ b/library/Director/CustomVariable/CustomVariableArray.php
@@ -0,0 +1,100 @@
+<?php
+
+namespace Icinga\Module\Director\CustomVariable;
+
+use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c;
+use Icinga\Module\Director\IcingaConfig\IcingaLegacyConfigHelper as c1;
+
+class CustomVariableArray extends CustomVariable
+{
+ /** @var CustomVariable[] */
+ protected $value;
+
+ public function equals(CustomVariable $var)
+ {
+ if (! $var instanceof CustomVariableArray) {
+ return false;
+ }
+
+ return $var->getDbValue() === $this->getDbValue();
+ }
+
+ public function getValue()
+ {
+ $ret = array();
+ foreach ($this->value as $var) {
+ $ret[] = $var->getValue();
+ }
+
+ return $ret;
+ }
+
+ public function getDbValue()
+ {
+ return json_encode($this->getValue());
+ }
+
+ public function getDbFormat()
+ {
+ return 'json';
+ }
+
+ public function setValue($value)
+ {
+ $new = array();
+
+ foreach ($value as $k => $v) {
+ $new[] = self::wantCustomVariable($k, $v);
+ }
+
+ $equals = true;
+ if (is_array($this->value) && count($new) === count($this->value)) {
+ foreach ($this->value as $k => $v) {
+ if (! $new[$k]->equals($v)) {
+ $equals = false;
+ break;
+ }
+ }
+ } else {
+ $equals = false;
+ }
+
+ if (! $equals) {
+ $this->value = $new;
+ $this->setModified();
+ }
+
+ $this->deleted = false;
+
+ return $this;
+ }
+
+ public function flatten(array &$flat, $prefix)
+ {
+ foreach ($this->value as $k => $v) {
+ $v->flatten($flat, sprintf('%s[%d]', $prefix, $k));
+ }
+ }
+
+ public function toConfigString($renderExpressions = false)
+ {
+ $parts = array();
+ foreach ($this->value as $k => $v) {
+ $parts[] = $v->toConfigString($renderExpressions);
+ }
+
+ return c::renderEscapedArray($parts);
+ }
+
+ public function __clone()
+ {
+ foreach ($this->value as $key => $value) {
+ $this->value[$key] = clone($value);
+ }
+ }
+
+ public function toLegacyConfigString()
+ {
+ return c1::renderArray($this->value);
+ }
+}
diff --git a/library/Director/CustomVariable/CustomVariableBoolean.php b/library/Director/CustomVariable/CustomVariableBoolean.php
new file mode 100644
index 0000000..9953fae
--- /dev/null
+++ b/library/Director/CustomVariable/CustomVariableBoolean.php
@@ -0,0 +1,53 @@
+<?php
+
+namespace Icinga\Module\Director\CustomVariable;
+
+use Icinga\Exception\ProgrammingError;
+
+class CustomVariableBoolean extends CustomVariable
+{
+ public function equals(CustomVariable $var)
+ {
+ return $var->getValue() === $this->getValue();
+ }
+
+ public function getDbFormat()
+ {
+ return 'json';
+ }
+
+ public function getDbValue()
+ {
+ return json_encode($this->getValue());
+ }
+
+ public function getValue()
+ {
+ return $this->value;
+ }
+
+ public function setValue($value)
+ {
+ if (! is_bool($value)) {
+ throw new ProgrammingError(
+ 'Expected a boolean, got %s',
+ var_export($value, 1)
+ );
+ }
+
+ $this->value = $value;
+ $this->deleted = false;
+
+ return $this;
+ }
+
+ public function toConfigString($renderExpressions = false)
+ {
+ return $this->value ? 'true' : 'false';
+ }
+
+ public function toLegacyConfigString()
+ {
+ return $this->toConfigString();
+ }
+}
diff --git a/library/Director/CustomVariable/CustomVariableDictionary.php b/library/Director/CustomVariable/CustomVariableDictionary.php
new file mode 100644
index 0000000..d84be4f
--- /dev/null
+++ b/library/Director/CustomVariable/CustomVariableDictionary.php
@@ -0,0 +1,130 @@
+<?php
+
+namespace Icinga\Module\Director\CustomVariable;
+
+use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c;
+use Icinga\Module\Director\IcingaConfig\IcingaLegacyConfigHelper as c1;
+use Countable;
+
+class CustomVariableDictionary extends CustomVariable implements Countable
+{
+ /** @var CustomVariable[] */
+ protected $value;
+
+ public function equals(CustomVariable $var)
+ {
+ if (! $var instanceof CustomVariableDictionary) {
+ return false;
+ }
+
+ $myKeys = $this->listKeys();
+ $foreignKeys = $var->listKeys();
+ if ($myKeys !== $foreignKeys) {
+ return false;
+ }
+
+ foreach ($this->value as $key => $value) {
+ if (! $value->equals($var->getInternalValue($key))) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ public function getDbFormat()
+ {
+ return 'json';
+ }
+
+ public function getDbValue()
+ {
+ return json_encode($this->getValue());
+ }
+
+ public function setValue($value)
+ {
+ $new = array();
+
+ foreach ($value as $key => $val) {
+ $new[$key] = self::wantCustomVariable($key, $val);
+ }
+
+ $this->deleted = false;
+
+ // WTF?
+ if ($this->value === $new) {
+ return $this;
+ }
+
+ $this->value = $new;
+ $this->setModified();
+
+ return $this;
+ }
+
+ public function getValue()
+ {
+ $ret = (object) array();
+ ksort($this->value);
+
+ foreach ($this->value as $key => $var) {
+ $ret->$key = $var->getValue();
+ }
+
+ return $ret;
+ }
+
+ public function flatten(array &$flat, $prefix)
+ {
+ foreach ($this->value as $k => $v) {
+ $v->flatten($flat, sprintf('%s["%s"]', $prefix, $k));
+ }
+ }
+
+ public function listKeys()
+ {
+ $keys = array_keys($this->value);
+ sort($keys);
+ return $keys;
+ }
+
+ #[\ReturnTypeWillChange]
+ public function count()
+ {
+ return count($this->value);
+ }
+
+ public function __clone()
+ {
+ foreach ($this->value as $key => $value) {
+ $this->value[$key] = clone($value);
+ }
+ }
+
+ public function __get($key)
+ {
+ return $this->value[$key];
+ }
+
+ public function __isset($key)
+ {
+ return array_key_exists($key, $this->value);
+ }
+
+ public function getInternalValue($key)
+ {
+ return $this->value[$key];
+ }
+
+ public function toConfigString($renderExpressions = false)
+ {
+ // TODO
+ return c::renderDictionary($this->value);
+ }
+
+ public function toLegacyConfigString()
+ {
+ return c1::renderDictionary($this->value);
+ }
+}
diff --git a/library/Director/CustomVariable/CustomVariableNull.php b/library/Director/CustomVariable/CustomVariableNull.php
new file mode 100644
index 0000000..f87ccfa
--- /dev/null
+++ b/library/Director/CustomVariable/CustomVariableNull.php
@@ -0,0 +1,52 @@
+<?php
+
+namespace Icinga\Module\Director\CustomVariable;
+
+use Icinga\Exception\ProgrammingError;
+
+class CustomVariableNull extends CustomVariable
+{
+ public function equals(CustomVariable $var)
+ {
+ return $var instanceof CustomVariableNull;
+ }
+
+ public function getValue()
+ {
+ return null;
+ }
+
+ public function getDbValue()
+ {
+ return json_encode($this->getValue());
+ }
+
+ public function getDbFormat()
+ {
+ return 'json';
+ }
+
+ public function setValue($value)
+ {
+ if (! is_null($value)) {
+ throw new ProgrammingError(
+ 'Null can only be null, got %s',
+ var_export($value, 1)
+ );
+ }
+
+ $this->deleted = false;
+
+ return $this;
+ }
+
+ public function toConfigString($renderExpressions = false)
+ {
+ return 'null';
+ }
+
+ public function toLegacyConfigString()
+ {
+ return $this->toConfigString();
+ }
+}
diff --git a/library/Director/CustomVariable/CustomVariableNumber.php b/library/Director/CustomVariable/CustomVariableNumber.php
new file mode 100644
index 0000000..62838a9
--- /dev/null
+++ b/library/Director/CustomVariable/CustomVariableNumber.php
@@ -0,0 +1,73 @@
+<?php
+
+namespace Icinga\Module\Director\CustomVariable;
+
+use Icinga\Exception\ProgrammingError;
+
+class CustomVariableNumber extends CustomVariable
+{
+ // Hint: 'F' is intentional, this MUST NOT respect locales
+ const PRECISION = '%.9F';
+
+ public function equals(CustomVariable $var)
+ {
+ if (! $var instanceof CustomVariableNumber) {
+ return false;
+ }
+
+ $cur = $this->getValue();
+ $new = $var->getValue();
+
+ // Be tolerant when comparing floats:
+ if (is_float($cur) || is_float($new)) {
+ return sprintf(self::PRECISION, $cur)
+ === sprintf(self::PRECISION, $new);
+ }
+
+ return $cur === $new;
+ }
+
+ public function getDbFormat()
+ {
+ return 'json';
+ }
+
+ public function getDbValue()
+ {
+ return json_encode($this->getValue());
+ }
+
+ public function getValue()
+ {
+ return $this->value;
+ }
+
+ public function setValue($value)
+ {
+ if (! is_int($value) && ! is_float($value)) {
+ throw new ProgrammingError(
+ 'Expected a number, got %s',
+ var_export($value, 1)
+ );
+ }
+
+ $this->value = $value;
+ $this->deleted = false;
+
+ return $this;
+ }
+
+ public function toConfigString($renderExpressions = false)
+ {
+ if (is_int($this->value)) {
+ return (string) $this->value;
+ } else {
+ return sprintf(self::PRECISION, $this->value);
+ }
+ }
+
+ public function toLegacyConfigString()
+ {
+ return $this->toConfigString();
+ }
+}
diff --git a/library/Director/CustomVariable/CustomVariableString.php b/library/Director/CustomVariable/CustomVariableString.php
new file mode 100644
index 0000000..2d50968
--- /dev/null
+++ b/library/Director/CustomVariable/CustomVariableString.php
@@ -0,0 +1,59 @@
+<?php
+
+namespace Icinga\Module\Director\CustomVariable;
+
+use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c;
+use Icinga\Module\Director\IcingaConfig\IcingaLegacyConfigHelper as c1;
+
+class CustomVariableString extends CustomVariable
+{
+ public function equals(CustomVariable $var)
+ {
+ if (! $var instanceof CustomVariableString) {
+ return false;
+ }
+
+ return $var->getValue() === $this->getValue();
+ }
+
+ public function getValue()
+ {
+ return $this->value;
+ }
+
+ public function setValue($value)
+ {
+ if (! is_string($value)) {
+ $value = (string) $value;
+ }
+
+ if ($value !== $this->value) {
+ $this->value = $value;
+ $this->setModified();
+ }
+
+ $this->deleted = false;
+
+ return $this;
+ }
+
+ public function flatten(array &$flat, $prefix)
+ {
+ // TODO: we should get rid of type=string and always use JSON
+ $flat[$prefix] = json_encode($this->getValue());
+ }
+
+ public function toConfigString($renderExpressions = false)
+ {
+ if ($renderExpressions) {
+ return c::renderStringWithVariables($this->getValue(), ['config']);
+ } else {
+ return c::renderString($this->getValue());
+ }
+ }
+
+ public function toLegacyConfigString()
+ {
+ return c1::renderString($this->getValue());
+ }
+}
diff --git a/library/Director/CustomVariable/CustomVariables.php b/library/Director/CustomVariable/CustomVariables.php
new file mode 100644
index 0000000..cdcc4bd
--- /dev/null
+++ b/library/Director/CustomVariable/CustomVariables.php
@@ -0,0 +1,488 @@
+<?php
+
+namespace Icinga\Module\Director\CustomVariable;
+
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c;
+use Icinga\Module\Director\IcingaConfig\IcingaLegacyConfigHelper as c1;
+use Icinga\Module\Director\IcingaConfig\IcingaConfigRenderer;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Countable;
+use Exception;
+use Iterator;
+
+class CustomVariables implements Iterator, Countable, IcingaConfigRenderer
+{
+ /** @var CustomVariable[] */
+ protected $storedVars = array();
+
+ /** @var CustomVariable[] */
+ protected $vars = array();
+
+ protected $modified = false;
+
+ private $position = 0;
+
+ private $overrideKeyName;
+
+ protected $idx = array();
+
+ protected static $allTables = array(
+ 'icinga_command_var',
+ 'icinga_host_var',
+ 'icinga_notification_var',
+ 'icinga_service_set_var',
+ 'icinga_service_var',
+ 'icinga_user_var',
+ );
+
+ public static function countAll($varname, Db $connection)
+ {
+ $db = $connection->getDbAdapter();
+ $parts = array();
+ $where = $db->quoteInto('varname = ?', $varname);
+ foreach (static::$allTables as $table) {
+ $parts[] = "SELECT COUNT(*) as cnt FROM $table WHERE $where";
+ }
+
+ $sub = implode(' UNION ALL ', $parts);
+ $query = "SELECT SUM(sub.cnt) AS cnt FROM ($sub) sub";
+
+ return (int) $db->fetchOne($query);
+ }
+
+ public static function deleteAll($varname, Db $connection)
+ {
+ $db = $connection->getDbAdapter();
+ $where = $db->quoteInto('varname = ?', $varname);
+ foreach (static::$allTables as $table) {
+ $db->delete($table, $where);
+ }
+ }
+
+ public static function renameAll($oldname, $newname, Db $connection)
+ {
+ $db = $connection->getDbAdapter();
+ $where = $db->quoteInto('varname = ?', $oldname);
+ foreach (static::$allTables as $table) {
+ $db->update($table, ['varname' => $newname], $where);
+ }
+ }
+
+ #[\ReturnTypeWillChange]
+ public function count()
+ {
+ $count = 0;
+ foreach ($this->vars as $var) {
+ if (! $var->hasBeenDeleted()) {
+ $count++;
+ }
+ }
+
+ return $count;
+ }
+
+ #[\ReturnTypeWillChange]
+ public function rewind()
+ {
+ $this->position = 0;
+ }
+
+ #[\ReturnTypeWillChange]
+ public function current()
+ {
+ if (! $this->valid()) {
+ return null;
+ }
+
+ return $this->vars[$this->idx[$this->position]];
+ }
+
+ #[\ReturnTypeWillChange]
+ public function key()
+ {
+ return $this->idx[$this->position];
+ }
+
+ #[\ReturnTypeWillChange]
+ public function next()
+ {
+ ++$this->position;
+ }
+
+ #[\ReturnTypeWillChange]
+ public function valid()
+ {
+ return array_key_exists($this->position, $this->idx);
+ }
+
+ /**
+ * Generic setter
+ *
+ * @param string $key
+ * @param mixed $value
+ *
+ * @return self
+ */
+ public function set($key, $value)
+ {
+ $key = (string) $key;
+
+ if ($value instanceof CustomVariable) {
+ $value = clone($value);
+ } else {
+ if ($value === null) {
+ $this->__unset($key);
+ return $this;
+ }
+ $value = CustomVariable::create($key, $value);
+ }
+
+ // Hint: isset($this->$key) wouldn't conflict with protected properties
+ if ($this->__isset($key)) {
+ if ($value->equals($this->get($key))) {
+ return $this;
+ } else {
+ if (get_class($this->vars[$key]) === get_class($value)) {
+ $this->vars[$key]->setValue($value->getValue())->setModified();
+ } else {
+ $this->vars[$key] = $value->setLoadedFromDb()->setModified();
+ }
+ }
+ } else {
+ $this->vars[$key] = $value->setModified();
+ }
+
+ $this->modified = true;
+ $this->refreshIndex();
+
+ return $this;
+ }
+
+ protected function refreshIndex()
+ {
+ $this->idx = array();
+ ksort($this->vars);
+ foreach ($this->vars as $name => $var) {
+ if (! $var->hasBeenDeleted()) {
+ $this->idx[] = $name;
+ }
+ }
+ }
+
+ public static function loadForStoredObject(IcingaObject $object)
+ {
+ $db = $object->getDb();
+
+ $query = $db->select()->from(
+ array('v' => $object->getVarsTableName()),
+ array(
+ 'v.varname',
+ 'v.varvalue',
+ 'v.format',
+ )
+ )->where(sprintf('v.%s = ?', $object->getVarsIdColumn()), $object->get('id'));
+
+ $vars = new CustomVariables;
+ foreach ($db->fetchAll($query) as $row) {
+ $vars->vars[$row->varname] = CustomVariable::fromDbRow($row);
+ }
+ $vars->refreshIndex();
+ $vars->setBeingLoadedFromDb();
+ return $vars;
+ }
+
+ public static function forStoredRows($rows)
+ {
+ $vars = new CustomVariables;
+ foreach ($rows as $row) {
+ $vars->vars[$row->varname] = CustomVariable::fromDbRow($row);
+ }
+ $vars->refreshIndex();
+ $vars->setBeingLoadedFromDb();
+
+ return $vars;
+ }
+
+ public function storeToDb(IcingaObject $object)
+ {
+ $db = $object->getDb();
+ $table = $object->getVarsTableName();
+ $foreignColumn = $object->getVarsIdColumn();
+ $foreignId = $object->get('id');
+
+
+ foreach ($this->vars as $var) {
+ if ($var->isNew()) {
+ $db->insert(
+ $table,
+ array(
+ $foreignColumn => $foreignId,
+ 'varname' => $var->getKey(),
+ 'varvalue' => $var->getDbValue(),
+ 'format' => $var->getDbFormat()
+ )
+ );
+ $var->setLoadedFromDb();
+ continue;
+ }
+
+ $where = $db->quoteInto(sprintf('%s = ?', $foreignColumn), (int) $foreignId)
+ . $db->quoteInto(' AND varname = ?', $var->getKey());
+
+ if ($var->hasBeenDeleted()) {
+ $db->delete($table, $where);
+ } elseif ($var->hasBeenModified()) {
+ $db->update(
+ $table,
+ array(
+ 'varvalue' => $var->getDbValue(),
+ 'format' => $var->getDbFormat()
+ ),
+ $where
+ );
+ }
+ }
+
+ $this->setBeingLoadedFromDb();
+ }
+
+ public function get($key)
+ {
+ if (array_key_exists($key, $this->vars)) {
+ return $this->vars[$key];
+ }
+
+ return null;
+ }
+
+ public function hasBeenModified()
+ {
+ if ($this->modified) {
+ return true;
+ }
+
+ foreach ($this->vars as $var) {
+ if ($var->hasBeenModified()) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public function setBeingLoadedFromDb()
+ {
+ $this->modified = false;
+ $this->storedVars = array();
+ foreach ($this->vars as $key => $var) {
+ $this->storedVars[$key] = clone($var);
+ $var->setUnmodified();
+ $var->setLoadedFromDb();
+ }
+
+ return $this;
+ }
+
+ public function restoreStoredVar($key)
+ {
+ if (array_key_exists($key, $this->storedVars)) {
+ $this->vars[$key] = clone($this->storedVars[$key]);
+ $this->vars[$key]->setUnmodified();
+ $this->recheckForModifications();
+ $this->refreshIndex();
+ } elseif (array_key_exists($key, $this->vars)) {
+ unset($this->vars[$key]);
+ $this->recheckForModifications();
+ $this->refreshIndex();
+ }
+ }
+
+ protected function recheckForModifications()
+ {
+ $this->modified = false;
+ foreach ($this->vars as $var) {
+ if ($var->hasBeenModified()) {
+ $this->modified = true;
+
+ return;
+ }
+ }
+ }
+
+ public function getOriginalVars()
+ {
+ return $this->storedVars;
+ }
+
+ public function flatten()
+ {
+ $flat = array();
+ foreach ($this->vars as $key => $var) {
+ $var->flatten($flat, $key);
+ }
+
+ return $flat;
+ }
+
+ public function checksum()
+ {
+ $sums = array();
+ foreach ($this->vars as $key => $var) {
+ $sums[] = $key . '=' . $var->checksum();
+ }
+
+ return sha1(implode('|', $sums), true);
+ }
+
+ public function setOverrideKeyName($name)
+ {
+ $this->overrideKeyName = $name;
+ return $this;
+ }
+
+ public function toConfigString($renderExpressions = false)
+ {
+ $out = '';
+
+ foreach ($this as $key => $var) {
+ // TODO: ctype_alnum + underscore?
+ $out .= $this->renderSingleVar($key, $var, $renderExpressions);
+ }
+
+ return $out;
+ }
+
+ public function toLegacyConfigString()
+ {
+ $out = '';
+
+ ksort($this->vars);
+ foreach ($this->vars as $key => $var) {
+ // TODO: ctype_alnum + underscore?
+ // vars with ARGn will be handled by IcingaObject::renderLegacyCheck_command
+ if (substr($key, 0, 3) == 'ARG') {
+ continue;
+ }
+
+ switch ($type = $var->getType()) {
+ case 'String':
+ case 'Number':
+ # TODO: Make Prefetchable
+ $out .= c1::renderKeyValue(
+ '_' . $key,
+ $var->toLegacyConfigString()
+ );
+ break;
+ default:
+ $out .= c1::renderKeyValue(
+ '# _' . $key,
+ sprintf('(unsupported: %s)', $type)
+ );
+ }
+ }
+
+ if ($out !== '') {
+ $out = "\n".$out;
+ }
+
+ return $out;
+ }
+
+ /**
+ * @param string $key
+ * @param CustomVariable $var
+ * @param bool $renderExpressions
+ *
+ * @return string
+ */
+ protected function renderSingleVar($key, $var, $renderExpressions = false)
+ {
+ if ($key === $this->overrideKeyName) {
+ return c::renderKeyOperatorValue(
+ $this->renderKeyName($key),
+ '+=',
+ $var->toConfigStringPrefetchable($renderExpressions)
+ );
+ } else {
+ return c::renderKeyValue(
+ $this->renderKeyName($key),
+ $var->toConfigStringPrefetchable($renderExpressions)
+ );
+ }
+ }
+
+ protected function renderKeyName($key)
+ {
+ if (preg_match('/^[a-z][a-z0-9_]*$/i', $key)) {
+ return 'vars.' . c::escapeIfReserved($key);
+ } else {
+ return 'vars[' . c::renderString($key) . ']';
+ }
+ }
+
+ public function __get($key)
+ {
+ return $this->get($key);
+ }
+
+ /**
+ * Magic setter
+ *
+ * @param string $key Key
+ * @param mixed $val Value
+ *
+ * @return void
+ */
+ public function __set($key, $val)
+ {
+ $this->set($key, $val);
+ }
+
+ /**
+ * Magic isset check
+ *
+ * @param string $key
+ *
+ * @return boolean
+ */
+ public function __isset($key)
+ {
+ return array_key_exists($key, $this->vars);
+ }
+
+ /**
+ * Magic unsetter
+ *
+ * @param string $key
+ *
+ * @return void
+ */
+ public function __unset($key)
+ {
+ if (! array_key_exists($key, $this->vars)) {
+ return;
+ }
+
+ $this->vars[$key]->delete();
+ $this->modified = true;
+
+ $this->refreshIndex();
+ }
+
+ public function __toString()
+ {
+ try {
+ return $this->toConfigString();
+ } catch (Exception $e) {
+ trigger_error($e);
+ $previousHandler = set_exception_handler(
+ function () {
+ }
+ );
+ restore_error_handler();
+ call_user_func($previousHandler, $e);
+ die();
+ }
+ }
+}
diff --git a/library/Director/Daemon/BackgroundDaemon.php b/library/Director/Daemon/BackgroundDaemon.php
new file mode 100644
index 0000000..34cc28b
--- /dev/null
+++ b/library/Director/Daemon/BackgroundDaemon.php
@@ -0,0 +1,235 @@
+<?php
+
+namespace Icinga\Module\Director\Daemon;
+
+use Exception;
+use gipfl\Cli\Process;
+use gipfl\IcingaCliDaemon\DbResourceConfigWatch;
+use gipfl\SystemD\NotifySystemD;
+use React\EventLoop\Factory as Loop;
+use React\EventLoop\LoopInterface;
+use Ramsey\Uuid\Uuid;
+
+class BackgroundDaemon
+{
+ /** @var LoopInterface */
+ private $loop;
+
+ /** @var NotifySystemD|boolean */
+ protected $systemd;
+
+ /** @var JobRunner */
+ protected $jobRunner;
+
+ /** @var string|null */
+ protected $dbResourceName;
+
+ /** @var DaemonDb */
+ protected $daemonDb;
+
+ /** @var DaemonProcessState */
+ protected $processState;
+
+ /** @var DaemonProcessDetails */
+ protected $processDetails;
+
+ /** @var LogProxy */
+ protected $logProxy;
+
+ /** @var bool */
+ protected $reloading = false;
+
+ /** @var bool */
+ protected $shuttingDown = false;
+
+ public function run(LoopInterface $loop = null)
+ {
+ if ($ownLoop = ($loop === null)) {
+ $loop = Loop::create();
+ }
+ $this->loop = $loop;
+ $this->loop->futureTick(function () {
+ $this->initialize();
+ });
+ if ($ownLoop) {
+ $loop->run();
+ }
+ }
+
+ public function setDbResourceName($name)
+ {
+ $this->dbResourceName = $name;
+
+ return $this;
+ }
+
+ protected function initialize()
+ {
+ $this->registerSignalHandlers($this->loop);
+ $this->processState = new DaemonProcessState('icinga::director');
+ $this->jobRunner = new JobRunner($this->loop);
+ $this->systemd = $this->eventuallyInitializeSystemd();
+ $this->processState->setSystemd($this->systemd);
+ if ($this->systemd) {
+ $this->systemd->setReady();
+ }
+ $this->setState('ready');
+ $this->processDetails = $this
+ ->initializeProcessDetails($this->systemd)
+ ->registerProcessList($this->jobRunner->getProcessList());
+ $this->logProxy = new LogProxy($this->processDetails->getInstanceUuid());
+ $this->jobRunner->forwardLog($this->logProxy);
+ $this->daemonDb = $this->initializeDb(
+ $this->processDetails,
+ $this->processState,
+ $this->dbResourceName
+ );
+ $this->daemonDb
+ ->register($this->jobRunner)
+ ->register($this->logProxy)
+ ->register(new DeploymentChecker($this->loop))
+ ->run($this->loop);
+ $this->setState('running');
+ }
+
+ /**
+ * @param NotifySystemD|false $systemd
+ * @return DaemonProcessDetails
+ */
+ protected function initializeProcessDetails($systemd)
+ {
+ if ($systemd && $systemd->hasInvocationId()) {
+ $uuid = $systemd->getInvocationId();
+ } else {
+ try {
+ $uuid = \bin2hex(Uuid::uuid4()->getBytes());
+ } catch (Exception $e) {
+ $uuid = 'deadc0de' . \substr(\md5(\getmypid()), 0, 24);
+ }
+ }
+ $processDetails = new DaemonProcessDetails($uuid);
+ if ($systemd) {
+ $processDetails->set('running_with_systemd', 'y');
+ }
+
+ return $processDetails;
+ }
+
+ protected function eventuallyInitializeSystemd()
+ {
+ $systemd = NotifySystemD::ifRequired($this->loop);
+ if ($systemd) {
+ Logger::replaceRunningInstance(new SystemdLogWriter());
+ Logger::info(sprintf(
+ "Started by systemd, notifying watchdog every %0.2Gs via %s",
+ $systemd->getWatchdogInterval(),
+ $systemd->getSocketPath()
+ ));
+ } else {
+ Logger::debug('Running without systemd');
+ }
+
+ return $systemd;
+ }
+
+ /**
+ * @return DaemonProcessDetails
+ */
+ public function getProcessDetails()
+ {
+ return $this->processDetails;
+ }
+
+ /**
+ * @return DaemonProcessState
+ */
+ public function getProcessState()
+ {
+ return $this->processState;
+ }
+
+ protected function initializeDb(
+ DaemonProcessDetails $processDetails,
+ DaemonProcessState $processState,
+ $dbResourceName = null
+ ) {
+ $db = new DaemonDb($processDetails);
+ $db->on('state', function ($state, $level = null) use ($processState) {
+ // TODO: level is sent but not used
+ $processState->setComponentState('db', $state);
+ });
+ $db->on('schemaChange', function ($startupSchema, $dbSchema) {
+ Logger::info(sprintf(
+ "DB schema version changed. Started with %d, DB has %d. Restarting.",
+ $startupSchema,
+ $dbSchema
+ ));
+ $this->reload();
+ });
+
+ $db->setConfigWatch(
+ $dbResourceName
+ ? DbResourceConfigWatch::name($dbResourceName)
+ : DbResourceConfigWatch::module('director')
+ );
+
+ return $db;
+ }
+
+ protected function registerSignalHandlers(LoopInterface $loop)
+ {
+ $func = function ($signal) use (&$func) {
+ $this->shutdownWithSignal($signal, $func);
+ };
+ $funcReload = function () {
+ $this->reload();
+ };
+ $loop->addSignal(SIGHUP, $funcReload);
+ $loop->addSignal(SIGINT, $func);
+ $loop->addSignal(SIGTERM, $func);
+ }
+
+ protected function shutdownWithSignal($signal, &$func)
+ {
+ $this->loop->removeSignal($signal, $func);
+ $this->shutdown();
+ }
+
+ public function reload()
+ {
+ if ($this->reloading) {
+ Logger::error('Ignoring reload request, reload is already in progress');
+ return;
+ }
+ $this->reloading = true;
+ Logger::info('Going gown for reload now');
+ $this->setState('reloading the main process');
+ $this->daemonDb->disconnect()->then(function () {
+ Process::restart();
+ });
+ }
+
+ protected function shutdown()
+ {
+ if ($this->shuttingDown) {
+ Logger::error('Ignoring shutdown request, shutdown is already in progress');
+ return;
+ }
+ Logger::info('Shutting down');
+ $this->shuttingDown = true;
+ $this->setState('shutting down');
+ $this->daemonDb->disconnect()->then(function () {
+ Logger::info('DB has been disconnected, shutdown finished');
+ $this->loop->stop();
+ });
+ }
+
+ protected function setState($state)
+ {
+ if ($this->processState) {
+ $this->processState->setState($state);
+ }
+
+ return $this;
+ }
+}
diff --git a/library/Director/Daemon/DaemonDb.php b/library/Director/Daemon/DaemonDb.php
new file mode 100644
index 0000000..7772b3a
--- /dev/null
+++ b/library/Director/Daemon/DaemonDb.php
@@ -0,0 +1,365 @@
+<?php
+
+namespace Icinga\Module\Director\Daemon;
+
+use Exception;
+use gipfl\IcingaCliDaemon\DbResourceConfigWatch;
+use gipfl\IcingaCliDaemon\RetryUnless;
+use Icinga\Data\ConfigObject;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Db\Migrations;
+use ipl\Stdlib\EventEmitter;
+use React\EventLoop\LoopInterface;
+use React\Promise\Deferred;
+use RuntimeException;
+use SplObjectStorage;
+use function React\Promise\reject;
+use function React\Promise\resolve;
+
+class DaemonDb
+{
+ use EventEmitter;
+
+ /** @var LoopInterface */
+ private $loop;
+
+ /** @var Db */
+ protected $connection;
+
+ /** @var \Zend_Db_Adapter_Abstract */
+ protected $db;
+
+ /** @var DaemonProcessDetails */
+ protected $details;
+
+ /** @var DbBasedComponent[] */
+ protected $registeredComponents = [];
+
+ /** @var DbResourceConfigWatch|null */
+ protected $configWatch;
+
+ /** @var array|null */
+ protected $dbConfig;
+
+ /** @var RetryUnless|null */
+ protected $pendingReconnection;
+
+ /** @var Deferred|null */
+ protected $pendingDisconnect;
+
+ /** @var \React\EventLoop\TimerInterface */
+ protected $refreshTimer;
+
+ /** @var \React\EventLoop\TimerInterface */
+ protected $schemaCheckTimer;
+
+ /** @var int */
+ protected $startupSchemaVersion;
+
+ public function __construct(DaemonProcessDetails $details, $dbConfig = null)
+ {
+ $this->details = $details;
+ $this->dbConfig = $dbConfig;
+ }
+
+ public function register(DbBasedComponent $component)
+ {
+ $this->registeredComponents[] = $component;
+
+ return $this;
+ }
+
+ public function setConfigWatch(DbResourceConfigWatch $configWatch)
+ {
+ $this->configWatch = $configWatch;
+ $configWatch->notify(function ($config) {
+ $this->disconnect()->then(function () use ($config) {
+ return $this->onNewConfig($config);
+ });
+ });
+ if ($this->loop) {
+ $configWatch->run($this->loop);
+ }
+
+ return $this;
+ }
+
+ public function run(LoopInterface $loop)
+ {
+ $this->loop = $loop;
+ $this->connect();
+ $this->refreshTimer = $loop->addPeriodicTimer(3, function () {
+ $this->refreshMyState();
+ });
+ $this->schemaCheckTimer = $loop->addPeriodicTimer(15, function () {
+ $this->checkDbSchema();
+ });
+ if ($this->configWatch) {
+ $this->configWatch->run($this->loop);
+ }
+ }
+
+ protected function onNewConfig($config)
+ {
+ if ($config === null) {
+ if ($this->dbConfig === null) {
+ Logger::error('DB configuration is not valid');
+ } else {
+ Logger::error('DB configuration is no longer valid');
+ }
+ $this->emitStatus('no configuration');
+ $this->dbConfig = $config;
+
+ return resolve();
+ } else {
+ $this->emitStatus('configuration loaded');
+ $this->dbConfig = $config;
+
+ return $this->establishConnection($config);
+ }
+ }
+
+ protected function establishConnection($config)
+ {
+ if ($this->connection !== null) {
+ Logger::error('Trying to establish a connection while being connected');
+ return reject();
+ }
+ $callback = function () use ($config) {
+ $this->reallyEstablishConnection($config);
+ };
+ $onSuccess = function () {
+ $this->pendingReconnection = null;
+ $this->onConnected();
+ };
+ if ($this->pendingReconnection) {
+ $this->pendingReconnection->reset();
+ $this->pendingReconnection = null;
+ }
+ $this->emitStatus('connecting');
+
+ return $this->pendingReconnection = RetryUnless::succeeding($callback)
+ ->setInterval(0.2)
+ ->slowDownAfter(10, 10)
+ ->run($this->loop)
+ ->then($onSuccess)
+ ;
+ }
+
+ protected function reallyEstablishConnection($config)
+ {
+ $connection = new Db(new ConfigObject($config));
+ $connection->getDbAdapter()->getConnection();
+ $migrations = new Migrations($connection);
+ if (! $migrations->hasSchema()) {
+ $this->emitStatus('no schema', 'error');
+ throw new RuntimeException('DB has no schema');
+ }
+ $this->wipeOrphanedInstances($connection);
+ if ($this->hasAnyOtherActiveInstance($connection)) {
+ $this->emitStatus('locked by other instance', 'warning');
+ throw new RuntimeException('DB is locked by a running daemon instance, will retry');
+ }
+ $this->startupSchemaVersion = $migrations->getLastMigrationNumber();
+ $this->details->set('schema_version', $this->startupSchemaVersion);
+
+ $this->connection = $connection;
+ $this->db = $connection->getDbAdapter();
+ $this->loop->futureTick(function () {
+ $this->refreshMyState();
+ });
+
+ return $connection;
+ }
+
+ protected function checkDbSchema()
+ {
+ if ($this->connection === null) {
+ return;
+ }
+
+ if ($this->schemaIsOutdated()) {
+ $this->emit('schemaChange', [
+ $this->getStartupSchemaVersion(),
+ $this->getDbSchemaVersion()
+ ]);
+ }
+ }
+
+ protected function schemaIsOutdated()
+ {
+ return $this->getStartupSchemaVersion() < $this->getDbSchemaVersion();
+ }
+
+ protected function getStartupSchemaVersion()
+ {
+ return $this->startupSchemaVersion;
+ }
+
+ protected function getDbSchemaVersion()
+ {
+ if ($this->connection === null) {
+ throw new RuntimeException(
+ 'Cannot determine DB schema version without an established DB connection'
+ );
+ }
+ $migrations = new Migrations($this->connection);
+
+ return $migrations->getLastMigrationNumber();
+ }
+
+ protected function onConnected()
+ {
+ $this->emitStatus('connected');
+ Logger::info('Connected to the database');
+ foreach ($this->registeredComponents as $component) {
+ $component->initDb($this->connection);
+ }
+ }
+
+ /**
+ * @return \React\Promise\PromiseInterface
+ */
+ protected function reconnect()
+ {
+ return $this->disconnect()->then(function () {
+ return $this->connect();
+ }, function (Exception $e) {
+ Logger::error('Disconnect failed. This should never happen: ' . $e->getMessage());
+ exit(1);
+ });
+ }
+
+ /**
+ * @return \React\Promise\ExtendedPromiseInterface
+ */
+ public function connect()
+ {
+ if ($this->connection === null) {
+ if ($this->dbConfig) {
+ return $this->establishConnection($this->dbConfig);
+ }
+ }
+
+ return resolve();
+ }
+
+ /**
+ * @return \React\Promise\ExtendedPromiseInterface
+ */
+ public function disconnect()
+ {
+ if (! $this->connection) {
+ return resolve();
+ }
+ if ($this->pendingDisconnect) {
+ return $this->pendingDisconnect->promise();
+ }
+
+ $this->eventuallySetStopped();
+ $this->pendingDisconnect = new Deferred();
+ $pendingComponents = new SplObjectStorage();
+ foreach ($this->registeredComponents as $component) {
+ $pendingComponents->attach($component);
+ $resolve = function () use ($pendingComponents, $component) {
+ $pendingComponents->detach($component);
+ if ($pendingComponents->count() === 0) {
+ $this->pendingDisconnect->resolve();
+ }
+ };
+ // TODO: What should we do in case they don't?
+ $component->stopDb()->then($resolve);
+ }
+
+ try {
+ if ($this->db) {
+ $this->db->closeConnection();
+ }
+ } catch (Exception $e) {
+ Logger::error('Failed to disconnect: ' . $e->getMessage());
+ }
+
+ return $this->pendingDisconnect->promise()->then(function () {
+ $this->connection = null;
+ $this->db = null;
+ $this->pendingDisconnect = null;
+ });
+ }
+
+ protected function emitStatus($message, $level = 'info')
+ {
+ $this->emit('state', [$message, $level]);
+
+ return $this;
+ }
+
+ protected function hasAnyOtherActiveInstance(Db $connection)
+ {
+ $db = $connection->getDbAdapter();
+
+ return (int) $db->fetchOne(
+ $db->select()
+ ->from('director_daemon_info', 'COUNT(*)')
+ ->where('ts_stopped IS NULL')
+ ) > 0;
+ }
+
+ protected function wipeOrphanedInstances(Db $connection)
+ {
+ $db = $connection->getDbAdapter();
+ $db->delete('director_daemon_info', 'ts_stopped IS NOT NULL');
+ $db->delete('director_daemon_info', $db->quoteInto(
+ 'instance_uuid_hex = ?',
+ $this->details->getInstanceUuid()
+ ));
+ $count = $db->delete(
+ 'director_daemon_info',
+ 'ts_stopped IS NULL AND ts_last_update < ' . (
+ DaemonUtil::timestampWithMilliseconds() - (60 * 1000)
+ )
+ );
+ if ($count > 1) {
+ Logger::error("Removed $count orphaned daemon instance(s) from DB");
+ }
+ }
+
+ protected function refreshMyState()
+ {
+ if ($this->db === null || $this->pendingReconnection || $this->pendingDisconnect) {
+ return;
+ }
+ try {
+ $updated = $this->db->update(
+ 'director_daemon_info',
+ $this->details->getPropertiesToUpdate(),
+ $this->db->quoteInto('instance_uuid_hex = ?', $this->details->getInstanceUuid())
+ );
+
+ if (! $updated) {
+ $this->db->insert(
+ 'director_daemon_info',
+ $this->details->getPropertiesToInsert()
+ );
+ }
+ } catch (Exception $e) {
+ Logger::error($e->getMessage());
+ $this->reconnect();
+ }
+ }
+
+ protected function eventuallySetStopped()
+ {
+ try {
+ if (! $this->db) {
+ return;
+ }
+ $this->db->update(
+ 'director_daemon_info',
+ ['ts_stopped' => DaemonUtil::timestampWithMilliseconds()],
+ $this->db->quoteInto('instance_uuid_hex = ?', $this->details->getInstanceUuid())
+ );
+ } catch (Exception $e) {
+ Logger::error('Failed to update daemon info (setting ts_stopped): ' . $e->getMessage());
+ }
+ }
+}
diff --git a/library/Director/Daemon/DaemonProcessDetails.php b/library/Director/Daemon/DaemonProcessDetails.php
new file mode 100644
index 0000000..454e31f
--- /dev/null
+++ b/library/Director/Daemon/DaemonProcessDetails.php
@@ -0,0 +1,122 @@
+<?php
+
+namespace Icinga\Module\Director\Daemon;
+
+use gipfl\LinuxHealth\Memory;
+use Icinga\Application\Platform;
+use React\ChildProcess\Process;
+use gipfl\Cli\Process as CliProcess;
+
+class DaemonProcessDetails
+{
+ /** @var string */
+ protected $instanceUuid;
+
+ /** @var \stdClass */
+ protected $info;
+
+ /** @var ProcessList[] */
+ protected $processLists = [];
+
+ protected $myArgs;
+
+ protected $myPid;
+
+ public function __construct($instanceUuid)
+ {
+ $this->instanceUuid = $instanceUuid;
+ $this->initialize();
+ }
+
+ public function getInstanceUuid()
+ {
+ return $this->instanceUuid;
+ }
+
+ public function getPropertiesToInsert()
+ {
+ return $this->getPropertiesToUpdate() + (array) $this->info;
+ }
+
+ public function getPropertiesToUpdate()
+ {
+ return [
+ 'ts_last_update' => DaemonUtil::timestampWithMilliseconds(),
+ 'ts_stopped' => null,
+ 'process_info' => \json_encode($this->collectProcessInfo()),
+ ];
+ }
+
+ public function set($property, $value)
+ {
+ if (\property_exists($this->info, $property)) {
+ $this->info->$property = $value;
+ } else {
+ throw new \InvalidArgumentException("Trying to set invalid daemon info property: $property");
+ }
+ }
+
+ public function registerProcessList(ProcessList $list)
+ {
+ $refresh = function (Process $process) {
+ $this->refreshProcessInfo();
+ };
+ $list->on('start', $refresh)->on('exit', $refresh);
+ $this->processLists[] = $list;
+
+ return $this;
+ }
+
+ protected function refreshProcessInfo()
+ {
+ $this->set('process_info', \json_encode($this->collectProcessInfo()));
+ }
+
+ protected function collectProcessInfo()
+ {
+ $info = (object) [$this->myPid => (object) [
+ 'command' => implode(' ', $this->myArgs),
+ 'running' => true,
+ 'memory' => Memory::getUsageForPid($this->myPid)
+ ]];
+
+ foreach ($this->processLists as $processList) {
+ foreach ($processList->getOverview() as $pid => $details) {
+ $info->$pid = $details;
+ }
+ }
+
+ return $info;
+ }
+
+ protected function initialize()
+ {
+ global $argv;
+ CliProcess::getInitialCwd();
+ $this->myArgs = $argv;
+ $this->myPid = \posix_getpid();
+ if (isset($_SERVER['_'])) {
+ $self = $_SERVER['_'];
+ } else {
+ // Process does a better job, but want the relative path (if such)
+ $self = $_SERVER['PHP_SELF'];
+ }
+ $this->info = (object) [
+ 'instance_uuid_hex' => $this->instanceUuid,
+ 'running_with_systemd' => 'n',
+ 'ts_started' => (int) ((float) $_SERVER['REQUEST_TIME_FLOAT'] * 1000),
+ 'ts_stopped' => null,
+ 'pid' => \posix_getpid(),
+ 'fqdn' => Platform::getFqdn(),
+ 'username' => Platform::getPhpUser(),
+ 'schema_version' => null,
+ 'php_version' => Platform::getPhpVersion(),
+ 'binary_path' => $self,
+ 'binary_realpath' => CliProcess::getBinaryPath(),
+ 'php_integer_size' => PHP_INT_SIZE,
+ 'php_binary_path' => PHP_BINARY,
+ 'php_binary_realpath' => \realpath(PHP_BINARY), // TODO: useless?
+ 'process_info' => null,
+ ];
+ }
+}
diff --git a/library/Director/Daemon/DaemonProcessState.php b/library/Director/Daemon/DaemonProcessState.php
new file mode 100644
index 0000000..6ae3cd2
--- /dev/null
+++ b/library/Director/Daemon/DaemonProcessState.php
@@ -0,0 +1,85 @@
+<?php
+
+namespace Icinga\Module\Director\Daemon;
+
+use gipfl\Cli\Process;
+use gipfl\SystemD\NotifySystemD;
+
+class DaemonProcessState
+{
+ /** @var NotifySystemD|null */
+ protected $systemd;
+
+ protected $components = [];
+
+ protected $currentMessage;
+
+ protected $processTitle;
+
+ protected $state;
+
+ public function __construct($processTitle)
+ {
+ $this->processTitle = $processTitle;
+ $this->refreshMessage();
+ }
+
+ /**
+ * @param NotifySystemD|false $systemd
+ * @return $this
+ */
+ public function setSystemd($systemd)
+ {
+ if ($systemd) {
+ $this->systemd = $systemd;
+ } else {
+ $this->systemd = null;
+ }
+
+ return $this;
+ }
+
+ public function setState($message)
+ {
+ $this->state = $message;
+ $this->refreshMessage();
+
+ return $this;
+ }
+
+ public function setComponentState($name, $stateMessage)
+ {
+ if ($stateMessage === null) {
+ unset($this->components[$name]);
+ } else {
+ $this->components[$name] = $stateMessage;
+ }
+ $this->refreshMessage();
+ }
+
+ protected function refreshMessage()
+ {
+ $messageParts = [];
+ if ($this->state !== null && \strlen($this->state)) {
+ $messageParts[] = $this->state;
+ }
+ foreach ($this->components as $component => $message) {
+ $messageParts[] = "$component: $message";
+ }
+
+ $message = \implode(', ', $messageParts);
+
+ if ($message !== $this->currentMessage) {
+ $this->currentMessage = $message;
+ if (\strlen($message) === 0) {
+ Process::setTitle($this->processTitle);
+ } else {
+ Process::setTitle($this->processTitle . ": $message");
+ }
+
+ if ($this->systemd) {
+ $this->systemd->setStatus($message);
+ }
+ }
+ }
+}
diff --git a/library/Director/Daemon/DaemonUtil.php b/library/Director/Daemon/DaemonUtil.php
new file mode 100644
index 0000000..c978d11
--- /dev/null
+++ b/library/Director/Daemon/DaemonUtil.php
@@ -0,0 +1,16 @@
+<?php
+
+namespace Icinga\Module\Director\Daemon;
+
+class DaemonUtil
+{
+ /**
+ * @return int
+ */
+ public static function timestampWithMilliseconds()
+ {
+ $mTime = explode(' ', microtime());
+
+ return (int) round($mTime[0] * 1000) + (int) $mTime[1] * 1000;
+ }
+}
diff --git a/library/Director/Daemon/DbBasedComponent.php b/library/Director/Daemon/DbBasedComponent.php
new file mode 100644
index 0000000..c176c14
--- /dev/null
+++ b/library/Director/Daemon/DbBasedComponent.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace Icinga\Module\Director\Daemon;
+
+use Icinga\Module\Director\Db;
+
+interface DbBasedComponent
+{
+ /**
+ * @param Db $db
+ * @return \React\Promise\ExtendedPromiseInterface;
+ */
+ public function initDb(Db $db);
+
+ /**
+ * @return \React\Promise\ExtendedPromiseInterface;
+ */
+ public function stopDb();
+}
diff --git a/library/Director/Daemon/DeploymentChecker.php b/library/Director/Daemon/DeploymentChecker.php
new file mode 100644
index 0000000..82d6d05
--- /dev/null
+++ b/library/Director/Daemon/DeploymentChecker.php
@@ -0,0 +1,51 @@
+<?php
+
+namespace Icinga\Module\Director\Daemon;
+
+use Exception;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Objects\DirectorDeploymentLog;
+use React\EventLoop\LoopInterface;
+use function React\Promise\resolve;
+
+class DeploymentChecker implements DbBasedComponent
+{
+ /** @var Db */
+ protected $connection;
+
+ public function __construct(LoopInterface $loop)
+ {
+ $loop->addPeriodicTimer(5, function () {
+ if ($db = $this->connection) {
+ try {
+ if (DirectorDeploymentLog::hasUncollected($db)) {
+ $db->getDeploymentEndpoint()->api()->collectLogFiles($db);
+ }
+ } catch (Exception $e) {
+ // Ignore eventual issues while talking to Icinga
+ }
+ }
+ });
+ }
+
+ /**
+ * @param Db $connection
+ * @return \React\Promise\ExtendedPromiseInterface
+ */
+ public function initDb(Db $connection)
+ {
+ $this->connection = $connection;
+
+ return resolve();
+ }
+
+ /**
+ * @return \React\Promise\ExtendedPromiseInterface
+ */
+ public function stopDb()
+ {
+ $this->connection = null;
+
+ return resolve();
+ }
+}
diff --git a/library/Director/Daemon/JobRunner.php b/library/Director/Daemon/JobRunner.php
new file mode 100644
index 0000000..78d7747
--- /dev/null
+++ b/library/Director/Daemon/JobRunner.php
@@ -0,0 +1,234 @@
+<?php
+
+namespace Icinga\Module\Director\Daemon;
+
+use gipfl\IcingaCliDaemon\FinishedProcessState;
+use gipfl\IcingaCliDaemon\IcingaCliRpc;
+use Icinga\Application\Logger;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Objects\DirectorJob;
+use React\ChildProcess\Process;
+use React\EventLoop\LoopInterface;
+use React\Promise\Promise;
+use function React\Promise\resolve;
+
+class JobRunner implements DbBasedComponent
+{
+ /** @var Db */
+ protected $db;
+
+ /** @var LoopInterface */
+ protected $loop;
+
+ /** @var int[] */
+ protected $scheduledIds = [];
+
+ /** @var Promise[] */
+ protected $runningIds = [];
+
+ protected $checkInterval = 10;
+
+ /** @var \React\EventLoop\TimerInterface */
+ protected $timer;
+
+ /** @var LogProxy */
+ protected $logProxy;
+
+ /** @var ProcessList */
+ protected $running;
+
+ public function __construct(LoopInterface $loop)
+ {
+ $this->loop = $loop;
+ $this->running = new ProcessList($loop);
+ }
+
+ public function forwardLog(LogProxy $logProxy)
+ {
+ $this->logProxy = $logProxy;
+
+ return $this;
+ }
+
+ /**
+ * @param Db $db
+ * @return \React\Promise\ExtendedPromiseInterface
+ */
+ public function initDb(Db $db)
+ {
+ $this->db = $db;
+ $check = function () {
+ try {
+ $this->checkForPendingJobs();
+ $this->runNextPendingJob();
+ } catch (\Exception $e) {
+ Logger::error($e->getMessage());
+ }
+ };
+ if ($this->timer === null) {
+ $this->loop->futureTick($check);
+ }
+ if ($this->timer !== null) {
+ Logger::info('Cancelling former timer');
+ $this->loop->cancelTimer($this->timer);
+ }
+ $this->timer = $this->loop->addPeriodicTimer($this->checkInterval, $check);
+
+ return resolve();
+ }
+
+ /**
+ * @return \React\Promise\ExtendedPromiseInterface
+ */
+ public function stopDb()
+ {
+ $this->scheduledIds = [];
+ if ($this->timer !== null) {
+ $this->loop->cancelTimer($this->timer);
+ $this->timer = null;
+ }
+ $allFinished = $this->running->killOrTerminate();
+ foreach ($this->runningIds as $id => $promise) {
+ $promise->cancel();
+ }
+ $this->runningIds = [];
+
+ return $allFinished;
+ }
+
+ protected function hasBeenDisabled()
+ {
+ $db = $this->db->getDbAdapter();
+ return $db->fetchOne(
+ $db->select()
+ ->from('director_setting', 'setting_value')
+ ->where('setting_name = ?', 'disable_all_jobs')
+ ) === 'y';
+ }
+
+ protected function checkForPendingJobs()
+ {
+ if ($this->hasBeenDisabled()) {
+ $this->scheduledIds = [];
+ // TODO: disable jobs currently going on?
+ return;
+ }
+ if (empty($this->scheduledIds)) {
+ $this->loadNextIds();
+ }
+ }
+
+ protected function runNextPendingJob()
+ {
+ if ($this->timer === null) {
+ // Reset happened. Stopping?
+ return;
+ }
+
+ if (! empty($this->runningIds)) {
+ return;
+ }
+ while (! empty($this->scheduledIds)) {
+ if ($this->runNextJob()) {
+ break;
+ }
+ }
+ }
+
+ protected function loadNextIds()
+ {
+ $db = $this->db->getDbAdapter();
+
+ foreach ($db->fetchCol(
+ $db->select()->from('director_job', 'id')->where('disabled = ?', 'n')
+ ) as $id) {
+ $this->scheduledIds[] = (int) $id;
+ };
+ }
+
+ /**
+ * @return bool
+ */
+ protected function runNextJob()
+ {
+ $id = \array_shift($this->scheduledIds);
+ try {
+ $job = DirectorJob::loadWithAutoIncId((int) $id, $this->db);
+ if ($job->shouldRun()) {
+ $this->runJob($job);
+ return true;
+ }
+ } catch (\Exception $e) {
+ Logger::error('Trying to schedule Job failed: ' . $e->getMessage());
+ }
+
+ return false;
+ }
+
+ /**
+ * @param DirectorJob $job
+ */
+ protected function runJob(DirectorJob $job)
+ {
+ $id = $job->get('id');
+ $jobName = $job->get('job_name');
+ Logger::debug("Job ($jobName) starting");
+ $arguments = [
+ 'director',
+ 'job',
+ 'run',
+ '--id',
+ $job->get('id'),
+ '--debug',
+ '--rpc'
+ ];
+ $cli = new IcingaCliRpc();
+ $cli->setArguments($arguments);
+ $cli->on('start', function (Process $process) {
+ $this->onProcessStarted($process);
+ });
+
+ // Happens on protocol (Netstring) errors or similar:
+ $cli->on('error', function (\Exception $e) {
+ Logger::error('UNEXPECTED: ' . rtrim($e->getMessage()));
+ });
+ if ($this->logProxy) {
+ $logger = clone($this->logProxy);
+ $logger->setPrefix("Job ($jobName): ");
+ $cli->rpc()->setHandler($logger, 'logger');
+ }
+ unset($this->scheduledIds[$id]);
+ $this->runningIds[$id] = $cli->run($this->loop)->then(function () use ($id, $jobName) {
+ Logger::debug("Job ($jobName) finished");
+ })->otherwise(function (\Exception $e) use ($id, $jobName) {
+ Logger::error("Job ($jobName) failed: " . $e->getMessage());
+ })->otherwise(function (FinishedProcessState $state) use ($jobName) {
+ Logger::error("Job ($jobName) failed: " . $state->getReason());
+ })->always(function () use ($id) {
+ unset($this->runningIds[$id]);
+ $this->loop->futureTick(function () {
+ $this->runNextPendingJob();
+ });
+ });
+ }
+
+ /**
+ * @return ProcessList
+ */
+ public function getProcessList()
+ {
+ return $this->running;
+ }
+
+ protected function onProcessStarted(Process $process)
+ {
+ $this->running->attach($process);
+ }
+
+ public function __destruct()
+ {
+ $this->stopDb();
+ $this->logProxy = null;
+ $this->loop = null;
+ }
+}
diff --git a/library/Director/Daemon/JsonRpcLogWriter.php b/library/Director/Daemon/JsonRpcLogWriter.php
new file mode 100644
index 0000000..edfa23e
--- /dev/null
+++ b/library/Director/Daemon/JsonRpcLogWriter.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace Icinga\Module\Director\Daemon;
+
+use gipfl\Protocol\JsonRpc\Connection;
+use gipfl\Protocol\JsonRpc\Notification;
+use Icinga\Application\Logger\LogWriter;
+use Icinga\Data\ConfigObject;
+
+class JsonRpcLogWriter extends LogWriter
+{
+ protected $connection;
+
+ protected static $severityMap = [
+ Logger::DEBUG => 'debug',
+ Logger::INFO => 'info',
+ Logger::WARNING => 'warning',
+ Logger::ERROR => 'error',
+ ];
+
+ public function __construct(Connection $connection)
+ {
+ parent::__construct(new ConfigObject([]));
+ $this->connection = $connection;
+ }
+
+ public function log($severity, $message)
+ {
+ $message = \iconv('UTF-8', 'UTF-8//IGNORE', $message);
+ $this->connection->sendNotification(
+ Notification::create('logger.log', [
+ static::$severityMap[$severity],
+ $message
+ ])
+ );
+ }
+}
diff --git a/library/Director/Daemon/LogProxy.php b/library/Director/Daemon/LogProxy.php
new file mode 100644
index 0000000..0b58ae8
--- /dev/null
+++ b/library/Director/Daemon/LogProxy.php
@@ -0,0 +1,76 @@
+<?php
+
+namespace Icinga\Module\Director\Daemon;
+
+use Exception;
+use Icinga\Module\Director\Db;
+use function React\Promise\resolve;
+
+class LogProxy implements DbBasedComponent
+{
+ protected $connection;
+
+ protected $db;
+
+ protected $server;
+
+ protected $instanceUuid;
+
+ protected $prefix = '';
+
+ public function __construct($instanceUuid)
+ {
+ $this->instanceUuid = $instanceUuid;
+ }
+
+ public function setPrefix($prefix)
+ {
+ $this->prefix = $prefix;
+
+ return $this;
+ }
+
+ /**
+ * @param Db $connection
+ * @return \React\Promise\ExtendedPromiseInterface
+ */
+ public function initDb(Db $connection)
+ {
+ $this->connection = $connection;
+ $this->db = $connection->getDbAdapter();
+
+ return resolve();
+ }
+
+ /**
+ * @return \React\Promise\ExtendedPromiseInterface
+ */
+ public function stopDb()
+ {
+ $this->connection = null;
+ $this->db = null;
+
+ return resolve();
+ }
+
+ public function log($severity, $message)
+ {
+ Logger::$severity($this->prefix . $message);
+ /*
+ // Not yet
+ try {
+ if ($this->db) {
+ $this->db->insert('director_daemonlog', [
+ // environment/installation/db?
+ 'instance_uuid' => $this->instanceUuid,
+ 'ts_create' => DaemonUtil::timestampWithMilliseconds(),
+ 'level' => $severity,
+ 'message' => $message,
+ ]);
+ }
+ } catch (Exception $e) {
+ Logger::error($e->getMessage());
+ }
+ */
+ }
+}
diff --git a/library/Director/Daemon/Logger.php b/library/Director/Daemon/Logger.php
new file mode 100644
index 0000000..27fcbf5
--- /dev/null
+++ b/library/Director/Daemon/Logger.php
@@ -0,0 +1,24 @@
+<?php
+
+namespace Icinga\Module\Director\Daemon;
+
+use Icinga\Application\Logger as IcingaLogger;
+use Icinga\Application\Logger\LogWriter;
+use Icinga\Exception\ConfigurationError;
+
+class Logger extends IcingaLogger
+{
+ public static function replaceRunningInstance(LogWriter $writer, $level = null)
+ {
+ try {
+ $instance = static::$instance;
+ if ($level !== null) {
+ $instance->setLevel($level);
+ }
+
+ $instance->writer = $writer;
+ } catch (ConfigurationError $e) {
+ self::$instance->error($e->getMessage());
+ }
+ }
+}
diff --git a/library/Director/Daemon/ProcessList.php b/library/Director/Daemon/ProcessList.php
new file mode 100644
index 0000000..85b9aac
--- /dev/null
+++ b/library/Director/Daemon/ProcessList.php
@@ -0,0 +1,125 @@
+<?php
+
+namespace Icinga\Module\Director\Daemon;
+
+use gipfl\LinuxHealth\Memory;
+use Icinga\Application\Logger;
+use ipl\Stdlib\EventEmitter;
+use React\ChildProcess\Process;
+use React\EventLoop\LoopInterface;
+use React\Promise\Deferred;
+use function React\Promise\resolve;
+
+class ProcessList
+{
+ use EventEmitter;
+
+ /** @var LoopInterface */
+ protected $loop;
+
+ /** @var \SplObjectStorage */
+ protected $processes;
+
+ /**
+ * ProcessList constructor.
+ * @param LoopInterface $loop
+ * @param Process[] $processes
+ */
+ public function __construct(LoopInterface $loop, array $processes = [])
+ {
+ $this->loop = $loop;
+ $this->processes = new \SplObjectStorage();
+ foreach ($processes as $process) {
+ $this->attach($process);
+ }
+ }
+
+ public function attach(Process $process)
+ {
+ $this->processes->attach($process);
+ $this->emit('start', [$process]);
+ $process->on('exit', function () use ($process) {
+ $this->detach($process);
+ $this->emit('exit', [$process]);
+ });
+
+ return $this;
+ }
+
+ public function detach(Process $process)
+ {
+ $this->processes->detach($process);
+
+ return $this;
+ }
+
+ /**
+ * @param int $timeout
+ * @return \React\Promise\ExtendedPromiseInterface
+ */
+ public function killOrTerminate($timeout = 5)
+ {
+ if ($this->processes->count() === 0) {
+ return resolve();
+ }
+ $deferred = new Deferred();
+ $killTimer = $this->loop->addTimer($timeout, function () use ($deferred) {
+ /** @var Process $process */
+ foreach ($this->processes as $process) {
+ $pid = $process->getPid();
+ Logger::error("Process $pid is still running, sending SIGKILL");
+ $process->terminate(SIGKILL);
+ }
+
+ // Let's a little bit of delay after KILLing
+ $this->loop->addTimer(0.1, function () use ($deferred) {
+ $deferred->resolve();
+ });
+ });
+
+ $timer = $this->loop->addPeriodicTimer($timeout / 20, function () use (
+ $deferred,
+ &$timer,
+ $killTimer
+ ) {
+ $stopped = [];
+ /** @var Process $process */
+ foreach ($this->processes as $process) {
+ if (! $process->isRunning()) {
+ $stopped[] = $process;
+ }
+ }
+ foreach ($stopped as $process) {
+ $this->processes->detach($process);
+ }
+ if ($this->processes->count() === 0) {
+ $this->loop->cancelTimer($timer);
+ $this->loop->cancelTimer($killTimer);
+ $deferred->resolve();
+ }
+ });
+ /** @var Process $process */
+ foreach ($this->processes as $process) {
+ $process->terminate(SIGTERM);
+ }
+
+ return $deferred->promise();
+ }
+
+ public function getOverview()
+ {
+ $info = [];
+
+ /** @var Process $process */
+ foreach ($this->processes as $process) {
+ $pid = $process->getPid();
+ $info[$pid] = (object) [
+ 'command' => preg_replace('/^exec /', '', $process->getCommand()),
+ 'running' => $process->isRunning(),
+ 'memory' => Memory::getUsageForPid($pid)
+ ];
+ }
+
+ return $info;
+ }
+}
diff --git a/library/Director/Daemon/RunningDaemonInfo.php b/library/Director/Daemon/RunningDaemonInfo.php
new file mode 100644
index 0000000..adb3549
--- /dev/null
+++ b/library/Director/Daemon/RunningDaemonInfo.php
@@ -0,0 +1,154 @@
+<?php
+
+namespace Icinga\Module\Director\Daemon;
+
+class RunningDaemonInfo
+{
+ /** @var object */
+ protected $info;
+
+ public function __construct($info = null)
+ {
+ $this->setInfo($info);
+ }
+
+ public function setInfo($info)
+ {
+ if (empty($info)) {
+ $this->info = $this->createEmptyInfo();
+ } else {
+ $this->info = $info;
+ }
+
+ return $this;
+ }
+
+ public function isRunning()
+ {
+ return $this->getPid() !== null && ! $this->isOutdated();
+ }
+
+ public function getPid()
+ {
+ return (int) $this->info->pid;
+ }
+
+ public function getUsername()
+ {
+ return $this->info->username;
+ }
+
+ public function getFqdn()
+ {
+ return $this->info->fqdn;
+ }
+
+ public function getLastUpdate()
+ {
+ return $this->info->ts_last_update;
+ }
+
+ public function getLastModification()
+ {
+ return $this->info->ts_last_modification;
+ }
+
+ public function getPhpVersion()
+ {
+ return $this->info->php_version;
+ }
+
+ public function hasBeenStopped()
+ {
+ return $this->getTimestampStopped() !== null;
+ }
+
+ public function getTimestampStarted()
+ {
+ return $this->info->ts_started;
+ }
+
+ public function getTimestampStopped()
+ {
+ return $this->info->ts_stopped;
+ }
+
+ public function isOutdated($seconds = 5)
+ {
+ return (
+ DaemonUtil::timestampWithMilliseconds() - $this->info->ts_last_update
+ ) > $seconds * 1000;
+ }
+
+ public function isRunningWithSystemd()
+ {
+ return $this->info->running_with_systemd === 'y';
+ }
+
+ public function getBinaryPath()
+ {
+ return $this->info->binary_path;
+ }
+
+ public function getBinaryRealpath()
+ {
+ return $this->info->binary_realpath;
+ }
+
+ public function binaryRealpathDiffers()
+ {
+ return $this->getBinaryPath() !== $this->getBinaryRealpath();
+ }
+
+ public function getPhpBinaryPath()
+ {
+ return $this->info->php_binary_path;
+ }
+
+ public function getPhpBinaryRealpath()
+ {
+ return $this->info->php_binary_realpath;
+ }
+
+ public function phpBinaryRealpathDiffers()
+ {
+ return $this->getPhpBinaryPath() !== $this->getPhpBinaryRealpath();
+ }
+
+ public function getPhpIntegerSize()
+ {
+ return (int) $this->info->php_integer_size;
+ }
+
+ public function has64bitIntegers()
+ {
+ return $this->getPhpIntegerSize() === 8;
+ }
+
+ /*
+ // TODO: not yet
+ public function isMaster()
+ {
+ return $this->info->is_master === 'y';
+ }
+
+ public function isStandby()
+ {
+ return ! $this->isMaster();
+ }
+ */
+
+ protected function createEmptyInfo()
+ {
+ return (object) [
+ 'pid' => null,
+ 'fqdn' => null,
+ 'username' => null,
+ 'php_version' => null,
+ // 'is_master' => null,
+ // Only if not running. Does this make any sense in 'empty info'?
+ 'ts_last_update' => null,
+ 'ts_last_modification' => null
+ ];
+ }
+}
diff --git a/library/Director/Daemon/SystemdLogWriter.php b/library/Director/Daemon/SystemdLogWriter.php
new file mode 100644
index 0000000..8b64442
--- /dev/null
+++ b/library/Director/Daemon/SystemdLogWriter.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace Icinga\Module\Director\Daemon;
+
+use Icinga\Application\Logger\LogWriter;
+use Icinga\Data\ConfigObject;
+
+class SystemdLogWriter extends LogWriter
+{
+ protected static $severityMap = [
+ Logger::DEBUG => 7,
+ Logger::INFO => 6,
+ Logger::WARNING => 4,
+ Logger::ERROR => 3,
+ ];
+
+ public function __construct()
+ {
+ parent::__construct(new ConfigObject([]));
+ }
+
+ public function log($severity, $message)
+ {
+ $severity = self::$severityMap[$severity];
+ echo "<$severity>$message\n";
+ }
+}
diff --git a/library/Director/Dashboard/AlertsDashboard.php b/library/Director/Dashboard/AlertsDashboard.php
new file mode 100644
index 0000000..447f74f
--- /dev/null
+++ b/library/Director/Dashboard/AlertsDashboard.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard;
+
+class AlertsDashboard extends Dashboard
+{
+ protected $dashletNames = array(
+ 'Notifications',
+ 'Users',
+ 'Timeperiods',
+ 'DependencyObject',
+ 'ScheduledDowntimeApply',
+ );
+
+ public function getTitle()
+ {
+ return $this->translate('Get alerts when something goes wrong');
+ }
+}
diff --git a/library/Director/Dashboard/AutomationDashboard.php b/library/Director/Dashboard/AutomationDashboard.php
new file mode 100644
index 0000000..dd07d71
--- /dev/null
+++ b/library/Director/Dashboard/AutomationDashboard.php
@@ -0,0 +1,17 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard;
+
+class AutomationDashboard extends Dashboard
+{
+ protected $dashletNames = array(
+ 'ImportSource',
+ 'Sync',
+ 'Job'
+ );
+
+ public function getTitle()
+ {
+ return $this->translate('Automate all tasks');
+ }
+}
diff --git a/library/Director/Dashboard/BranchesDashboard.php b/library/Director/Dashboard/BranchesDashboard.php
new file mode 100644
index 0000000..fe8b385
--- /dev/null
+++ b/library/Director/Dashboard/BranchesDashboard.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard;
+
+use gipfl\Web\Widget\Hint;
+use Icinga\Application\Hook;
+use Icinga\Module\Director\Db\Branch\Branch;
+use Icinga\Module\Director\Db\Branch\BranchStore;
+use Icinga\Module\Director\Hook\BranchSupportHook;
+use ipl\Html\Html;
+
+class BranchesDashboard extends Dashboard
+{
+ public function getTitle()
+ {
+ $branch = Branch::detect(new BranchStore($this->getDb()));
+ if ($branch->isBranch()) {
+ $this->prepend(Hint::info(Html::sprintf(
+ $this->translate('You\'re currently working in a Configuration Branch: %s'),
+ Branch::requireHook()->linkToBranch($branch, $this->getAuth(), $branch->getName())
+ )));
+ }
+
+ return $this->translate('Prepare your configuration in a safe Environment');
+ }
+
+ public function loadDashlets()
+ {
+ /** @var BranchSupportHook $hook */
+ if ($hook = Hook::first('director/BranchSupport')) {
+ $this->dashlets = $hook->loadDashlets($this->getDb());
+ } else {
+ $this->dashlets = [];
+ }
+ }
+}
diff --git a/library/Director/Dashboard/CommandsDashboard.php b/library/Director/Dashboard/CommandsDashboard.php
new file mode 100644
index 0000000..13f4e42
--- /dev/null
+++ b/library/Director/Dashboard/CommandsDashboard.php
@@ -0,0 +1,35 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard;
+
+class CommandsDashboard extends Dashboard
+{
+ protected $dashletNames = array(
+ 'CheckCommands',
+ 'ExternalCheckCommands',
+ // 'NotificationCommands',
+ // 'ExternalNotificationCommands',
+ 'CommandTemplates',
+ );
+
+ public function getTitle()
+ {
+ return $this->translate('Manage your Icinga Commands');
+ }
+
+ public function getDescription()
+ {
+ return $this->translate(
+ 'Define Check-, Notification- or Event-Commands. Command definitions'
+ . ' are the glue between your Host- and Service-Checks and the Check'
+ . ' plugins on your Monitoring (or monitored) systems'
+ );
+ }
+
+ public function getTabs()
+ {
+ return $this->createTabsForDashboards(
+ ['hosts', 'services', 'commands']
+ );
+ }
+}
diff --git a/library/Director/Dashboard/Dashboard.php b/library/Director/Dashboard/Dashboard.php
new file mode 100644
index 0000000..de8970c
--- /dev/null
+++ b/library/Director/Dashboard/Dashboard.php
@@ -0,0 +1,305 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard;
+
+use Exception;
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Widget\Tabs;
+use ipl\Html\Html;
+use ipl\Html\HtmlDocument;
+use ipl\Html\HtmlString;
+use Icinga\Authentication\Auth;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Icinga\Module\Director\Restriction\HostgroupRestriction;
+use Icinga\Module\Director\Dashboard\Dashlet\Dashlet;
+use Icinga\Module\Director\Db;
+use Icinga\Web\Widget\Tab;
+use ipl\Html\ValidHtml;
+use Zend_Db_Select as ZfSelect;
+
+abstract class Dashboard extends HtmlDocument
+{
+ use TranslationHelper;
+
+ protected $name;
+
+ /** @var Dashlet[] */
+ protected $dashlets;
+
+ protected $dashletNames;
+
+ /** @var Db */
+ protected $db;
+
+ final private function __construct()
+ {
+ }
+
+ /**
+ * @param $name
+ * @param Db $db
+ *
+ * @return self
+ */
+ public static function loadByName($name, Db $db)
+ {
+ $class = __NAMESPACE__ . '\\' . ucfirst($name) . 'Dashboard';
+ $dashboard = new $class();
+ $dashboard->db = $db;
+ $dashboard->name = $name;
+ return $dashboard;
+ }
+
+ public static function exists($name)
+ {
+ return class_exists(__NAMESPACE__ . '\\' . ucfirst($name) . 'Dashboard');
+ }
+
+ /**
+ * @param $description
+ * @return $this
+ */
+ protected function addDescription($description)
+ {
+ if ($description instanceof ValidHtml) {
+ $this->add(Html::tag('p', $description));
+ } elseif ($description !== null) {
+ $this->add(Html::tag(
+ 'p',
+ null,
+ HtmlString::create(nl2br(Html::escape($description)))
+ ));
+ }
+
+ return $this;
+ }
+
+ public function render()
+ {
+ $this
+ ->setSeparator("\n")
+ ->add(Html::tag('h1', null, $this->getTitle()))
+ ->addDescription($this->getDescription())
+ ->add($this->renderDashlets());
+
+ return parent::render();
+ }
+
+ public function renderDashlets()
+ {
+ $ul = Html::tag('ul', [
+ 'class' => 'main-actions',
+ 'data-base-target' => '_next'
+ ]);
+
+ foreach ($this->dashlets() as $dashlet) {
+ if ($dashlet->shouldBeShown()) {
+ $ul->add($dashlet);
+ }
+ }
+
+ return $ul;
+ }
+
+ public function getName()
+ {
+ return $this->name;
+ }
+
+ abstract public function getTitle();
+
+ public function getDescription()
+ {
+ return null;
+ }
+
+ public function getTabs()
+ {
+ $lName = $this->getName();
+ $tabs = new Tabs();
+ $tabs->add($lName, new Tab([
+ 'label' => $this->translate(ucfirst($this->getName())),
+ 'url' => 'director/dashboard',
+ 'urlParams' => ['name' => $lName]
+ ]));
+
+ return $tabs;
+ }
+
+ protected function createTabsForDashboards($names)
+ {
+ $tabs = new Tabs();
+ foreach ($names as $name) {
+ $dashboard = Dashboard::loadByName($name, $this->getDb());
+ if ($dashboard->isAvailable()) {
+ $tabs->add($name, $this->createTabForDashboard($dashboard));
+ }
+ }
+
+ return $tabs;
+ }
+
+ protected function createTabForDashboard(Dashboard $dashboard)
+ {
+ $name = $dashboard->getName();
+ return new Tab([
+ 'label' => $this->translate(ucfirst($name)),
+ 'url' => 'director/dashboard',
+ 'urlParams' => ['name' => $name]
+ ]);
+ }
+
+ public function count()
+ {
+ return count($this->dashlets());
+ }
+
+ public function isAvailable()
+ {
+ return $this->count() > 0;
+ }
+
+ public function getDb()
+ {
+ return $this->db;
+ }
+
+ public function dashlets()
+ {
+ if ($this->dashlets === null) {
+ $this->loadDashlets();
+ $this->fetchDashletSummaries();
+ }
+
+ return $this->dashlets;
+ }
+
+ public function loadDashlets()
+ {
+ $names = $this->getDashletNames();
+
+ if (empty($names)) {
+ $this->dashlets = array();
+ } else {
+ $this->dashlets = Dashlet::loadByNames(
+ $this->dashletNames,
+ $this->getDb()
+ );
+ }
+ }
+
+ public function getDashletNames()
+ {
+ return $this->dashletNames;
+ }
+
+ protected function fetchDashletSummaries()
+ {
+ $types = array();
+ foreach ($this->dashlets as $dashlet) {
+ foreach ($dashlet->listRequiredStats() as $objectType) {
+ $types[$objectType] = $objectType;
+ }
+ }
+
+ if (empty($types)) {
+ return;
+ }
+
+ try {
+ $stats = $this->getObjectSummary($types);
+ } catch (Exception $e) {
+ $stats = array();
+ }
+
+ $failing = array();
+ foreach ($this->dashlets as $key => $dashlet) {
+ foreach ($dashlet->listRequiredStats() as $objectType) {
+ if (array_key_exists($objectType, $stats)) {
+ $dashlet->addStats($objectType, $stats[$objectType]);
+ } else {
+ $failing[] = $key;
+ }
+ }
+ }
+
+ foreach ($failing as $key) {
+ unset($this->dashlets[$key]);
+ }
+ }
+
+ public function getObjectSummary($types)
+ {
+ $queries = array();
+
+ foreach ($types as $type) {
+ $queries[] = $this->makeSummaryQuery($type);
+ }
+ $query = $this->db->select()->union($queries, ZfSelect::SQL_UNION_ALL);
+
+ $result = array();
+ foreach ($this->db->fetchAll($query) as $row) {
+ $result[$row->icinga_type] = $row;
+ }
+
+ return $result;
+ }
+
+ protected function makeSummaryQuery($type)
+ {
+ $columns = array(
+ 'icinga_type' => "('" . $type . "')",
+ 'cnt_object' => $this->getCntSql('object'),
+ 'cnt_template' => $this->getCntSql('template'),
+ 'cnt_external' => $this->getCntSql('external_object'),
+ 'cnt_apply' => $this->getCntSql('apply'),
+ 'cnt_total' => 'COUNT(*)',
+ );
+
+ if ($this->db->isPgsql()) {
+ $dummy = IcingaObject::createByType($type);
+ if (! $dummy->supportsApplyRules()) {
+ $columns['cnt_apply'] = '(0)';
+ }
+ }
+
+ $query = $this->db->getDbAdapter()->select()->from(
+ array('o' => 'icinga_' . $type),
+ $columns
+ );
+
+ return $this->applyRestrictions($type, $query);
+ }
+
+ protected function applyRestrictions($type, $query)
+ {
+ switch ($type) {
+ case 'host':
+ case 'hostgroup':
+ $r = new HostgroupRestriction($this->getDb(), $this->getAuth());
+ $r->applyToQuery($query);
+ break;
+ }
+
+ return $query;
+ }
+
+ protected function applyHostgroupRestrictions($query)
+ {
+ $restrictions = new HostgroupRestriction($this->getDb(), $this->getAuth());
+ $restrictions->applyToHostGroupsQuery($query);
+ }
+
+ protected function getAuth()
+ {
+ return Auth::getInstance();
+ }
+
+ protected function getCntSql($objectType)
+ {
+ return sprintf(
+ "COALESCE(SUM(CASE WHEN o.object_type = '%s' THEN 1 ELSE 0 END), 0)",
+ $objectType
+ );
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/ActivityLogDashlet.php b/library/Director/Dashboard/Dashlet/ActivityLogDashlet.php
new file mode 100644
index 0000000..9794986
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/ActivityLogDashlet.php
@@ -0,0 +1,35 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+class ActivityLogDashlet extends Dashlet
+{
+ protected $icon = 'book';
+
+ public function getTitle()
+ {
+ return $this->translate('Activity Log');
+ }
+
+ public function getSummary()
+ {
+ return $this->translate(
+ 'Wondering about what changed why? Track your changes!'
+ );
+ }
+
+ public function listCssClasses()
+ {
+ return 'state-ok';
+ }
+
+ public function getUrl()
+ {
+ return 'director/config/activities';
+ }
+
+ public function listRequiredPermissions()
+ {
+ return array('director/audit');
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/ApiUserObjectDashlet.php b/library/Director/Dashboard/Dashlet/ApiUserObjectDashlet.php
new file mode 100644
index 0000000..419859d
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/ApiUserObjectDashlet.php
@@ -0,0 +1,25 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+class ApiUserObjectDashlet extends Dashlet
+{
+ protected $icon = 'lock-open-alt';
+
+ protected $requiredStats = array('apiuser');
+
+ public function getTitle()
+ {
+ return $this->translate('Icinga Api users');
+ }
+
+ public function getUrl()
+ {
+ return 'director/apiusers';
+ }
+
+ public function listRequiredPermissions()
+ {
+ return array('director/admin');
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/BasketDashlet.php b/library/Director/Dashboard/Dashlet/BasketDashlet.php
new file mode 100644
index 0000000..10f2b81
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/BasketDashlet.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+class BasketDashlet extends Dashlet
+{
+ protected $icon = 'tag';
+
+ public function getTitle()
+ {
+ return $this->translate('Configuration Baskets');
+ }
+
+ public function getSummary()
+ {
+ return $this->translate(
+ 'Preserve specific configuration objects in a specific state'
+ );
+ }
+
+ public function getUrl()
+ {
+ return 'director/baskets';
+ }
+
+ public function listRequiredPermissions()
+ {
+ return array('director/admin');
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/CheckCommandsDashlet.php b/library/Director/Dashboard/Dashlet/CheckCommandsDashlet.php
new file mode 100644
index 0000000..65d8c8c
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/CheckCommandsDashlet.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+class CheckCommandsDashlet extends Dashlet
+{
+ protected $icon = 'wrench';
+
+ public function getSummary()
+ {
+ return $this->translate(
+ 'Manage definitions for your Commands that should be executed as'
+ . ' Check Plugins, Notifications or based on Events'
+ );
+ }
+
+ public function getTitle()
+ {
+ return $this->translate('Commands');
+ }
+
+ public function listRequiredPermissions()
+ {
+ return array('director/admin');
+ }
+
+ public function getUrl()
+ {
+ return 'director/commands';
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/ChoicesDashlet.php b/library/Director/Dashboard/Dashlet/ChoicesDashlet.php
new file mode 100644
index 0000000..efdbba5
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/ChoicesDashlet.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+abstract class ChoicesDashlet extends Dashlet
+{
+ protected $icon = 'flapping';
+
+ public function getTitle()
+ {
+ return $this->translate('Choices');
+ }
+
+ public function getSummary()
+ {
+ return $this->translate(
+ 'Combine multiple templates into meaningful Choices, making life'
+ . ' easier for your users'
+ );
+ }
+
+ protected function getType()
+ {
+ return strtolower(substr(
+ substr(get_called_class(), strlen(__NAMESPACE__) + 1),
+ 0,
+ - strlen('ChoicesDashlet')
+ ));
+ }
+
+ public function getUrl()
+ {
+
+ return 'director/templatechoices/' . $this->getType();
+ }
+
+ public function listRequiredPermissions()
+ {
+ return array('director/admin');
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/CommandObjectDashlet.php b/library/Director/Dashboard/Dashlet/CommandObjectDashlet.php
new file mode 100644
index 0000000..083172e
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/CommandObjectDashlet.php
@@ -0,0 +1,25 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+class CommandObjectDashlet extends Dashlet
+{
+ protected $icon = 'wrench';
+
+ protected $requiredStats = array('command');
+
+ public function getTitle()
+ {
+ return $this->translate('Commands');
+ }
+
+ public function getUrl()
+ {
+ return 'director/dashboard?name=commands';
+ }
+
+ public function listRequiredPermissions()
+ {
+ return array('director/admin');
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/CommandTemplatesDashlet.php b/library/Director/Dashboard/Dashlet/CommandTemplatesDashlet.php
new file mode 100644
index 0000000..512298a
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/CommandTemplatesDashlet.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+class CommandTemplatesDashlet extends CheckCommandsDashlet
+{
+ protected $icon = 'cubes';
+
+ public function getSummary()
+ {
+ return $this->translate(
+ 'External Notification Commands have been defined in your local Icinga 2'
+ . ' Configuration.'
+ );
+ }
+
+ public function getTitle()
+ {
+ return $this->translate('Command Templates');
+ }
+
+ public function getUrl()
+ {
+ return 'director/commands/templates';
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/CustomvarDashlet.php b/library/Director/Dashboard/Dashlet/CustomvarDashlet.php
new file mode 100644
index 0000000..919c06b
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/CustomvarDashlet.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+class CustomvarDashlet extends Dashlet
+{
+ protected $icon = 'keyboard';
+
+ public function getTitle()
+ {
+ return $this->translate('CustomVar Overview');
+ }
+
+ public function getSummary()
+ {
+ return $this->translate(
+ 'Get an overview of used CustomVars and their variants'
+ );
+ }
+
+ public function getUrl()
+ {
+ return 'director/data/vars';
+ }
+
+ public function listRequiredPermissions()
+ {
+ return array('director/admin');
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/Dashlet.php b/library/Director/Dashboard/Dashlet/Dashlet.php
new file mode 100644
index 0000000..f8bc708
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/Dashlet.php
@@ -0,0 +1,239 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+use Icinga\Module\Director\Acl;
+use Icinga\Module\Director\Db;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use gipfl\IcingaWeb2\Icon;
+use gipfl\IcingaWeb2\Link;
+use gipfl\Translation\TranslationHelper;
+
+abstract class Dashlet extends BaseHtmlElement
+{
+ use TranslationHelper;
+
+ /** @var Db */
+ protected $db;
+
+ protected $tag = 'li';
+
+ protected $icon = 'help';
+
+ protected $stats;
+
+ protected $requiredStats = [];
+
+ public function __construct(Db $db)
+ {
+ $this->db = $db;
+ }
+
+ /**
+ * @return string[]
+ */
+ public function listRequiredStats()
+ {
+ return $this->requiredStats;
+ }
+
+ public function addStats($type, $stats)
+ {
+ $this->stats[$type] = $stats;
+ }
+
+ /**
+ * @param $name
+ * @param Db $db
+ * @return Dashlet
+ */
+ public static function loadByName($name, Db $db)
+ {
+ /** @var Dashlet */
+ $class = __NAMESPACE__ . '\\' . $name . 'Dashlet';
+ return new $class($db);
+ }
+
+ public static function loadByNames(array $names, Db $db)
+ {
+ $dashlets = [];
+ foreach ($names as $name) {
+ $dashlet = static::loadByName($name, $db);
+
+ if ($dashlet->isAllowed()) {
+ $dashlets[] = $dashlet;
+ }
+ }
+
+ return $dashlets;
+ }
+
+ public function listCssClasses()
+ {
+ return [];
+ }
+
+ public function getIconName()
+ {
+ return $this->icon;
+ }
+
+ abstract public function getTitle();
+
+ abstract public function getUrl();
+
+ protected function assemble()
+ {
+ $this->add(Link::create([
+ $this->getTitle(),
+ Icon::create($this->getIconName()),
+ Html::tag('p', null, $this->getSummary())
+ ], $this->getUrl(), null, [
+ 'class' => $this->listCssClasses()
+ ]));
+ }
+
+ public function listRequiredPermissions()
+ {
+ return array($this->getUrl());
+ }
+
+ public function isAllowed()
+ {
+ $acl = Acl::instance();
+ foreach ($this->listRequiredPermissions() as $perm) {
+ if (! $acl->hasPermission($perm)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ public function shouldBeShown()
+ {
+ return true;
+ }
+
+ public function getSummary()
+ {
+ $result = '';
+ if (! empty($this->requiredStats)) {
+ reset($this->requiredStats);
+ $result .= $this->statSummary(current($this->requiredStats));
+ }
+
+ return $result;
+ }
+
+ public function getStats($type, $name = null)
+ {
+ if ($name === null) {
+ return $this->stats[$type];
+ } else {
+ return $this->stats[$type]->{'cnt_' . $name};
+ }
+ }
+
+ protected function getTemplateSummaryText($type)
+ {
+ $cnt = (int) $this->stats[$type]->cnt_template;
+
+ if ($cnt === 0) {
+ return $this->translate('No template has been defined yet');
+ }
+
+ if ($cnt === 1) {
+ return $this->translate('One template has been defined');
+ }
+
+ return sprintf(
+ $this->translate('%d templates have been defined'),
+ $cnt
+ );
+ }
+
+ protected function getApplySummaryText($type)
+ {
+ $cnt = (int) $this->stats[$type]->cnt_apply;
+
+ if ($cnt === 0) {
+ return $this->translate('No apply rule has been defined yet');
+ }
+
+ if ($cnt === 1) {
+ return $this->translate('One apply rule has been defined');
+ }
+
+ return sprintf(
+ $this->translate('%d apply rules have been defined'),
+ $cnt
+ );
+ }
+
+ protected function statSummary($type)
+ {
+ $stat = $this->stats[$type];
+
+ if ((int) $stat->cnt_total === 0) {
+ return $this->translate('No object has been defined yet');
+ }
+
+ if ((int) $stat->cnt_total === 1) {
+ if ($stat->cnt_template > 0) {
+ $msg = $this->translate('One template has been defined');
+ } elseif ($stat->cnt_external > 0) {
+ $msg = $this->translate(
+ 'One external object has been defined, it will not be deployed'
+ );
+ } else {
+ $msg = $this->translate('One object has been defined');
+ }
+ } else {
+ $msg = sprintf(
+ $this->translate('%d objects have been defined'),
+ $stat->cnt_total
+ );
+ }
+
+ $extra = array();
+ if ($stat->cnt_total !== $stat->cnt_object) {
+ if ($stat->cnt_template > 0) {
+ $extra[] = sprintf(
+ $this->translate('%d of them are templates'),
+ $stat->cnt_template
+ );
+ }
+
+ if ($stat->cnt_external > 0) {
+ $extra[] = sprintf(
+ $this->translate(
+ '%d have been externally defined and will not be deployed'
+ ),
+ $stat->cnt_external
+ );
+ }
+ }
+
+ if (array_key_exists($type . 'group', $this->stats)) {
+ $groupstat = $this->stats[$type . 'group'];
+ if ((int) $groupstat->cnt_total === 0) {
+ $extra[] = $this->translate('no related group exists');
+ } elseif ((int) $groupstat->cnt_total === 1) {
+ $extra[] = $this->translate('one related group exists');
+ } else {
+ $extra[] = sprintf(
+ $this->translate('%s related group objects have been created'),
+ $groupstat->cnt_total
+ );
+ }
+ }
+
+ if (empty($extra)) {
+ return $msg;
+ }
+
+ return $msg . ', ' . implode(', ', $extra);
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/DatafieldCategoryDashlet.php b/library/Director/Dashboard/Dashlet/DatafieldCategoryDashlet.php
new file mode 100644
index 0000000..6efb4ca
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/DatafieldCategoryDashlet.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+class DatafieldCategoryDashlet extends Dashlet
+{
+ protected $icon = 'th-list';
+
+ public function getTitle()
+ {
+ return $this->translate('Data Field Categories');
+ }
+
+ public function getSummary()
+ {
+ return $this->translate(
+ 'Categories bring structure to your Data Fields'
+ );
+ }
+
+ public function getUrl()
+ {
+ return 'director/data/fieldcategories';
+ }
+
+ public function listRequiredPermissions()
+ {
+ return array('director/admin');
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/DatafieldDashlet.php b/library/Director/Dashboard/Dashlet/DatafieldDashlet.php
new file mode 100644
index 0000000..03f2d8d
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/DatafieldDashlet.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+class DatafieldDashlet extends Dashlet
+{
+ protected $icon = 'edit';
+
+ public function getTitle()
+ {
+ return $this->translate('Define Data Fields');
+ }
+
+ public function getSummary()
+ {
+ return $this->translate(
+ 'Data fields make sure that configuration fits your rules'
+ );
+ }
+
+ public function getUrl()
+ {
+ return 'director/data/fields';
+ }
+
+ public function listRequiredPermissions()
+ {
+ return array('director/admin');
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/DatalistDashlet.php b/library/Director/Dashboard/Dashlet/DatalistDashlet.php
new file mode 100644
index 0000000..bdf179f
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/DatalistDashlet.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+class DatalistDashlet extends Dashlet
+{
+ protected $icon = 'sort-name-up';
+
+ public function getTitle()
+ {
+ return $this->translate('Provide Data Lists');
+ }
+
+ public function getSummary()
+ {
+ return $this->translate(
+ 'Provide data lists to make life easier for your users'
+ );
+ }
+
+ public function getUrl()
+ {
+ return 'director/data/lists';
+ }
+
+ public function listRequiredPermissions()
+ {
+ return array('director/admin');
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/DependencyObjectDashlet.php b/library/Director/Dashboard/Dashlet/DependencyObjectDashlet.php
new file mode 100644
index 0000000..47a18aa
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/DependencyObjectDashlet.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+class DependencyObjectDashlet extends Dashlet
+{
+ protected $icon = 'sitemap';
+
+ protected $requiredStats = array('dependency');
+
+ public function getTitle()
+ {
+ return $this->translate('Dependencies');
+ }
+
+ public function getSummary()
+ {
+ return $this->translate('Object dependency relationships.')
+ . ' ' . parent::getSummary();
+ }
+
+ public function getUrl()
+ {
+ return 'director/dependencies/applyrules';
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/DeploymentDashlet.php b/library/Director/Dashboard/Dashlet/DeploymentDashlet.php
new file mode 100644
index 0000000..7a52793
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/DeploymentDashlet.php
@@ -0,0 +1,114 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+use Exception;
+use Icinga\Module\Director\Objects\DirectorDeploymentLog;
+
+class DeploymentDashlet extends Dashlet
+{
+ protected $icon = 'wrench';
+
+ protected $undeployedActivities;
+
+ protected $lastDeployment;
+
+ public function getTitle()
+ {
+ return $this->translate('Config Deployment');
+ }
+
+ public function hasUndeployedActivities()
+ {
+ return $this->undeployedActivities() > 0;
+ }
+
+ public function undeployedActivities()
+ {
+ if ($this->undeployedActivities === null) {
+ try {
+ $this->undeployedActivities = $this->db
+ ->countActivitiesSinceLastDeployedConfig();
+ } catch (Exception $e) {
+ $this->undeployedActivities = 0;
+ }
+ }
+
+ return $this->undeployedActivities;
+ }
+
+ public function lastDeploymentFailed()
+ {
+ return ! $this->lastDeployment()->succeeded();
+ }
+
+ public function lastDeploymentPending()
+ {
+ return $this->lastDeployment()->isPending();
+ }
+
+ public function listCssClasses()
+ {
+ try {
+ if ($this->lastDeploymentFailed()) {
+ return array('state-critical');
+ } elseif ($this->lastDeploymentPending()) {
+ return array('state-pending');
+ } elseif ($this->hasUndeployedActivities()) {
+ return array('state-warning');
+ } else {
+ return array('state-ok');
+ }
+ } catch (Exception $e) {
+ return null;
+ }
+ }
+
+ protected function lastDeployment()
+ {
+ if ($this->lastDeployment === null) {
+ $this->lastDeployment = DirectorDeploymentLog::loadLatest($this->db);
+ }
+
+ return $this->lastDeployment;
+ }
+
+ public function getSummary()
+ {
+ $msgs = array();
+ $cnt = $this->undeployedActivities();
+
+ try {
+ if ($this->lastDeploymentFailed()) {
+ $msgs[] = $this->translate('The last deployment did not succeed');
+ } elseif ($this->lastDeploymentPending()) {
+ $msgs[] = $this->translate('The last deployment is currently pending');
+ }
+ } catch (Exception $e) {
+ }
+
+ if ($cnt === 0) {
+ $msgs[] = $this->translate('There are no pending changes');
+ } else {
+ $msgs[] = sprintf(
+ $this->translate(
+ 'A total of %d config changes happened since your last'
+ . ' deployed config has been rendered'
+ ),
+ $cnt
+ );
+ }
+
+ return implode('. ', $msgs) . '.';
+ }
+
+ public function getUrl()
+ {
+ return 'director/config/deployments';
+ }
+
+ public function listRequiredPermissions()
+ {
+ return array('director/deploy');
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/EndpointObjectDashlet.php b/library/Director/Dashboard/Dashlet/EndpointObjectDashlet.php
new file mode 100644
index 0000000..9dd9467
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/EndpointObjectDashlet.php
@@ -0,0 +1,63 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+use Exception;
+
+class EndpointObjectDashlet extends Dashlet
+{
+ protected $icon = 'cloud';
+
+ protected $requiredStats = array('endpoint');
+
+ protected $hasDeploymentEndpoint;
+
+ public function getTitle()
+ {
+ return $this->translate('Endpoints');
+ }
+
+ public function getUrl()
+ {
+ return 'director/endpoints';
+ }
+
+ public function listRequiredPermissions()
+ {
+ return array('director/admin');
+ }
+
+ protected function hasDeploymentEndpoint()
+ {
+ if ($this->hasDeploymentEndpoint === null) {
+ try {
+ $this->hasDeploymentEndpoint = $this->db->hasDeploymentEndpoint();
+ } catch (Exception $e) {
+ return false;
+ }
+ }
+
+ return $this->hasDeploymentEndpoint;
+ }
+
+ public function listCssClasses()
+ {
+ if (! $this->hasDeploymentEndpoint()) {
+ return 'state-critical';
+ }
+
+ return null;
+ }
+
+ public function getSummary()
+ {
+ $msg = parent::getSummary();
+ if (! $this->hasDeploymentEndpoint()) {
+ $msg .= '. ' . $this->translate(
+ 'None could be used for deployments right now'
+ );
+ }
+
+ return $msg;
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/ExternalCheckCommandsDashlet.php b/library/Director/Dashboard/Dashlet/ExternalCheckCommandsDashlet.php
new file mode 100644
index 0000000..2711fb9
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/ExternalCheckCommandsDashlet.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+class ExternalCheckCommandsDashlet extends CheckCommandsDashlet
+{
+ protected $icon = 'download';
+
+ public function getSummary()
+ {
+ return $this->translate(
+ 'External Commands have been defined in your local Icinga 2'
+ . ' Configuration.'
+ );
+ }
+
+ public function getTitle()
+ {
+ return $this->translate('External Commands');
+ }
+
+ public function getUrl()
+ {
+ return 'director/commands?type=external_object';
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/ExternalNotificationCommandsDashlet.php b/library/Director/Dashboard/Dashlet/ExternalNotificationCommandsDashlet.php
new file mode 100644
index 0000000..435d0cb
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/ExternalNotificationCommandsDashlet.php
@@ -0,0 +1,21 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+class ExternalNotificationCommandsDashlet extends CheckCommandsDashlet
+{
+ protected $icon = 'wrench';
+
+ public function getSummary()
+ {
+ return $this->translate(
+ 'External Notification Commands have been defined in your local Icinga 2'
+ . ' Configuration.'
+ );
+ }
+
+ public function getTitle()
+ {
+ return $this->translate('External Notification Commands');
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/HostChoicesDashlet.php b/library/Director/Dashboard/Dashlet/HostChoicesDashlet.php
new file mode 100644
index 0000000..98bfe32
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/HostChoicesDashlet.php
@@ -0,0 +1,7 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+class HostChoicesDashlet extends ChoicesDashlet
+{
+}
diff --git a/library/Director/Dashboard/Dashlet/HostGroupsDashlet.php b/library/Director/Dashboard/Dashlet/HostGroupsDashlet.php
new file mode 100644
index 0000000..5d3b25f
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/HostGroupsDashlet.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+class HostGroupsDashlet extends Dashlet
+{
+ protected $icon = 'tags';
+
+ public function getTitle()
+ {
+ return $this->translate('Host Groups');
+ }
+
+ public function getSummary()
+ {
+ return $this->translate(
+ 'Define Host Groups to give your configuration more structure. They'
+ . ' are useful for Dashboards, Notifications or Restrictions'
+ );
+ }
+
+ public function getUrl()
+ {
+ return 'director/hostgroups';
+ }
+
+ public function listRequiredPermissions()
+ {
+ return array('director/hostgroups');
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/HostObjectDashlet.php b/library/Director/Dashboard/Dashlet/HostObjectDashlet.php
new file mode 100644
index 0000000..10cff94
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/HostObjectDashlet.php
@@ -0,0 +1,25 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+class HostObjectDashlet extends Dashlet
+{
+ protected $icon = 'host';
+
+ protected $requiredStats = array('host', 'hostgroup');
+
+ public function getTitle()
+ {
+ return $this->translate('Host objects');
+ }
+
+ public function listRequiredPermissions()
+ {
+ return ['director/hosts'];
+ }
+
+ public function getUrl()
+ {
+ return 'director/dashboard?name=hosts';
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/HostTemplatesDashlet.php b/library/Director/Dashboard/Dashlet/HostTemplatesDashlet.php
new file mode 100644
index 0000000..09bed17
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/HostTemplatesDashlet.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+class HostTemplatesDashlet extends Dashlet
+{
+ protected $icon = 'cubes';
+
+ public function getTitle()
+ {
+ return $this->translate('Host Templates');
+ }
+
+ public function getSummary()
+ {
+ return $this->translate(
+ 'Manage your Host Templates. Use Fields to make it easy for'
+ . ' your users to get them customized.'
+ );
+ }
+
+ public function getUrl()
+ {
+ return 'director/hosts/templates';
+ }
+
+ public function listRequiredPermissions()
+ {
+ return array('director/admin');
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/HostsDashlet.php b/library/Director/Dashboard/Dashlet/HostsDashlet.php
new file mode 100644
index 0000000..39c1421
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/HostsDashlet.php
@@ -0,0 +1,32 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+class HostsDashlet extends Dashlet
+{
+ protected $icon = 'host';
+
+ public function getTitle()
+ {
+ return $this->translate('Hosts');
+ }
+
+ public function getSummary()
+ {
+ return $this->translate(
+ 'This is where you add all your servers, containers, network or'
+ . ' sensor devices - and much more. Every subject worth to be'
+ . ' monitored'
+ );
+ }
+
+ public function getUrl()
+ {
+ return 'director/hosts';
+ }
+
+ public function listRequiredPermissions()
+ {
+ return ['director/hosts'];
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/ImportSourceDashlet.php b/library/Director/Dashboard/Dashlet/ImportSourceDashlet.php
new file mode 100644
index 0000000..302c1ed
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/ImportSourceDashlet.php
@@ -0,0 +1,65 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+use Exception;
+use Icinga\Module\Director\Objects\ImportSource;
+
+class ImportSourceDashlet extends Dashlet
+{
+ protected $icon = 'database';
+
+ public function getTitle()
+ {
+ return $this->translate('Import data sources');
+ }
+
+ public function listCssClasses()
+ {
+ try {
+ return $this->fetchStateClass();
+ } catch (Exception $e) {
+ return 'state-critical';
+ }
+ }
+
+ public function getSummary()
+ {
+ return $this->translate(
+ 'Define and manage imports from various data sources'
+ );
+ }
+
+ protected function fetchStateClass()
+ {
+ $srcs = ImportSource::loadAll($this->db);
+ if (count($srcs) > 0) {
+ $state = 'state-ok';
+ } else {
+ $state = null;
+ }
+
+ foreach ($srcs as $src) {
+ if ($src->import_state !== 'in-sync') {
+ if ($src->import_state === 'failing') {
+ $state = 'state-critical';
+ break;
+ } else {
+ $state = 'state-warning';
+ }
+ }
+ }
+
+ return $state;
+ }
+
+ public function getUrl()
+ {
+ return 'director/importsources';
+ }
+
+ public function listRequiredPermissions()
+ {
+ return array('director/admin');
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/InfrastructureDashlet.php b/library/Director/Dashboard/Dashlet/InfrastructureDashlet.php
new file mode 100644
index 0000000..328df72
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/InfrastructureDashlet.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+class InfrastructureDashlet extends Dashlet
+{
+ protected $icon = 'cloud';
+
+ public function getTitle()
+ {
+ return $this->translate('Icinga Infrastructure');
+ }
+
+ public function getSummary()
+ {
+ return $this->translate(
+ 'Manage your Icinga 2 infrastructure: Masters, Zones, Satellites and more'
+ );
+ }
+
+ public function getUrl()
+ {
+ return 'director/dashboard?name=infrastructure';
+ }
+
+ public function listRequiredPermissions()
+ {
+ return array('director/admin');
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/JobDashlet.php b/library/Director/Dashboard/Dashlet/JobDashlet.php
new file mode 100644
index 0000000..d7452e0
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/JobDashlet.php
@@ -0,0 +1,65 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+use Exception;
+use Icinga\Module\Director\Objects\DirectorJob;
+
+class JobDashlet extends Dashlet
+{
+ protected $icon = 'clock';
+
+ public function getTitle()
+ {
+ return $this->translate('Jobs');
+ }
+
+ public function listCssClasses()
+ {
+ try {
+ return $this->fetchStateClass();
+ } catch (Exception $e) {
+ return 'state-critical';
+ }
+ }
+
+ public function getSummary()
+ {
+ return $this->translate(
+ 'Schedule and automate Import, Syncronization, Config Deployment,'
+ . ' Housekeeping and more'
+ );
+ }
+
+ protected function fetchStateClass()
+ {
+ /** @var DirectorJob[] $jobs */
+ $jobs = DirectorJob::loadAll($this->db);
+ if (count($jobs) > 0) {
+ $state = 'state-ok';
+ } else {
+ $state = null;
+ }
+
+ foreach ($jobs as $job) {
+ if ($job->isPending()) {
+ $state = 'state-pending';
+ } elseif (! $job->lastAttemptSucceeded()) {
+ $state = 'state-critical';
+ break;
+ }
+ }
+
+ return $state;
+ }
+
+ public function getUrl()
+ {
+ return 'director/jobs';
+ }
+
+ public function listRequiredPermissions()
+ {
+ return array('director/admin');
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/KickstartDashlet.php b/library/Director/Dashboard/Dashlet/KickstartDashlet.php
new file mode 100644
index 0000000..09801f5
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/KickstartDashlet.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+class KickstartDashlet extends Dashlet
+{
+ protected $icon = 'gauge';
+
+ public function getTitle()
+ {
+ return $this->translate('Kickstart Wizard');
+ }
+
+ public function getSummary()
+ {
+ return $this->translate(
+ 'This synchronizes Icinga Director to your Icinga 2 infrastructure.'
+ . ' A new run should be triggered on infrastructure changes'
+ );
+ }
+
+ public function getUrl()
+ {
+ return 'director/kickstart';
+ }
+
+ public function listRequiredPermissions()
+ {
+ return array('director/admin');
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/NotificationApplyDashlet.php b/library/Director/Dashboard/Dashlet/NotificationApplyDashlet.php
new file mode 100644
index 0000000..e0b0443
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/NotificationApplyDashlet.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+class NotificationApplyDashlet extends Dashlet
+{
+ protected $icon = 'bell';
+
+ protected $requiredStats = array('notification');
+
+ public function getTitle()
+ {
+ return $this->translate('Notifications');
+ }
+
+ public function getSummary()
+ {
+ return $this->translate(
+ 'Apply notifications with specific properties according to given rules.'
+ ) . ' ' . $this->getApplySummaryText('notification');
+ }
+
+ public function shouldBeShown()
+ {
+ return $this->getStats('notification', 'template') > 0;
+ }
+
+ public function listRequiredPermissions()
+ {
+ return array('director/notifications');
+ }
+
+ public function getUrl()
+ {
+ return 'director/notifications/applyrules';
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/NotificationCommandsDashlet.php b/library/Director/Dashboard/Dashlet/NotificationCommandsDashlet.php
new file mode 100644
index 0000000..0a640ae
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/NotificationCommandsDashlet.php
@@ -0,0 +1,21 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+class NotificationCommandsDashlet extends CheckCommandsDashlet
+{
+ protected $icon = 'wrench';
+
+ public function getSummary()
+ {
+ return $this->translate(
+ 'Notification Commands allow you to trigger any action you want when'
+ . ' a notification takes place'
+ );
+ }
+
+ public function getTitle()
+ {
+ return $this->translate('Notification Commands');
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/NotificationTemplateDashlet.php b/library/Director/Dashboard/Dashlet/NotificationTemplateDashlet.php
new file mode 100644
index 0000000..a58b5d0
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/NotificationTemplateDashlet.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+class NotificationTemplateDashlet extends Dashlet
+{
+ protected $icon = 'cubes';
+
+ protected $requiredStats = array('notification');
+
+ public function getTitle()
+ {
+ return $this->translate('Notification templates');
+ }
+
+ public function getSummary()
+ {
+ return $this->translate('Provide templates for your notifications.')
+ . ' ' . $this->getTemplateSummaryText('notification');
+ }
+
+ public function listRequiredPermissions()
+ {
+ return array('director/admin');
+ }
+
+ public function getUrl()
+ {
+ return 'director/notifications/templates';
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/NotificationsDashlet.php b/library/Director/Dashboard/Dashlet/NotificationsDashlet.php
new file mode 100644
index 0000000..85610f0
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/NotificationsDashlet.php
@@ -0,0 +1,33 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+class NotificationsDashlet extends Dashlet
+{
+ protected $icon = 'bell';
+
+ protected $requiredStats = array('notification');
+
+ public function getTitle()
+ {
+ return $this->translate('Notifications');
+ }
+
+ public function getSummary()
+ {
+ return $this->translate(
+ 'Schedule your notifications. Define who should be notified, when,'
+ . ' and for which kind of problem'
+ );
+ }
+
+ public function listRequiredPermissions()
+ {
+ return array('director/notifications');
+ }
+
+ public function getUrl()
+ {
+ return 'director/dashboard?name=notifications';
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/ScheduledDowntimeApplyDashlet.php b/library/Director/Dashboard/Dashlet/ScheduledDowntimeApplyDashlet.php
new file mode 100644
index 0000000..45bcfa2
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/ScheduledDowntimeApplyDashlet.php
@@ -0,0 +1,25 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+class ScheduledDowntimeApplyDashlet extends Dashlet
+{
+ protected $icon = 'plug';
+
+ protected $requiredStats = ['scheduled_downtime'];
+
+ public function getTitle()
+ {
+ return $this->translate('Scheduled Downtimes');
+ }
+
+ public function listRequiredPermissions()
+ {
+ return array('director/scheduled-downtimes');
+ }
+
+ public function getUrl()
+ {
+ return 'director/scheduled-downtimes/applyrules';
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/SelfServiceDashlet.php b/library/Director/Dashboard/Dashlet/SelfServiceDashlet.php
new file mode 100644
index 0000000..32b1cfa
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/SelfServiceDashlet.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+class SelfServiceDashlet extends Dashlet
+{
+ protected $icon = 'chat';
+
+ public function getTitle()
+ {
+ return $this->translate('Self Service API');
+ }
+
+ public function getSummary()
+ {
+ return $this->translate(
+ 'Icinga Director offers a Self Service API, allowing new Icinga'
+ . ' nodes to register themselves'
+ );
+ }
+
+ public function getUrl()
+ {
+ return 'director/settings/self-service';
+ }
+
+ public function listRequiredPermissions()
+ {
+ return array('director/admin');
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/ServiceApplyRulesDashlet.php b/library/Director/Dashboard/Dashlet/ServiceApplyRulesDashlet.php
new file mode 100644
index 0000000..b4bee04
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/ServiceApplyRulesDashlet.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+class ServiceApplyRulesDashlet extends Dashlet
+{
+ protected $icon = 'resize-full-alt';
+
+ public function getTitle()
+ {
+ return $this->translate('Service Apply Rules');
+ }
+
+ public function getSummary()
+ {
+ return $this->translate(
+ 'Using Apply Rules a Service can be applied to multiple hosts at once,'
+ . ' based on filters dealing with any combination of their properties'
+ );
+ }
+
+ public function getUrl()
+ {
+ return 'director/services/applyrules';
+ }
+
+ public function listRequiredPermissions()
+ {
+ return array('director/admin');
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/ServiceChoicesDashlet.php b/library/Director/Dashboard/Dashlet/ServiceChoicesDashlet.php
new file mode 100644
index 0000000..ff23321
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/ServiceChoicesDashlet.php
@@ -0,0 +1,7 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+class ServiceChoicesDashlet extends ChoicesDashlet
+{
+}
diff --git a/library/Director/Dashboard/Dashlet/ServiceGroupsDashlet.php b/library/Director/Dashboard/Dashlet/ServiceGroupsDashlet.php
new file mode 100644
index 0000000..ad47768
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/ServiceGroupsDashlet.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+class ServiceGroupsDashlet extends Dashlet
+{
+ protected $icon = 'tags';
+
+ public function getTitle()
+ {
+ return $this->translate('Service Groups');
+ }
+
+ public function getSummary()
+ {
+ return $this->translate(
+ 'Defining Service Groups get more structure. Great for Dashboards.'
+ . ' Notifications and Permissions might be based on groups.'
+ );
+ }
+
+ public function getUrl()
+ {
+ return 'director/servicegroups';
+ }
+
+ public function listRequiredPermissions()
+ {
+ return array('director/admin');
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/ServiceObjectDashlet.php b/library/Director/Dashboard/Dashlet/ServiceObjectDashlet.php
new file mode 100644
index 0000000..01fb800
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/ServiceObjectDashlet.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+use Icinga\Module\Director\Acl;
+
+class ServiceObjectDashlet extends Dashlet
+{
+ protected $icon = 'services';
+
+ protected $requiredStats = array('service', 'servicegroup');
+
+ public function getTitle()
+ {
+ return $this->translate('Monitored Services');
+ }
+
+ public function getUrl()
+ {
+ return 'director/dashboard?name=services';
+ }
+
+ public function listRequiredPermissions()
+ {
+ return ['director/services'];
+ }
+
+ public function isAllowed()
+ {
+ $acl = Acl::instance();
+ return $acl->hasPermission('director/services')
+ || $acl->hasPermission('director/service_sets');
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/ServiceSetsDashlet.php b/library/Director/Dashboard/Dashlet/ServiceSetsDashlet.php
new file mode 100644
index 0000000..f971d42
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/ServiceSetsDashlet.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+class ServiceSetsDashlet extends Dashlet
+{
+ protected $icon = 'services';
+
+ public function getTitle()
+ {
+ return $this->translate('Service Sets');
+ }
+
+ public function getSummary()
+ {
+ return $this->translate(
+ 'Grouping your Services into Sets allow you to quickly assign services'
+ . ' often used together in a single operation all at once'
+ );
+ }
+
+ public function getUrl()
+ {
+ return 'director/services/sets';
+ }
+
+ public function listRequiredPermissions()
+ {
+ return array('director/servicesets');
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/ServiceTemplatesDashlet.php b/library/Director/Dashboard/Dashlet/ServiceTemplatesDashlet.php
new file mode 100644
index 0000000..62d1b41
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/ServiceTemplatesDashlet.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+class ServiceTemplatesDashlet extends Dashlet
+{
+ protected $icon = 'cubes';
+
+ public function getTitle()
+ {
+ return $this->translate('Service Templates');
+ }
+
+ public function getSummary()
+ {
+ return $this->translate(
+ 'Manage your Service Templates. Use Fields to make it easy for'
+ . ' your users to get them customized.'
+ );
+ }
+
+ public function getUrl()
+ {
+ return 'director/services/templates';
+ }
+
+ public function listRequiredPermissions()
+ {
+ return array('director/admin');
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/SettingsDashlet.php b/library/Director/Dashboard/Dashlet/SettingsDashlet.php
new file mode 100644
index 0000000..716e565
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/SettingsDashlet.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+class SettingsDashlet extends Dashlet
+{
+ protected $icon = 'edit';
+
+ public function getTitle()
+ {
+ return $this->translate('Director Settings');
+ }
+
+ public function getSummary()
+ {
+ return $this->translate(
+ 'Tweak some global Director settings'
+ );
+ }
+
+ public function getUrl()
+ {
+ return 'director/config/settings';
+ }
+
+ public function listRequiredPermissions()
+ {
+ return array('director/admin');
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/SingleServicesDashlet.php b/library/Director/Dashboard/Dashlet/SingleServicesDashlet.php
new file mode 100644
index 0000000..297b3f8
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/SingleServicesDashlet.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+class SingleServicesDashlet extends Dashlet
+{
+ protected $icon = 'service';
+
+ public function getTitle()
+ {
+ return $this->translate('Single Services');
+ }
+
+ public function getSummary()
+ {
+ return $this->translate(
+ 'Here you can find all single services directly attached to single'
+ . ' hosts'
+ );
+ }
+
+ public function getUrl()
+ {
+ return 'director/services';
+ }
+
+ public function listRequiredPermissions()
+ {
+ return array('director/services');
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/SyncDashlet.php b/library/Director/Dashboard/Dashlet/SyncDashlet.php
new file mode 100644
index 0000000..4ac689a
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/SyncDashlet.php
@@ -0,0 +1,65 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+use Exception;
+use Icinga\Module\Director\Objects\SyncRule;
+
+class SyncDashlet extends Dashlet
+{
+ protected $icon = 'flapping';
+
+ public function getTitle()
+ {
+ return $this->translate('Synchronize');
+ }
+
+ public function listCssClasses()
+ {
+ try {
+ return $this->fetchStateClass();
+ } catch (Exception $e) {
+ return 'state-critical';
+ }
+ }
+
+ public function getSummary()
+ {
+ return $this->translate(
+ 'Define how imported data should be synchronized with Icinga'
+ );
+ }
+
+ protected function fetchStateClass()
+ {
+ $syncs = SyncRule::loadAll($this->db);
+ if (count($syncs) > 0) {
+ $state = 'state-ok';
+ } else {
+ $state = null;
+ }
+
+ foreach ($syncs as $sync) {
+ if ($sync->sync_state !== 'in-sync') {
+ if ($sync->sync_state === 'failing') {
+ $state = 'state-critical';
+ break;
+ } else {
+ $state = 'state-warning';
+ }
+ }
+ }
+
+ return $state;
+ }
+
+ public function getUrl()
+ {
+ return 'director/syncrules';
+ }
+
+ public function listRequiredPermissions()
+ {
+ return array('director/admin');
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/TimeperiodObjectDashlet.php b/library/Director/Dashboard/Dashlet/TimeperiodObjectDashlet.php
new file mode 100644
index 0000000..ba4c1db
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/TimeperiodObjectDashlet.php
@@ -0,0 +1,28 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+use DirectoryIterator;
+use Icinga\Exception\ProgrammingError;
+
+class TimeperiodObjectDashlet extends Dashlet
+{
+ protected $icon = 'calendar';
+
+ protected $requiredStats = array('timeperiod');
+
+ public function getTitle()
+ {
+ return $this->translate('Timeperiods');
+ }
+
+ public function getUrl()
+ {
+ return 'director/timeperiods';
+ }
+
+ public function listRequiredPermissions()
+ {
+ return array('director/admin');
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/TimeperiodTemplateDashlet.php b/library/Director/Dashboard/Dashlet/TimeperiodTemplateDashlet.php
new file mode 100644
index 0000000..26339e4
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/TimeperiodTemplateDashlet.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+class TimeperiodTemplateDashlet extends Dashlet
+{
+ protected $icon = 'cubes';
+
+ protected $requiredStats = array('timeperiod');
+
+ public function getTitle()
+ {
+ return $this->translate('Timeperiod Templates');
+ }
+
+ public function getSummary()
+ {
+ return $this->translate('Provide templates for your TimePeriod objects.')
+ . ' ' . $this->getTemplateSummaryText('timeperiod');
+ }
+
+ public function listRequiredPermissions()
+ {
+ return array('director/admin');
+ }
+
+ public function getUrl()
+ {
+ return 'director/timeperiods/templates';
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/TimeperiodsDashlet.php b/library/Director/Dashboard/Dashlet/TimeperiodsDashlet.php
new file mode 100644
index 0000000..5a54bec
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/TimeperiodsDashlet.php
@@ -0,0 +1,25 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+class TimeperiodsDashlet extends Dashlet
+{
+ protected $icon = 'calendar';
+
+ protected $requiredStats = array('timeperiod');
+
+ public function getTitle()
+ {
+ return $this->translate('Timeperiods');
+ }
+
+ public function getUrl()
+ {
+ return 'director/dashboard?name=timeperiods';
+ }
+
+ public function listRequiredPermissions()
+ {
+ return array('director/admin');
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/UserGroupsDashlet.php b/library/Director/Dashboard/Dashlet/UserGroupsDashlet.php
new file mode 100644
index 0000000..3fba4ba
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/UserGroupsDashlet.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+class UserGroupsDashlet extends Dashlet
+{
+ protected $icon = 'tags';
+
+ public function getTitle()
+ {
+ return $this->translate('User Groups');
+ }
+
+ public function getSummary()
+ {
+ return $this->translate(
+ 'Defining Notifications for User Groups instead of single Users'
+ . ' gives more flexibility'
+ );
+ }
+
+ public function getUrl()
+ {
+ return 'director/usergroups';
+ }
+
+ public function listRequiredPermissions()
+ {
+ return array('director/admin');
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/UserObjectDashlet.php b/library/Director/Dashboard/Dashlet/UserObjectDashlet.php
new file mode 100644
index 0000000..463b84c
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/UserObjectDashlet.php
@@ -0,0 +1,23 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+use DirectoryIterator;
+use Icinga\Exception\ProgrammingError;
+
+class UserObjectDashlet extends Dashlet
+{
+ protected $icon = 'users';
+
+ protected $requiredStats = array('user', 'usergroup');
+
+ public function getTitle()
+ {
+ return $this->translate('Users / Contacts');
+ }
+
+ public function getUrl()
+ {
+ return 'director/users';
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/UserTemplateDashlet.php b/library/Director/Dashboard/Dashlet/UserTemplateDashlet.php
new file mode 100644
index 0000000..291ab05
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/UserTemplateDashlet.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+class UserTemplateDashlet extends Dashlet
+{
+ protected $icon = 'cubes';
+
+ protected $requiredStats = array('user');
+
+ public function getTitle()
+ {
+ return $this->translate('User Templates');
+ }
+
+ public function getSummary()
+ {
+ return $this->translate('Provide templates for your User objects.')
+ . ' ' . $this->getTemplateSummaryText('user');
+ }
+
+ public function listRequiredPermissions()
+ {
+ return array('director/admin');
+ }
+
+ public function getUrl()
+ {
+ return 'director/users/templates';
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/UsersDashlet.php b/library/Director/Dashboard/Dashlet/UsersDashlet.php
new file mode 100644
index 0000000..43ddc26
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/UsersDashlet.php
@@ -0,0 +1,25 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+class UsersDashlet extends Dashlet
+{
+ protected $icon = 'users';
+
+ protected $requiredStats = array('user', 'usergroup');
+
+ public function getTitle()
+ {
+ return $this->translate('Users / Contacts');
+ }
+
+ public function listRequiredPermissions()
+ {
+ return array('director/users');
+ }
+
+ public function getUrl()
+ {
+ return 'director/dashboard?name=users';
+ }
+}
diff --git a/library/Director/Dashboard/Dashlet/ZoneObjectDashlet.php b/library/Director/Dashboard/Dashlet/ZoneObjectDashlet.php
new file mode 100644
index 0000000..ee789f2
--- /dev/null
+++ b/library/Director/Dashboard/Dashlet/ZoneObjectDashlet.php
@@ -0,0 +1,25 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard\Dashlet;
+
+class ZoneObjectDashlet extends Dashlet
+{
+ protected $icon = 'globe';
+
+ protected $requiredStats = array('zone');
+
+ public function getTitle()
+ {
+ return $this->translate('Zones');
+ }
+
+ public function getUrl()
+ {
+ return 'director/zones';
+ }
+
+ public function listRequiredPermissions()
+ {
+ return array('director/admin');
+ }
+}
diff --git a/library/Director/Dashboard/DataDashboard.php b/library/Director/Dashboard/DataDashboard.php
new file mode 100644
index 0000000..36a807b
--- /dev/null
+++ b/library/Director/Dashboard/DataDashboard.php
@@ -0,0 +1,18 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard;
+
+class DataDashboard extends Dashboard
+{
+ protected $dashletNames = [
+ 'Datafield',
+ 'DatafieldCategory',
+ 'Datalist',
+ 'Customvar'
+ ];
+
+ public function getTitle()
+ {
+ return $this->translate('Do more with custom data');
+ }
+}
diff --git a/library/Director/Dashboard/DeploymentDashboard.php b/library/Director/Dashboard/DeploymentDashboard.php
new file mode 100644
index 0000000..6cd2005
--- /dev/null
+++ b/library/Director/Dashboard/DeploymentDashboard.php
@@ -0,0 +1,17 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard;
+
+class DeploymentDashboard extends Dashboard
+{
+ protected $dashletNames = array(
+ 'ActivityLog',
+ 'Deployment',
+ 'Infrastructure',
+ );
+
+ public function getTitle()
+ {
+ return $this->translate('Deploy configuration to your Icinga nodes');
+ }
+}
diff --git a/library/Director/Dashboard/DirectorDashboard.php b/library/Director/Dashboard/DirectorDashboard.php
new file mode 100644
index 0000000..47a17af
--- /dev/null
+++ b/library/Director/Dashboard/DirectorDashboard.php
@@ -0,0 +1,17 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard;
+
+class DirectorDashboard extends Dashboard
+{
+ protected $dashletNames = array(
+ 'Settings',
+ 'Basket',
+ 'SelfService',
+ );
+
+ public function getTitle()
+ {
+ return $this->translate('Icinga Director Configuration');
+ }
+}
diff --git a/library/Director/Dashboard/HostsDashboard.php b/library/Director/Dashboard/HostsDashboard.php
new file mode 100644
index 0000000..281accb
--- /dev/null
+++ b/library/Director/Dashboard/HostsDashboard.php
@@ -0,0 +1,35 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard;
+
+class HostsDashboard extends Dashboard
+{
+ protected $dashletNames = array(
+ 'Hosts',
+ 'HostTemplates',
+ 'HostGroups',
+ 'HostChoices',
+ );
+
+ public function getTitle()
+ {
+ return $this->translate('Manage your Icinga Hosts');
+ }
+
+ public function getDescription()
+ {
+ return $this->translate(
+ 'This is where you manage your Icinga 2 Host Checks. Host templates'
+ . ' are your main building blocks. You can bundle them to "choices",'
+ . ' allowing (or forcing) your users to choose among a given set of'
+ . ' preconfigured templates.'
+ );
+ }
+
+ public function getTabs()
+ {
+ return $this->createTabsForDashboards(
+ ['hosts', 'services', 'commands']
+ );
+ }
+}
diff --git a/library/Director/Dashboard/InfrastructureDashboard.php b/library/Director/Dashboard/InfrastructureDashboard.php
new file mode 100644
index 0000000..2b369fc
--- /dev/null
+++ b/library/Director/Dashboard/InfrastructureDashboard.php
@@ -0,0 +1,60 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard;
+
+use gipfl\Web\Widget\Hint;
+use Icinga\Application\Icinga;
+use Icinga\Authentication\Auth;
+use Icinga\Module\Director\Web\Tabs\InfraTabs;
+use Icinga\Module\Director\Web\Widget\Documentation;
+use ipl\Html\Html;
+use ipl\Html\HtmlDocument;
+
+class InfrastructureDashboard extends Dashboard
+{
+ protected $dashletNames = array(
+ 'Kickstart',
+ 'ApiUserObject',
+ 'EndpointObject',
+ 'ZoneObject',
+ );
+
+ public function getTitle()
+ {
+ return $this->translate('Manage your Icinga Infrastructure');
+ }
+
+ public function getDescription()
+ {
+ $documentation = new Documentation(Icinga::app(), Auth::getInstance());
+
+ $link = $documentation->getModuleLink(
+ $this->translate('documentation'),
+ 'director',
+ '24-Working-with-agents',
+ $this->translate('Working with Agents and Config Zones')
+ );
+ return (new HtmlDocument())->add([
+ $this->translate(
+ 'This is where you manage your Icinga 2 infrastructure. When adding'
+ . ' a new Icinga Master or Satellite please re-run the Kickstart'
+ . ' Helper once.'
+ ),
+ Hint::warning($this->translate(
+ 'When you feel the desire to manually create Zone or Endpoint'
+ . ' objects please rethink this twice. Doing so is mostly the wrong'
+ . ' way, might lead to a dead end, requiring quite some effort to'
+ . ' clean up the whole mess afterwards.'
+ )),
+ Html::sprintf(
+ $this->translate('Want to connect to your Icinga Agents? Have a look at our %s!'),
+ $link
+ )
+ ]);
+ }
+
+ public function getTabs()
+ {
+ return new InfraTabs($this->getAuth());
+ }
+}
diff --git a/library/Director/Dashboard/NotificationsDashboard.php b/library/Director/Dashboard/NotificationsDashboard.php
new file mode 100644
index 0000000..b7d72f5
--- /dev/null
+++ b/library/Director/Dashboard/NotificationsDashboard.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard;
+
+class NotificationsDashboard extends Dashboard
+{
+ protected $dashletNames = [
+ 'NotificationApply',
+ 'NotificationTemplate',
+ ];
+
+ public function getTitle()
+ {
+ return $this->translate('Schedule your notifications');
+ }
+
+ public function getDescription()
+ {
+ return $this->translate(
+ 'Notifications are sent when a host or service reaches a non-ok hard'
+ . ' state or recovers from such. One might also want to send them for'
+ . ' special events like when a Downtime starts, a problem gets'
+ . ' acknowledged and much more. You can send specific notifications'
+ . ' only within specific time periods, you can delay them and of course'
+ . ' re-notify at specific intervals.'
+ . "\n\n"
+ . ' Combine those possibilities in case you need to define escalation'
+ . ' levels, like notifying operators first and your management later on'
+ . ' in case the problem remains unhandled for a certain time.'
+ . "\n\n"
+ . ' You might send E-Mail or SMS, make phone calls or page on various'
+ . ' channels. You could also delegate notifications to external service'
+ . ' providers. The possibilities are endless, as you are allowed to'
+ . ' define as many custom notification commands as you want'
+ );
+ }
+
+ public function getTabs()
+ {
+ return $this->createTabsForDashboards(
+ ['notifications', 'users', 'timeperiods']
+ );
+ }
+}
diff --git a/library/Director/Dashboard/ObjectsDashboard.php b/library/Director/Dashboard/ObjectsDashboard.php
new file mode 100644
index 0000000..02c2a4b
--- /dev/null
+++ b/library/Director/Dashboard/ObjectsDashboard.php
@@ -0,0 +1,17 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard;
+
+class ObjectsDashboard extends Dashboard
+{
+ protected $dashletNames = array(
+ 'HostObject',
+ 'ServiceObject',
+ 'CommandObject',
+ );
+
+ public function getTitle()
+ {
+ return $this->translate('Define whatever you want to be monitored');
+ }
+}
diff --git a/library/Director/Dashboard/ServicesDashboard.php b/library/Director/Dashboard/ServicesDashboard.php
new file mode 100644
index 0000000..65c8f0a
--- /dev/null
+++ b/library/Director/Dashboard/ServicesDashboard.php
@@ -0,0 +1,39 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard;
+
+class ServicesDashboard extends Dashboard
+{
+ protected $dashletNames = array(
+ 'SingleServices',
+ 'ServiceApplyRules',
+ 'ServiceTemplates',
+ 'ServiceGroups',
+ 'ServiceChoices',
+ 'ServiceSets'
+ );
+
+ public function getTitle()
+ {
+ return $this->translate('Manage your Icinga Service Checks');
+ }
+
+ public function getDescription()
+ {
+ return $this->translate(
+ 'This is where you manage your Icinga 2 Service Checks. Service'
+ . ' Templates are your base building blocks, Service Sets allow'
+ . ' you to assign multiple Services at once. Apply Rules make it'
+ . ' possible to assign Services based on Host properties. And'
+ . ' the list of all single Service Objects gives you the possibility'
+ . ' to still modify (or delete) many of them at once.'
+ );
+ }
+
+ public function getTabs()
+ {
+ return $this->createTabsForDashboards(
+ ['hosts', 'services', 'commands']
+ );
+ }
+}
diff --git a/library/Director/Dashboard/TimeperiodsDashboard.php b/library/Director/Dashboard/TimeperiodsDashboard.php
new file mode 100644
index 0000000..9821b94
--- /dev/null
+++ b/library/Director/Dashboard/TimeperiodsDashboard.php
@@ -0,0 +1,33 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard;
+
+class TimeperiodsDashboard extends Dashboard
+{
+ protected $dashletNames = [
+ 'TimeperiodObject',
+ 'TimeperiodTemplate',
+ ];
+
+ public function getTitle()
+ {
+ return $this->translate('Define custom Time Periods');
+ }
+
+ public function getDescription()
+ {
+ return $this->translate(
+ 'Want to define to execute specific checks only withing specific'
+ . ' time periods? Get mobile notifications only out of office hours,'
+ . ' but mail notifications all around the clock? Time Periods allow'
+ . ' you to tackle those and similar requirements.'
+ );
+ }
+
+ public function getTabs()
+ {
+ return $this->createTabsForDashboards(
+ ['notifications', 'users', 'timeperiods']
+ );
+ }
+}
diff --git a/library/Director/Dashboard/UsersDashboard.php b/library/Director/Dashboard/UsersDashboard.php
new file mode 100644
index 0000000..036d149
--- /dev/null
+++ b/library/Director/Dashboard/UsersDashboard.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace Icinga\Module\Director\Dashboard;
+
+class UsersDashboard extends Dashboard
+{
+ protected $dashletNames = [
+ 'UserObject',
+ 'UserTemplate',
+ 'UserGroups',
+ ];
+
+ public function getTitle()
+ {
+ return $this->translate('Schedule your notifications');
+ }
+
+ public function getDescription()
+ {
+ return $this->translate(
+ 'This is where you manage your Icinga 2 User (Contact) objects. Try'
+ . ' to keep your User objects simply by movin complexity to your'
+ . ' templates. Bundle your users in groups and build Notifications'
+ . ' based on them. Running MS Active Directory or another central'
+ . ' User inventory? Stay away from fiddling with manual config, try'
+ . ' to automate all the things with Imports and related Sync Rules!'
+ );
+ }
+
+ public function getTabs()
+ {
+ return $this->createTabsForDashboards(
+ ['notifications', 'users', 'timeperiods']
+ );
+ }
+}
diff --git a/library/Director/Data/AssignFilterHelper.php b/library/Director/Data/AssignFilterHelper.php
new file mode 100644
index 0000000..b0253cf
--- /dev/null
+++ b/library/Director/Data/AssignFilterHelper.php
@@ -0,0 +1,160 @@
+<?php
+
+namespace Icinga\Module\Director\Data;
+
+use Icinga\Data\Filter\Filter;
+use Icinga\Data\Filter\FilterAnd;
+use Icinga\Data\Filter\FilterExpression;
+use Icinga\Data\Filter\FilterNot;
+use Icinga\Data\Filter\FilterOr;
+use Icinga\Exception\NotImplementedError;
+
+/**
+ * Class ApplyFilterMatches
+ *
+ * A wrapper for Icinga Filter to evaluate filters against Director's objects
+ */
+class AssignFilterHelper
+{
+ /** @var Filter */
+ protected $filter;
+
+ public function __construct(Filter $filter)
+ {
+ $this->filter = $filter;
+ }
+
+ /**
+ * @param object $object
+ *
+ * @return bool
+ * @throws NotImplementedError
+ */
+ public function matches($object)
+ {
+ return $this->matchesPart($this->filter, $object);
+ }
+
+ /**
+ * @param Filter $filter
+ * @param object $object
+ *
+ * @return bool
+ */
+ public static function matchesFilter(Filter $filter, $object)
+ {
+ $helper = new static($filter);
+ return $helper->matches($object);
+ }
+
+ /**
+ * @param Filter $filter
+ * @param object $object
+ *
+ * @return bool
+ * @throws NotImplementedError
+ */
+ protected function matchesPart(Filter $filter, $object)
+ {
+ if ($filter->isChain()) {
+ return $this->matchesChain($filter, $object);
+ } elseif ($filter->isExpression()) {
+ /** @var FilterExpression $filter */
+ return $this->matchesExpression($filter, $object);
+ } else {
+ return $filter->matches($object);
+ }
+ }
+
+ /**
+ * @param Filter $filter
+ * @param object $object
+ *
+ * @return bool
+ * @throws NotImplementedError
+ */
+ protected function matchesChain(Filter $filter, $object)
+ {
+ if ($filter instanceof FilterAnd) {
+ foreach ($filter->filters() as $f) {
+ if (! $this->matchesPart($f, $object)) {
+ return false;
+ }
+ }
+
+ return true;
+ } elseif ($filter instanceof FilterOr) {
+ foreach ($filter->filters() as $f) {
+ if ($this->matchesPart($f, $object)) {
+ return true;
+ }
+ }
+
+ return false;
+ } elseif ($filter instanceof FilterNot) {
+ foreach ($filter->filters() as $f) {
+ if ($this->matchesPart($f, $object)) {
+ return false;
+ }
+ }
+
+ return true;
+ } else {
+ $class = \get_class($filter);
+ $parts = \preg_split('/\\\/', $class);
+
+ throw new NotImplementedError(
+ 'Matching for Filter of type "%s" is not implemented',
+ \end($parts)
+ );
+ }
+ }
+
+ /**
+ * @param FilterExpression $filter
+ * @param object $object
+ *
+ * @return bool
+ */
+ protected function matchesExpression(FilterExpression $filter, $object)
+ {
+ $column = $filter->getColumn();
+ $sign = $filter->getSign();
+ $expression = $filter->getExpression();
+
+ if ($sign === '=') {
+ if ($expression === true) {
+ return property_exists($object, $column) && ! empty($object->{$column});
+ } elseif ($expression === false) {
+ return ! property_exists($object, $column) || empty($object->{$column});
+ } elseif (is_string($expression) && strpos($expression, '*') !== false) {
+ if (! property_exists($object, $column) || empty($object->{$column})) {
+ return false;
+ }
+ $value = $object->{$column};
+
+ $parts = array();
+ foreach (preg_split('~\*~', $expression) as $part) {
+ $parts[] = preg_quote($part);
+ }
+ // match() is case insensitive
+ $pattern = '/^' . implode('.*', $parts) . '$/i';
+
+ if (is_array($value)) {
+ foreach ($value as $candidate) {
+ if (preg_match($pattern, $candidate)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ return (bool) preg_match($pattern, $value);
+ }
+ }
+
+ // fallback to default behavior
+ return $filter->matches($object);
+ }
+}
diff --git a/library/Director/Data/DataArrayHelper.php b/library/Director/Data/DataArrayHelper.php
new file mode 100644
index 0000000..442eb0f
--- /dev/null
+++ b/library/Director/Data/DataArrayHelper.php
@@ -0,0 +1,48 @@
+<?php
+
+namespace Icinga\Module\Director\Data;
+
+use InvalidArgumentException;
+use function array_diff;
+use function array_key_exists;
+use function implode;
+use function is_array;
+use function is_object;
+
+class DataArrayHelper
+{
+ public static function wantArray($value)
+ {
+ if (is_object($value)) {
+ return (array) $value;
+ }
+ if (! is_array($value)) {
+ throw new InvalidDataException('Object', $value);
+ }
+
+ return $value;
+ }
+
+ public static function failOnUnknownProperties(array $values, array $knownProperties)
+ {
+ $unknownProperties = array_diff($knownProperties, array_keys($values));
+
+ if (! empty($unknownProperties)) {
+ throw new InvalidArgumentException('Unexpected properties: ' . implode(', ', $unknownProperties));
+ }
+ }
+
+ public static function requireProperties(array $value, array $properties)
+ {
+ $missing = [];
+ foreach ($properties as $property) {
+ if (! array_key_exists($property, $value)) {
+ $missing[] = $property;
+ }
+ }
+
+ if (! empty($missing)) {
+ throw new InvalidArgumentException('Missing properties: ' . implode(', ', $missing));
+ }
+ }
+}
diff --git a/library/Director/Data/Db/DbConnection.php b/library/Director/Data/Db/DbConnection.php
new file mode 100644
index 0000000..146b0e8
--- /dev/null
+++ b/library/Director/Data/Db/DbConnection.php
@@ -0,0 +1,51 @@
+<?php
+
+namespace Icinga\Module\Director\Data\Db;
+
+use Icinga\Data\Db\DbConnection as IcingaDbConnection;
+use Icinga\Module\Director\Db\DbUtil;
+use RuntimeException;
+use Zend_Db_Expr;
+
+class DbConnection extends IcingaDbConnection
+{
+ public function isMysql()
+ {
+ return $this->getDbType() === 'mysql';
+ }
+
+ public function isPgsql()
+ {
+ return $this->getDbType() === 'pgsql';
+ }
+
+ /**
+ * @deprecated
+ * @param ?string $binary
+ * @return Zend_Db_Expr|Zend_Db_Expr[]|null
+ */
+ public function quoteBinary($binary)
+ {
+ return DbUtil::quoteBinaryLegacy($binary, $this->getDbAdapter());
+ }
+
+ public function binaryDbResult($value)
+ {
+ if (is_resource($value)) {
+ return stream_get_contents($value);
+ }
+
+ return $value;
+ }
+
+ public function hasPgExtension($name)
+ {
+ $db = $this->db();
+ $query = $db->select()->from(
+ array('e' => 'pg_extension'),
+ array('cnt' => 'COUNT(*)')
+ )->where('extname = ?', $name);
+
+ return (int) $db->fetchOne($query) === 1;
+ }
+}
diff --git a/library/Director/Data/Db/DbDataFormatter.php b/library/Director/Data/Db/DbDataFormatter.php
new file mode 100644
index 0000000..d6e4eeb
--- /dev/null
+++ b/library/Director/Data/Db/DbDataFormatter.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace Icinga\Module\Director\Data\Db;
+
+use InvalidArgumentException;
+
+class DbDataFormatter
+{
+ public static function normalizeBoolean($value)
+ {
+ if ($value === 'y' || $value === '1' || $value === true || $value === 1) {
+ return 'y';
+ }
+ if ($value === 'n' || $value === '0' || $value === false || $value === 0) {
+ return 'n';
+ }
+ if ($value === '' || $value === null) {
+ return null;
+ }
+
+ throw new InvalidArgumentException(sprintf(
+ 'Got invalid boolean: %s',
+ var_export($value, 1)
+ ));
+ }
+}
diff --git a/library/Director/Data/Db/DbObject.php b/library/Director/Data/Db/DbObject.php
new file mode 100644
index 0000000..6ecae8b
--- /dev/null
+++ b/library/Director/Data/Db/DbObject.php
@@ -0,0 +1,1487 @@
+<?php
+
+namespace Icinga\Module\Director\Data\Db;
+
+use Icinga\Exception\NotFoundError;
+use Icinga\Module\Director\Data\InvalidDataException;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Db\Branch\UuidLookup;
+use Icinga\Module\Director\Exception\DuplicateKeyException;
+use InvalidArgumentException;
+use LogicException;
+use Ramsey\Uuid\Uuid;
+use Ramsey\Uuid\UuidInterface;
+use RuntimeException;
+use Zend_Db_Adapter_Abstract;
+use Zend_Db_Exception;
+
+/**
+ * Base class for ...
+ */
+abstract class DbObject
+{
+ /** @var DbConnection $connection */
+ protected $connection;
+
+ /** @var string Table name. MUST be set when extending this class */
+ protected $table;
+
+ /** @var Zend_Db_Adapter_Abstract */
+ protected $db;
+
+ /**
+ * Default columns. MUST be set when extending this class. Each table
+ * column MUST be defined with a default value. Default value may be null.
+ *
+ * @var array
+ */
+ protected $defaultProperties;
+
+ /**
+ * Properties as loaded from db
+ */
+ protected $loadedProperties;
+
+ /**
+ * Whether at least one property has been modified
+ */
+ protected $hasBeenModified = false;
+
+ /**
+ * Whether this object has been loaded from db
+ */
+ protected $loadedFromDb = false;
+
+ /**
+ * Object properties
+ */
+ protected $properties = array();
+
+ /**
+ * Property names that have been modified since object creation
+ */
+ protected $modifiedProperties = array();
+
+ /**
+ * Unique key name, could be primary
+ */
+ protected $keyName;
+
+ /**
+ * Set this to an eventual autoincrementing column. May equal $keyName
+ */
+ protected $autoincKeyName;
+
+ /** @var string optional uuid column */
+ protected $uuidColumn;
+
+ /** @var bool forbid updates to autoinc values */
+ protected $protectAutoinc = true;
+
+ protected $binaryProperties = [];
+
+ /**
+ * Filled with object instances when prefetchAll is used
+ */
+ protected static $prefetched = array();
+
+ /**
+ * object_name => id map for prefetched objects
+ */
+ protected static $prefetchedNames = array();
+
+ protected static $prefetchStats = array();
+
+ /** @var ?DbObjectStore */
+ protected static $dbObjectStore;
+
+ /**
+ * Constructor is not accessible and should not be overridden
+ */
+ protected function __construct()
+ {
+ if ($this->table === null
+ || $this->keyName === null
+ || $this->defaultProperties === null
+ ) {
+ throw new LogicException("Someone extending this class didn't RTFM");
+ }
+
+ $this->properties = $this->defaultProperties;
+ $this->beforeInit();
+ }
+
+ public function getTableName()
+ {
+ return $this->table;
+ }
+
+ /************************************************************************\
+ * When extending this class one might want to override any of the *
+ * following hooks. Try to use them whenever possible, especially *
+ * instead of overriding other essential methods like store(). *
+ \************************************************************************/
+
+ /**
+ * One can override this to allow for cross checks and more before storing
+ * the object. Please note that the method is public and allows to check
+ * object consistence at any time.
+ *
+ * @return boolean Whether this object is valid
+ */
+ public function validate()
+ {
+ return true;
+ }
+
+ /**
+ * This is going to be executed before any initialization method takes *
+ * (load from DB, populate from Array...) takes place
+ *
+ * @return void
+ */
+ protected function beforeInit()
+ {
+ }
+
+ /**
+ * Will be executed every time an object has successfully been loaded from
+ * Database
+ *
+ * @return void
+ */
+ protected function onLoadFromDb()
+ {
+ }
+
+ /**
+ * Will be executed before an Object is going to be stored. In case you
+ * want to prevent the store() operation from taking place, please throw
+ * an Exception.
+ *
+ * @return void
+ */
+ protected function beforeStore()
+ {
+ }
+
+ /**
+ * Wird ausgeführt, nachdem ein Objekt erfolgreich gespeichert worden ist
+ *
+ * @return void
+ */
+ protected function onStore()
+ {
+ }
+
+ /**
+ * Wird ausgeführt, nachdem ein Objekt erfolgreich der Datenbank hinzu-
+ * gefügt worden ist
+ *
+ * @return void
+ */
+ protected function onInsert()
+ {
+ }
+
+ /**
+ * Wird ausgeführt, nachdem bestehendes Objekt erfolgreich der Datenbank
+ * geändert worden ist
+ *
+ * @return void
+ */
+ protected function onUpdate()
+ {
+ }
+
+ /**
+ * Wird ausgeführt, bevor ein Objekt gelöscht wird. Die Operation wird
+ * aber auf jeden Fall durchgeführt, außer man wirft eine Exception
+ *
+ * @return void
+ */
+ protected function beforeDelete()
+ {
+ }
+
+ /**
+ * Wird ausgeführt, nachdem bestehendes Objekt erfolgreich aud der
+ * Datenbank gelöscht worden ist
+ *
+ * @return void
+ */
+ protected function onDelete()
+ {
+ }
+
+ /**
+ * Set database connection
+ *
+ * @param DbConnection $connection Database connection
+ *
+ * @return self
+ */
+ public function setConnection(DbConnection $connection)
+ {
+ $this->connection = $connection;
+ $this->db = $connection->getDbAdapter();
+
+ return $this;
+ }
+
+ public static function setDbObjectStore(DbObjectStore $store)
+ {
+ self::$dbObjectStore = $store;
+ }
+
+ /**
+ * Getter
+ *
+ * @param string $property Property
+ *
+ * @return mixed
+ */
+ public function get($property)
+ {
+ $func = 'get' . ucfirst($property);
+ if (substr($func, -2) === '[]') {
+ $func = substr($func, 0, -2);
+ }
+ // TODO: id check avoids collision with getId. Rethink this.
+ if ($property !== 'id' && method_exists($this, $func)) {
+ return $this->$func();
+ }
+
+ $this->assertPropertyExists($property);
+ return $this->properties[$property];
+ }
+
+ public function getProperty($key)
+ {
+ $this->assertPropertyExists($key);
+ return $this->properties[$key];
+ }
+
+ protected function assertPropertyExists($key)
+ {
+ if (! array_key_exists($key, $this->properties)) {
+ throw new InvalidArgumentException(sprintf(
+ 'Trying to get invalid property "%s"',
+ $key
+ ));
+ }
+
+ return $this;
+ }
+
+ public function hasProperty($key)
+ {
+ if (array_key_exists($key, $this->properties)) {
+ return true;
+ } elseif ($key === 'id') {
+ // There is getId, would give false positive
+ return false;
+ }
+
+ return $this->hasGetterForProperty($key);
+ }
+
+ protected function hasGetterForProperty($key)
+ {
+ $func = 'get' . ucfirst($key);
+ if (\substr($func, -2) === '[]') {
+ $func = substr($func, 0, -2);
+ }
+
+ return \method_exists($this, $func);
+ }
+
+ protected function hasSetterForProperty($key)
+ {
+ $func = 'set' . ucfirst($key);
+ if (\substr($func, -2) === '[]') {
+ $func = substr($func, 0, -2);
+ }
+
+ return \method_exists($this, $func);
+ }
+
+ /**
+ * Generic setter
+ *
+ * @param string $key
+ * @param mixed $value
+ *
+ * @return self
+ */
+ public function set($key, $value)
+ {
+ $key = (string) $key;
+ if ($value === '') {
+ $value = null;
+ }
+
+ if (is_resource($value)) {
+ $value = stream_get_contents($value);
+ }
+ $func = 'validate' . ucfirst($key);
+ if (method_exists($this, $func) && $this->$func($value) !== true) {
+ throw new InvalidArgumentException(sprintf(
+ 'Got invalid value "%s" for "%s"',
+ $value,
+ $key
+ ));
+ }
+ $func = 'munge' . ucfirst($key);
+ if (method_exists($this, $func)) {
+ $value = $this->$func($value);
+ }
+
+ $func = 'set' . ucfirst($key);
+ if (substr($func, -2) === '[]') {
+ $func = substr($func, 0, -2);
+ }
+
+ if (method_exists($this, $func)) {
+ return $this->$func($value);
+ }
+
+ if (! $this->hasProperty($key)) {
+ throw new InvalidArgumentException(sprintf(
+ 'Trying to set invalid key "%s"',
+ $key
+ ));
+ }
+
+ if ((is_numeric($value) || is_string($value))
+ && (string) $value === (string) $this->get($key)
+ ) {
+ return $this;
+ }
+
+ if ($key === $this->getAutoincKeyName() && $this->hasBeenLoadedFromDb()) {
+ throw new InvalidArgumentException('Changing autoincremental key is not allowed');
+ }
+
+ return $this->reallySet($key, $value);
+ }
+
+ protected function reallySet($key, $value)
+ {
+ if ($value === $this->properties[$key]) {
+ return $this;
+ }
+ if ($key === 'id' || substr($key, -3) === '_id') {
+ if ((int) $value === (int) $this->properties[$key]) {
+ return $this;
+ }
+ }
+
+ if ($this->hasBeenLoadedFromDb()) {
+ if ($value === $this->loadedProperties[$key]) {
+ unset($this->modifiedProperties[$key]);
+ if (empty($this->modifiedProperties)) {
+ $this->hasBeenModified = false;
+ }
+ } else {
+ $this->hasBeenModified = true;
+ $this->modifiedProperties[$key] = true;
+ }
+ } else {
+ $this->hasBeenModified = true;
+ $this->modifiedProperties[$key] = true;
+ }
+
+ $this->properties[$key] = $value;
+ return $this;
+ }
+
+ /**
+ * Magic getter
+ *
+ * @param mixed $key
+ *
+ * @return mixed
+ */
+ public function __get($key)
+ {
+ return $this->get($key);
+ }
+
+ /**
+ * Magic setter
+ *
+ * @param string $key Key
+ * @param mixed $val Value
+ *
+ * @return void
+ */
+ public function __set($key, $val)
+ {
+ $this->set($key, $val);
+ }
+
+ /**
+ * Magic isset check
+ *
+ * @param string $key
+ * @return boolean
+ */
+ public function __isset($key)
+ {
+ return array_key_exists($key, $this->properties);
+ }
+
+ /**
+ * Magic unsetter
+ *
+ * @param string $key
+ * @return void
+ */
+ public function __unset($key)
+ {
+ if (! array_key_exists($key, $this->properties)) {
+ throw new InvalidArgumentException('Trying to unset invalid key');
+ }
+ $this->properties[$key] = $this->defaultProperties[$key];
+ }
+
+ /**
+ * Runs set() for every key/value pair of the given Array
+ *
+ * @param array $props Array of properties
+ * @return self
+ */
+ public function setProperties($props)
+ {
+ if (! is_array($props)) {
+ throw new InvalidArgumentException(sprintf(
+ 'Array required, got %s',
+ gettype($props)
+ ));
+ }
+ foreach ($props as $key => $value) {
+ $this->set($key, $value);
+ }
+ return $this;
+ }
+
+ /**
+ * Return an array with all object properties
+ *
+ * @return array
+ */
+ public function getProperties()
+ {
+ //return $this->properties;
+ $res = array();
+ foreach ($this->listProperties() as $key) {
+ $res[$key] = $this->get($key);
+ }
+
+ return $res;
+ }
+
+ protected function getPropertiesForDb()
+ {
+ return $this->properties;
+ }
+
+ public function listProperties()
+ {
+ return array_keys($this->properties);
+ }
+
+ public function getDefaultProperties()
+ {
+ return $this->defaultProperties;
+ }
+
+ /**
+ * Return all properties that changed since object creation
+ *
+ * @return array
+ */
+ public function getModifiedProperties()
+ {
+ $props = array();
+ foreach (array_keys($this->modifiedProperties) as $key) {
+ if ($key === $this->autoincKeyName) {
+ if ($this->protectAutoinc) {
+ continue;
+ } elseif ($this->properties[$key] === null) {
+ continue;
+ }
+ }
+
+ $props[$key] = $this->properties[$key];
+ }
+ return $props;
+ }
+
+ /**
+ * List all properties that changed since object creation
+ *
+ * @return array
+ */
+ public function listModifiedProperties()
+ {
+ return array_keys($this->modifiedProperties);
+ }
+
+ /**
+ * Whether this object has been modified
+ *
+ * @return bool
+ */
+ public function hasBeenModified()
+ {
+ return $this->hasBeenModified;
+ }
+
+ /**
+ * Whether the given property has been modified
+ *
+ * @param string $key Property name
+ * @return boolean
+ */
+ protected function hasModifiedProperty($key)
+ {
+ return array_key_exists($key, $this->modifiedProperties);
+ }
+
+ /**
+ * Unique key name
+ *
+ * @return string
+ */
+ public function getKeyName()
+ {
+ return $this->keyName;
+ }
+
+ /**
+ * Autoinc key name
+ *
+ * @return string
+ */
+ public function getAutoincKeyName()
+ {
+ return $this->autoincKeyName;
+ }
+
+ /**
+ * @return ?string
+ */
+ public function getUuidColumn()
+ {
+ return $this->uuidColumn;
+ }
+
+ /**
+ * @return bool
+ */
+ public function hasUuidColumn()
+ {
+ return $this->uuidColumn !== null;
+ }
+
+ /**
+ * @return \Ramsey\Uuid\UuidInterface
+ */
+ public function getUniqueId()
+ {
+ if ($this->hasUuidColumn()) {
+ $binaryValue = $this->properties[$this->uuidColumn];
+ if (is_resource($binaryValue)) {
+ throw new RuntimeException('Properties contain binary UUID, probably a programming error');
+ }
+ if ($binaryValue === null) {
+ $uuid = Uuid::uuid4();
+ $this->reallySet($this->uuidColumn, $uuid->getBytes());
+ return $uuid;
+ }
+
+ return Uuid::fromBytes($binaryValue);
+ }
+
+ throw new InvalidArgumentException(sprintf('%s has no UUID column', $this->getTableName()));
+ }
+
+ public function getKeyParams()
+ {
+ $params = array();
+ $key = $this->getKeyName();
+ if (is_array($key)) {
+ foreach ($key as $k) {
+ $params[$k] = $this->get($k);
+ }
+ } else {
+ $params[$key] = $this->get($this->keyName);
+ }
+
+ return $params;
+ }
+
+ /**
+ * Return the unique identifier
+ *
+ * // TODO: may conflict with ->id
+ *
+ * @throws InvalidArgumentException When key can not be calculated
+ *
+ * @return string|array
+ */
+ public function getId()
+ {
+ if (is_array($this->keyName)) {
+ $id = array();
+ foreach ($this->keyName as $key) {
+ if (isset($this->properties[$key])) {
+ $id[$key] = $this->properties[$key];
+ }
+ }
+
+ if (empty($id)) {
+ throw new InvalidArgumentException('Could not evaluate id for multi-column object!');
+ }
+
+ return $id;
+ } else {
+ if (isset($this->properties[$this->keyName])) {
+ return $this->properties[$this->keyName];
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Get the autoinc value if set
+ *
+ * @return int
+ */
+ public function getAutoincId()
+ {
+ if (isset($this->properties[$this->autoincKeyName])) {
+ return (int) $this->properties[$this->autoincKeyName];
+ }
+ return null;
+ }
+
+ protected function forgetAutoincId()
+ {
+ if (isset($this->properties[$this->autoincKeyName])) {
+ $this->properties[$this->autoincKeyName] = null;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Liefert das benutzte Datenbank-Handle
+ *
+ * @return Zend_Db_Adapter_Abstract
+ */
+ public function getDb()
+ {
+ return $this->db;
+ }
+
+ public function hasConnection()
+ {
+ return $this->connection !== null;
+ }
+
+ public function getConnection()
+ {
+ return $this->connection;
+ }
+
+ /**
+ * Lädt einen Datensatz aus der Datenbank und setzt die entsprechenden
+ * Eigenschaften dieses Objekts
+ *
+ * @throws NotFoundError
+ * @return self
+ */
+ protected function loadFromDb()
+ {
+ $select = $this->db->select()->from($this->table)->where($this->createWhere());
+ $properties = $this->db->fetchRow($select);
+
+ if (empty($properties)) {
+ if (is_array($this->getKeyName())) {
+ throw new NotFoundError(
+ 'Failed to load %s for %s',
+ $this->table,
+ $this->createWhere()
+ );
+ } else {
+ throw new NotFoundError(
+ 'Failed to load %s "%s"',
+ $this->table,
+ $this->getLogId()
+ );
+ }
+ }
+
+ return $this->setDbProperties($properties);
+ }
+
+ /**
+ * @param object|array $row
+ * @param Db $db
+ * @return self
+ */
+ public static function fromDbRow($row, Db $db)
+ {
+ $self = (new static())->setConnection($db);
+ if (is_object($row)) {
+ return $self->setDbProperties((array) $row);
+ }
+
+ if (is_array($row)) {
+ return $self->setDbProperties($row);
+ }
+
+ throw new InvalidDataException('array or object', $row);
+ }
+
+ protected function setDbProperties($properties)
+ {
+ foreach ($properties as $key => $val) {
+ if (! array_key_exists($key, $this->properties)) {
+ throw new LogicException(sprintf(
+ 'Trying to set invalid %s key "%s". DB schema change?',
+ $this->table,
+ $key
+ ));
+ }
+ if ($val === null) {
+ $this->properties[$key] = null;
+ } elseif (is_resource($val)) {
+ $this->properties[$key] = stream_get_contents($val);
+ } else {
+ $this->properties[$key] = (string) $val;
+ }
+ }
+
+ $this->setBeingLoadedFromDb();
+ $this->onLoadFromDb();
+ return $this;
+ }
+
+ public function setBeingLoadedFromDb()
+ {
+ $this->loadedFromDb = true;
+ $this->loadedProperties = $this->properties;
+ $this->hasBeenModified = false;
+ $this->modifiedProperties = [];
+ }
+
+ public function setLoadedProperty($key, $value)
+ {
+ if ($this->hasBeenLoadedFromDb()) {
+ $this->set($key, $value);
+ $this->loadedProperties[$key] = $this->get($key);
+ } else {
+ throw new RuntimeException('Cannot set loaded property for new object');
+ }
+ }
+
+ public function getOriginalProperties()
+ {
+ return $this->loadedProperties;
+ }
+
+ public function getOriginalProperty($key)
+ {
+ $this->assertPropertyExists($key);
+ if ($this->hasBeenLoadedFromDb()) {
+ return $this->loadedProperties[$key];
+ }
+
+ return null;
+ }
+
+ public function resetProperty($key)
+ {
+ $this->set($key, $this->getOriginalProperty($key));
+ if ($this->listModifiedProperties() === [$key]) {
+ $this->hasBeenModified = false;
+ }
+
+ return $this;
+ }
+
+ public function hasBeenLoadedFromDb()
+ {
+ return $this->loadedFromDb;
+ }
+
+ /**
+ * Ändert den entsprechenden Datensatz in der Datenbank
+ *
+ * @return int Anzahl der geänderten Zeilen
+ * @throws \Zend_Db_Adapter_Exception
+ */
+ protected function updateDb()
+ {
+ $properties = $this->getModifiedProperties();
+ if (empty($properties)) {
+ // Fake true, we might have manually set this to "modified"
+ return true;
+ }
+ $this->quoteBinaryProperties($properties);
+
+ // TODO: Remember changed data for audit and log
+ return $this->db->update(
+ $this->table,
+ $properties,
+ $this->createWhere()
+ );
+ }
+
+ /**
+ * Fügt der Datenbank-Tabelle einen entsprechenden Datensatz hinzu
+ *
+ * @return int Anzahl der betroffenen Zeilen
+ * @throws \Zend_Db_Adapter_Exception
+ */
+ protected function insertIntoDb()
+ {
+ $properties = $this->getPropertiesForDb();
+ if ($this->autoincKeyName !== null) {
+ if ($this->protectAutoinc || $properties[$this->autoincKeyName] === null) {
+ unset($properties[$this->autoincKeyName]);
+ }
+ }
+ if ($column = $this->getUuidColumn()) {
+ $properties[$column] = $this->getUniqueId()->getBytes();
+ }
+ $this->quoteBinaryProperties($properties);
+
+ return $this->db->insert($this->table, $properties);
+ }
+
+ protected function quoteBinaryProperties(&$properties)
+ {
+ foreach ($properties as $key => $value) {
+ if ($this->isBinaryColumn($key)) {
+ $properties[$key] = $this->getConnection()->quoteBinary($value);
+ }
+ }
+ }
+
+ protected function isBinaryColumn($column)
+ {
+ return in_array($column, $this->binaryProperties) || $this->getUuidColumn() === $column;
+ }
+
+ /**
+ * Store object to database
+ *
+ * @param DbConnection $db
+ * @return bool Whether storing succeeded. Always true, throws otherwise
+ * @throws DuplicateKeyException
+ */
+ public function store(DbConnection $db = null)
+ {
+ if ($db !== null) {
+ $this->setConnection($db);
+ }
+
+ if ($this->validate() !== true) {
+ throw new InvalidArgumentException(sprintf(
+ '%s[%s] validation failed',
+ $this->table,
+ $this->getLogId()
+ ));
+ }
+
+ if ($this->hasBeenLoadedFromDb() && ! $this->hasBeenModified()) {
+ return true;
+ }
+
+ $this->beforeStore();
+ $table = $this->table;
+ $id = $this->getId();
+
+ try {
+ if ($this->hasBeenLoadedFromDb()) {
+ if ($this->updateDb() !== false) {
+ $this->onUpdate();
+ } else {
+ throw new RuntimeException(sprintf(
+ 'FAILED storing %s "%s"',
+ $table,
+ $this->getLogId()
+ ));
+ }
+ } else {
+ if ($id && $this->existsInDb()) {
+ $logId = '"' . $this->getLogId() . '"';
+
+ if ($autoId = $this->getAutoincId()) {
+ $logId .= sprintf(', %s=%s', $this->autoincKeyName, $autoId);
+ }
+ throw new DuplicateKeyException(
+ 'Trying to recreate %s (%s)',
+ $table,
+ $logId
+ );
+ }
+
+ if ($this->insertIntoDb()) {
+ if ($this->autoincKeyName && $this->getProperty($this->autoincKeyName) === null) {
+ if ($this->connection->isPgsql()) {
+ $this->properties[$this->autoincKeyName] = $this->db->lastInsertId(
+ $table,
+ $this->autoincKeyName
+ );
+ } else {
+ $this->properties[$this->autoincKeyName] = $this->db->lastInsertId();
+ }
+ }
+ // $this->log(sprintf('New %s "%s" has been stored', $table, $id));
+ $this->onInsert();
+ } else {
+ throw new RuntimeException(sprintf(
+ 'FAILED to store new %s "%s"',
+ $table,
+ $this->getLogId()
+ ));
+ }
+ }
+ } catch (Zend_Db_Exception $e) {
+ throw new RuntimeException(sprintf(
+ 'Storing %s[%s] failed: %s {%s}',
+ $this->table,
+ $this->getLogId(),
+ $e->getMessage(),
+ var_export($this->getProperties(), 1) // TODO: Remove properties
+ ));
+ }
+
+ // Hint: order is differs from setBeingLoadedFromDb() as of the onStore hook
+ $this->modifiedProperties = [];
+ $this->hasBeenModified = false;
+ $this->loadedProperties = $this->properties;
+ $this->onStore();
+ $this->loadedFromDb = true;
+
+ return true;
+ }
+
+ /**
+ * Delete item from DB
+ *
+ * @return int Affected rows
+ */
+ protected function deleteFromDb()
+ {
+ return $this->db->delete(
+ $this->table,
+ $this->createWhere()
+ );
+ }
+
+ /**
+ * @param string $key
+ * @return self
+ * @throws InvalidArgumentException
+ */
+ protected function setKey($key)
+ {
+ $keyname = $this->getKeyName();
+ if (is_array($keyname)) {
+ if (! is_array($key)) {
+ throw new InvalidArgumentException(sprintf(
+ '%s has a multicolumn key, array required',
+ $this->table
+ ));
+ }
+ foreach ($keyname as $k) {
+ if (! array_key_exists($k, $key)) {
+ // We allow for null in multicolumn keys:
+ $key[$k] = null;
+ }
+ $this->set($k, $key[$k]);
+ }
+ } else {
+ $this->set($keyname, $key);
+ }
+ return $this;
+ }
+
+ protected function existsInDb()
+ {
+ $result = $this->db->fetchRow(
+ $this->db->select()->from($this->table)->where($this->createWhere())
+ );
+ return $result !== false;
+ }
+
+ public function createWhere()
+ {
+ if ($this->hasUuidColumn() && $this->properties[$this->uuidColumn] !== null) {
+ return $this->db->quoteInto(
+ sprintf('%s = ?', $this->getUuidColumn()),
+ $this->connection->quoteBinary($this->getUniqueId()->getBytes())
+ );
+ }
+ if ($id = $this->getAutoincId()) {
+ if ($originalId = $this->getOriginalProperty($this->autoincKeyName)) {
+ return $this->db->quoteInto(
+ sprintf('%s = ?', $this->autoincKeyName),
+ $originalId
+ );
+ }
+ return $this->db->quoteInto(
+ sprintf('%s = ?', $this->autoincKeyName),
+ $id
+ );
+ }
+
+ $key = $this->getKeyName();
+
+ if (is_array($key) && ! empty($key)) {
+ $where = array();
+ foreach ($key as $k) {
+ if ($this->hasBeenLoadedFromDb()) {
+ if ($this->loadedProperties[$k] === null) {
+ $where[] = sprintf('%s IS NULL', $k);
+ } else {
+ $where[] = $this->createQuotedWhere($k, $this->loadedProperties[$k]);
+ }
+ } else {
+ if ($this->properties[$k] === null) {
+ $where[] = sprintf('%s IS NULL', $k);
+ } else {
+ $where[] = $this->createQuotedWhere($k, $this->properties[$k]);
+ }
+ }
+ }
+
+ return implode(' AND ', $where);
+ } else {
+ if ($this->hasBeenLoadedFromDb()) {
+ return $this->createQuotedWhere($key, $this->loadedProperties[$key]);
+ } else {
+ return $this->createQuotedWhere($key, $this->properties[$key]);
+ }
+ }
+ }
+
+ protected function createQuotedWhere($column, $value)
+ {
+ return $this->db->quoteInto(
+ sprintf('%s = ?', $column),
+ $this->eventuallyQuoteBinary($value, $column)
+ );
+ }
+
+ protected function eventuallyQuoteBinary($value, $column)
+ {
+ if ($this->isBinaryColumn($column)) {
+ return $this->connection->quoteBinary($value);
+ } else {
+ return $value;
+ }
+ }
+
+ protected function getLogId()
+ {
+ $id = $this->getId();
+ if (is_array($id)) {
+ $logId = json_encode($id);
+ } else {
+ $logId = $id;
+ }
+
+ if ($logId === null && $this->autoincKeyName) {
+ $logId = $this->getAutoincId();
+ }
+
+ return $logId;
+ }
+
+ public function delete()
+ {
+ $table = $this->table;
+
+ if (! $this->hasBeenLoadedFromDb()) {
+ throw new LogicException(sprintf(
+ 'Cannot delete %s "%s", it has not been loaded from Db',
+ $table,
+ $this->getLogId()
+ ));
+ }
+
+ if (! $this->existsInDb()) {
+ throw new InvalidArgumentException(sprintf(
+ 'Cannot delete %s "%s", it does not exist',
+ $table,
+ $this->getLogId()
+ ));
+ }
+ $this->beforeDelete();
+ if (! $this->deleteFromDb()) {
+ throw new RuntimeException(sprintf(
+ 'Deleting %s (%s) FAILED',
+ $table,
+ $this->getLogId()
+ ));
+ }
+ // $this->log(sprintf('%s "%s" has been DELETED', $table, this->getLogId()));
+ $this->onDelete();
+ $this->loadedFromDb = false;
+ return true;
+ }
+
+ public function __clone()
+ {
+ $this->onClone();
+ $this->forgetAutoincId();
+ $this->loadedFromDb = false;
+ $this->hasBeenModified = true;
+ }
+
+ protected function onClone()
+ {
+ }
+
+ /**
+ * @param array $properties
+ * @param DbConnection|null $connection
+ *
+ * @return static
+ */
+ public static function create($properties = array(), DbConnection $connection = null)
+ {
+ $obj = new static();
+ if ($connection !== null) {
+ $obj->setConnection($connection);
+ }
+ $obj->setProperties($properties);
+ return $obj;
+ }
+
+ protected static function classWasPrefetched()
+ {
+ $class = get_called_class();
+ return array_key_exists($class, self::$prefetched);
+ }
+
+ /**
+ * @param $key
+ * @return static|bool
+ */
+ protected static function getPrefetched($key)
+ {
+ $class = get_called_class();
+ if (static::hasPrefetched($key)) {
+ if (is_string($key)
+ && array_key_exists($class, self::$prefetchedNames)
+ && array_key_exists($key, self::$prefetchedNames[$class])
+ ) {
+ return self::$prefetched[$class][
+ self::$prefetchedNames[$class][$key]
+ ];
+ } else {
+ return self::$prefetched[$class][$key];
+ }
+ } else {
+ return false;
+ }
+ }
+
+ protected static function hasPrefetched($key)
+ {
+ $class = get_called_class();
+ if (! array_key_exists($class, self::$prefetchStats)) {
+ self::$prefetchStats[$class] = (object) array(
+ 'miss' => 0,
+ 'hits' => 0,
+ 'hitNames' => 0,
+ 'combinedMiss' => 0
+ );
+ }
+
+ if (is_array($key)) {
+ self::$prefetchStats[$class]->combinedMiss++;
+ return false;
+ }
+
+ if (array_key_exists($class, self::$prefetched)) {
+ if (is_string($key)
+ && array_key_exists($class, self::$prefetchedNames)
+ && array_key_exists($key, self::$prefetchedNames[$class])
+ ) {
+ self::$prefetchStats[$class]->hitNames++;
+ return true;
+ } elseif (array_key_exists($key, self::$prefetched[$class])) {
+ self::$prefetchStats[$class]->hits++;
+ return true;
+ } else {
+ self::$prefetchStats[$class]->miss++;
+ return false;
+ }
+ } else {
+ self::$prefetchStats[$class]->miss++;
+ return false;
+ }
+ }
+
+ public static function getPrefetchStats()
+ {
+ return self::$prefetchStats;
+ }
+
+ /**
+ * @param $id
+ * @param DbConnection $connection
+ * @return static
+ * @throws NotFoundError
+ */
+ public static function loadWithAutoIncId($id, DbConnection $connection)
+ {
+ /* Need to cast to int, otherwise the id will be matched against
+ * object_name, which may wreak havoc if an object has a
+ * object_name matching some id. Note that DbObject::set() and
+ * DbObject::setDbProperties() will convert any property to
+ * string, including ids.
+ */
+ $id = (int) $id;
+
+ if ($prefetched = static::getPrefetched($id)) {
+ return $prefetched;
+ }
+
+ $obj = new static;
+ if (self::$dbObjectStore !== null && $obj->hasUuidColumn()) {
+ $table = $obj->getTableName();
+ assert($connection instanceof Db);
+ $uuid = UuidLookup::requireUuidForKey($id, $table, $connection, self::$dbObjectStore->getBranch());
+
+ return self::$dbObjectStore->load($table, $uuid);
+ }
+
+ $obj->setConnection($connection)
+ ->set($obj->autoincKeyName, $id)
+ ->loadFromDb();
+
+ return $obj;
+ }
+
+ /**
+ * @param $id
+ * @param DbConnection $connection
+ * @return static
+ * @throws NotFoundError
+ */
+ public static function load($id, DbConnection $connection)
+ {
+ if ($prefetched = static::getPrefetched($id)) {
+ return $prefetched;
+ }
+ /** @var DbObject $obj */
+ $obj = new static;
+
+ if (self::$dbObjectStore !== null && $obj->hasUuidColumn()) {
+ $table = $obj->getTableName();
+ assert($connection instanceof Db);
+ $uuid = UuidLookup::requireUuidForKey($id, $table, $connection, self::$dbObjectStore->getBranch());
+
+ return self::$dbObjectStore->load($table, $uuid);
+ }
+
+ $obj->setConnection($connection)->setKey($id)->loadFromDb();
+
+ return $obj;
+ }
+
+ /**
+ * @param DbConnection $connection
+ * @param \Zend_Db_Select $query
+ * @param string|null $keyColumn
+ *
+ * @return static[]
+ */
+ public static function loadAll(DbConnection $connection, $query = null, $keyColumn = null)
+ {
+ $objects = array();
+ $db = $connection->getDbAdapter();
+
+ if ($query === null) {
+ $dummy = new static;
+ $select = $db->select()->from($dummy->table);
+ } else {
+ $select = $query;
+ }
+ $rows = $db->fetchAll($select);
+
+ foreach ($rows as $row) {
+ /** @var DbObject $obj */
+ $obj = new static;
+ $obj->setConnection($connection)->setDbProperties($row);
+ if ($keyColumn === null) {
+ $objects[] = $obj;
+ } else {
+ $objects[$row->$keyColumn] = $obj;
+ }
+ }
+
+ return $objects;
+ }
+
+ /**
+ * @param DbConnection $connection
+ * @param bool $force
+ *
+ * @return static[]
+ */
+ public static function prefetchAll(DbConnection $connection, $force = false)
+ {
+ $dummy = static::create();
+ $class = get_class($dummy);
+ $autoInc = $dummy->getAutoincKeyName();
+ $keyName = $dummy->getKeyName();
+
+ if ($force || ! array_key_exists($class, self::$prefetched)) {
+ self::$prefetched[$class] = static::loadAll($connection, null, $autoInc);
+ if (! is_array($keyName) && $keyName !== $autoInc) {
+ foreach (self::$prefetched[$class] as $k => $v) {
+ self::$prefetchedNames[$class][$v->$keyName] = $k;
+ }
+ }
+ }
+
+ return self::$prefetched[$class];
+ }
+
+ public static function clearPrefetchCache()
+ {
+ $class = get_called_class();
+ if (! array_key_exists($class, self::$prefetched)) {
+ return;
+ }
+
+ unset(self::$prefetched[$class]);
+ unset(self::$prefetchedNames[$class]);
+ unset(self::$prefetchStats[$class]);
+ }
+
+ public static function clearAllPrefetchCaches()
+ {
+ self::$prefetched = array();
+ self::$prefetchedNames = array();
+ self::$prefetchStats = array();
+ }
+
+ /**
+ * @param $id
+ * @param DbConnection $connection
+ * @return bool
+ */
+ public static function exists($id, DbConnection $connection)
+ {
+ if (static::getPrefetched($id)) {
+ return true;
+ } elseif (static::classWasPrefetched()) {
+ return false;
+ }
+
+ /** @var DbObject $obj */
+ $obj = new static;
+ if (self::$dbObjectStore !== null && $obj->hasUuidColumn()) {
+ $table = $obj->getTableName();
+ assert($connection instanceof Db);
+ $uuid = UuidLookup::findUuidForKey($id, $table, $connection, self::$dbObjectStore->getBranch());
+ if ($uuid) {
+ return self::$dbObjectStore->exists($table, $uuid);
+ }
+
+ return false;
+ }
+
+ $obj->setConnection($connection)->setKey($id);
+ return $obj->existsInDb();
+ }
+
+ public static function uniqueIdExists(UuidInterface $uuid, DbConnection $connection)
+ {
+ $db = $connection->getDbAdapter();
+ $obj = new static;
+ $column = $obj->getUuidColumn();
+ $query = $db->select()
+ ->from($obj->getTableName(), $column)
+ ->where("$column = ?", $connection->quoteBinary($uuid->getBytes()));
+
+ $result = $db->fetchRow($query);
+
+ return $result !== false;
+ }
+
+ public static function requireWithUniqueId(UuidInterface $uuid, DbConnection $connection)
+ {
+ if ($object = static::loadWithUniqueId($uuid, $connection)) {
+ return $object;
+ }
+
+ throw new NotFoundError(sprintf(
+ 'No %s with UUID=%s has been found',
+ (new static)->getTableName(),
+ $uuid->toString()
+ ));
+ }
+
+ public static function loadWithUniqueId(UuidInterface $uuid, DbConnection $connection)
+ {
+ $db = $connection->getDbAdapter();
+ $obj = new static;
+
+ if (self::$dbObjectStore !== null && $obj->hasUuidColumn()) {
+ $table = $obj->getTableName();
+ assert($connection instanceof Db);
+ return self::$dbObjectStore->load($table, $uuid);
+ }
+
+ $query = $db->select()
+ ->from($obj->getTableName())
+ ->where($obj->getUuidColumn() . ' = ?', $connection->quoteBinary($uuid->getBytes()));
+
+ $result = $db->fetchRow($query);
+
+ if ($result) {
+ return $obj->setConnection($connection)->setDbProperties($result);
+ }
+
+ return null;
+ }
+
+ public function setUniqueId(UuidInterface $uuid)
+ {
+ if ($column = $this->getUuidColumn()) {
+ $binary = $uuid->getBytes();
+ $current = $this->get($column);
+ if ($current === null) {
+ $this->set($column, $binary);
+ } else {
+ if ($current !== $binary) {
+ throw new RuntimeException(sprintf(
+ 'Changing the UUID (from %s to %s) is not allowed',
+ Uuid::fromBytes($current)->toString(),
+ Uuid::fromBytes($binary)->toString()
+ ));
+ }
+ }
+ }
+ }
+
+ public function __destruct()
+ {
+ unset($this->db);
+ unset($this->connection);
+ }
+}
diff --git a/library/Director/Data/Db/DbObjectStore.php b/library/Director/Data/Db/DbObjectStore.php
new file mode 100644
index 0000000..bc69b5a
--- /dev/null
+++ b/library/Director/Data/Db/DbObjectStore.php
@@ -0,0 +1,169 @@
+<?php
+
+namespace Icinga\Module\Director\Data\Db;
+
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Db\Branch\Branch;
+use Icinga\Module\Director\Db\Branch\BranchActivity;
+use Icinga\Module\Director\Db\Branch\BranchedObject;
+use Icinga\Module\Director\Db\Branch\MergeErrorDeleteMissingObject;
+use Icinga\Module\Director\Db\Branch\MergeErrorModificationForMissingObject;
+use Icinga\Module\Director\Db\Branch\MergeErrorRecreateOnMerge;
+use Icinga\Module\Director\Db\DbUtil;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Ramsey\Uuid\UuidInterface;
+
+/**
+ * Loader for Icinga/DbObjects
+ *
+ * Is aware of branches and prefetching. I would prefer to see a StoreInterface,
+ * with one of the above wrapping the other. But for now, this helps to clean things
+ * up
+ */
+class DbObjectStore
+{
+ /** @var Db */
+ protected $connection;
+
+ /** @var ?Branch */
+ protected $branch;
+
+ public function __construct(Db $connection, Branch $branch = null)
+ {
+ $this->connection = $connection;
+ $this->branch = $branch;
+ }
+
+ /**
+ * @param $tableName
+ * @param UuidInterface $uuid
+ * @return DbObject|null
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function load($tableName, UuidInterface $uuid)
+ {
+ $branchedObject = BranchedObject::load($this->connection, $tableName, $uuid, $this->branch);
+ $object = $branchedObject->getBranchedDbObject($this->connection);
+ if ($object === null) {
+ return null;
+ }
+
+ $object->setBeingLoadedFromDb();
+
+ return $object;
+ }
+
+ /**
+ * @param string $tableName
+ * @param string $arrayIdx
+ * @return DbObject[]|IcingaObject[]
+ * @throws MergeErrorRecreateOnMerge
+ * @throws MergeErrorDeleteMissingObject
+ * @throws MergeErrorModificationForMissingObject
+ */
+ public function loadAll($tableName, $arrayIdx = 'uuid')
+ {
+ $db = $this->connection->getDbAdapter();
+ $class = DbObjectTypeRegistry::classByType($tableName);
+ $query = $db->select()->from($tableName)->order($arrayIdx);
+ $result = [];
+ foreach ($db->fetchAll($query) as $row) {
+ $row->uuid = DbUtil::binaryResult($row->uuid);
+ $result[$row->uuid] = $class::create((array) $row, $this->connection);
+ $result[$row->uuid]->setBeingLoadedFromDb();
+ }
+ if ($this->branch && $this->branch->isBranch()) {
+ $query = $db->select()
+ ->from(BranchActivity::DB_TABLE)
+ ->where('branch_uuid = ?', $this->connection->quoteBinary($this->branch->getUuid()->getBytes()))
+ ->order('timestamp_ns ASC');
+ $rows = $db->fetchAll($query);
+ foreach ($rows as $row) {
+ $activity = BranchActivity::fromDbRow($row);
+ if ($activity->getObjectTable() !== $tableName) {
+ continue;
+ }
+ $uuid = $activity->getObjectUuid();
+ $binaryUuid = $uuid->getBytes();
+
+ $exists = isset($result[$binaryUuid]);
+ if ($activity->isActionCreate()) {
+ if ($exists) {
+ throw new MergeErrorRecreateOnMerge($activity);
+ } else {
+ $new = $activity->createDbObject($this->connection);
+ $new->setBeingLoadedFromDb();
+ $result[$binaryUuid] = $new;
+ }
+ } elseif ($activity->isActionDelete()) {
+ if ($exists) {
+ unset($result[$binaryUuid]);
+ } else {
+ throw new MergeErrorDeleteMissingObject($activity);
+ }
+ } else {
+ if ($exists) {
+ $activity->applyToDbObject($result[$binaryUuid])->setBeingLoadedFromDb();
+ } else {
+ throw new MergeErrorModificationForMissingObject($activity);
+ }
+ }
+ }
+ }
+
+ if ($arrayIdx === 'uuid') {
+ return $result;
+ }
+
+ $indexedResult = [];
+ foreach ($result as $object) {
+ $indexedResult[$object->get($arrayIdx)] = $object;
+ }
+
+ return $indexedResult;
+ }
+
+ public function exists($tableName, UuidInterface $uuid)
+ {
+ return BranchedObject::exists($this->connection, $tableName, $uuid, $this->branch->getUuid());
+ }
+
+ public function store(DbObject $object)
+ {
+ if ($this->branch && $this->branch->isBranch()) {
+ $activity = BranchActivity::forDbObject($object, $this->branch);
+ $this->connection->runFailSafeTransaction(function () use ($activity) {
+ $activity->store($this->connection);
+ BranchedObject::withActivity($activity, $this->connection)->store($this->connection);
+ });
+
+ return true;
+ } else {
+ return $object->store($this->connection);
+ }
+ }
+
+ public function delete(DbObject $object)
+ {
+ if ($this->branch && $this->branch->isBranch()) {
+ $activity = BranchActivity::deleteObject($object, $this->branch);
+ $this->connection->runFailSafeTransaction(function () use ($activity) {
+ $activity->store($this->connection);
+ BranchedObject::load(
+ $this->connection,
+ $activity->getObjectTable(),
+ $activity->getObjectUuid(),
+ $this->branch
+ )->delete($this->connection);
+ });
+ return true;
+ }
+
+ return $object->delete();
+ }
+
+ public function getBranch()
+ {
+ return $this->branch;
+ }
+}
diff --git a/library/Director/Data/Db/DbObjectTypeRegistry.php b/library/Director/Data/Db/DbObjectTypeRegistry.php
new file mode 100644
index 0000000..0c226d6
--- /dev/null
+++ b/library/Director/Data/Db/DbObjectTypeRegistry.php
@@ -0,0 +1,75 @@
+<?php
+
+namespace Icinga\Module\Director\Data\Db;
+
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Objects\IcingaObject;
+
+class DbObjectTypeRegistry
+{
+ /**
+ * @param $type
+ * @return string|DbObject Fake typehint for IDE
+ */
+ public static function classByType($type)
+ {
+ // allow for icinga_host and host
+ $type = lcfirst(preg_replace('/^icinga_/', '', $type));
+
+ // Hint: Sync/Import are not IcingaObjects, this should be reconsidered:
+ if (strpos($type, 'import') === 0 || strpos($type, 'sync') === 0) {
+ $prefix = '';
+ } elseif (strpos($type, 'data') === false) {
+ $prefix = 'Icinga';
+ } else {
+ $prefix = 'Director';
+ }
+
+ // TODO: Provide a more sophisticated solution
+ if ($type === 'hostgroup') {
+ $type = 'hostGroup';
+ } elseif ($type === 'usergroup') {
+ $type = 'userGroup';
+ } elseif ($type === 'timeperiod') {
+ $type = 'timePeriod';
+ } elseif ($type === 'servicegroup') {
+ $type = 'serviceGroup';
+ } elseif ($type === 'service_set' || $type === 'serviceset') {
+ $type = 'serviceSet';
+ } elseif ($type === 'apiuser') {
+ $type = 'apiUser';
+ } elseif ($type === 'host_template_choice') {
+ $type = 'templateChoiceHost';
+ } elseif ($type === 'service_template_choice') {
+ $type = 'TemplateChoiceService';
+ } elseif ($type === 'scheduled_downtime' || $type === 'scheduled-downtime') {
+ $type = 'ScheduledDowntime';
+ }
+
+ return 'Icinga\\Module\\Director\\Objects\\' . $prefix . ucfirst($type);
+ }
+
+ public static function tableNameByType($type)
+ {
+ $class = static::classByType($type);
+ $dummy = $class::create([]);
+
+ return $dummy->getTableName();
+ }
+
+ public static function shortTypeForObject(DbObject $object)
+ {
+ if ($object instanceof IcingaObject) {
+ return $object->getShortTableName();
+ }
+
+ return $object->getTableName();
+ }
+
+ public static function newObject($type, $properties = [], Db $db = null)
+ {
+ /** @var DbObject $class fake hint for the IDE, it's a string */
+ $class = self::classByType($type);
+ return $class::create($properties, $db);
+ }
+}
diff --git a/library/Director/Data/Db/DbObjectWithSettings.php b/library/Director/Data/Db/DbObjectWithSettings.php
new file mode 100644
index 0000000..4f6b139
--- /dev/null
+++ b/library/Director/Data/Db/DbObjectWithSettings.php
@@ -0,0 +1,168 @@
+<?php
+
+namespace Icinga\Module\Director\Data\Db;
+
+use Icinga\Module\Director\Db;
+
+abstract class DbObjectWithSettings extends DbObject
+{
+ /** @var Db $connection */
+ protected $connection;
+
+ protected $settingsTable = 'your_table_name';
+
+ protected $settingsRemoteId = 'column_pointing_to_main_table_id';
+
+ protected $settings = [];
+
+ public function set($key, $value)
+ {
+ if ($this->hasProperty($key)) {
+ return parent::set($key, $value);
+ } elseif ($this->hasSetterForProperty($key)) { // Hint: hasProperty checks only for Getters
+ return parent::set($key, $value);
+ }
+
+ if (! \array_key_exists($key, $this->settings) || $value !== $this->settings[$key]) {
+ $this->hasBeenModified = true;
+ }
+
+ $this->settings[$key] = $value;
+ return $this;
+ }
+
+ public function get($key)
+ {
+ if ($this->hasProperty($key)) {
+ return parent::get($key);
+ }
+
+ if (array_key_exists($key, $this->settings)) {
+ return $this->settings[$key];
+ }
+
+ return parent::get($key);
+ }
+
+ public function setSettings($settings)
+ {
+ $settings = (array) $settings;
+ ksort($settings);
+ if ($settings !== $this->settings) {
+ $this->settings = $settings;
+ $this->hasBeenModified = true;
+ }
+
+ return $this;
+ }
+
+ public function getSettings()
+ {
+ // Sort them, important only for new objects
+ ksort($this->settings);
+ return $this->settings;
+ }
+
+ public function getSetting($name, $default = null)
+ {
+ if (array_key_exists($name, $this->settings)) {
+ return $this->settings[$name];
+ }
+
+ return $default;
+ }
+
+ public function getStoredSetting($name, $default = null)
+ {
+ $stored = $this->fetchSettingsFromDb();
+ if (array_key_exists($name, $stored)) {
+ return $stored[$name];
+ }
+
+ return $default;
+ }
+
+ public function __unset($key)
+ {
+ if ($this->hasProperty($key)) {
+ parent::__unset($key);
+ }
+
+ if (array_key_exists($key, $this->settings)) {
+ unset($this->settings[$key]);
+ $this->hasBeenModified = true;
+ }
+ }
+
+ protected function onStore()
+ {
+ $old = $this->fetchSettingsFromDb();
+ $oldKeys = array_keys($old);
+ $newKeys = array_keys($this->settings);
+ $add = [];
+ $mod = [];
+ $del = [];
+ $id = $this->get('id');
+
+ foreach ($this->settings as $key => $val) {
+ if (array_key_exists($key, $old)) {
+ if ($old[$key] !== $this->settings[$key]) {
+ $mod[$key] = $this->settings[$key];
+ }
+ } else {
+ $add[$key] = $this->settings[$key];
+ }
+ }
+
+ foreach (array_diff($oldKeys, $newKeys) as $key) {
+ $del[] = $key;
+ }
+
+ $where = sprintf($this->settingsRemoteId . ' = %d AND setting_name = ?', $id);
+ $db = $this->getDb();
+ foreach ($mod as $key => $val) {
+ $db->update(
+ $this->settingsTable,
+ ['setting_value' => $val],
+ $db->quoteInto($where, $key)
+ );
+ }
+
+ foreach ($add as $key => $val) {
+ $db->insert(
+ $this->settingsTable,
+ [
+ $this->settingsRemoteId => $id,
+ 'setting_name' => $key,
+ 'setting_value' => $val
+ ]
+ );
+ }
+
+ if (! empty($del)) {
+ $where = sprintf($this->settingsRemoteId . ' = %d AND setting_name IN (?)', $id);
+ $db->delete($this->settingsTable, $db->quoteInto($where, $del));
+ }
+ }
+
+ protected function fetchSettingsFromDb()
+ {
+ $db = $this->getDb();
+ $id = $this->get('id');
+ if (! $id) {
+ return [];
+ }
+
+ return $db->fetchPairs(
+ $db->select()
+ ->from($this->settingsTable, ['setting_name', 'setting_value'])
+ ->where($this->settingsRemoteId . ' = ?', $id)
+ ->order('setting_name')
+ );
+ }
+
+ protected function onLoadFromDb()
+ {
+ $this->settings = $this->fetchSettingsFromDb();
+ }
+}
diff --git a/library/Director/Data/Db/IcingaObjectFilterRenderer.php b/library/Director/Data/Db/IcingaObjectFilterRenderer.php
new file mode 100644
index 0000000..de2ec79
--- /dev/null
+++ b/library/Director/Data/Db/IcingaObjectFilterRenderer.php
@@ -0,0 +1,133 @@
+<?php
+
+namespace Icinga\Module\Director\Data\Db;
+
+use Icinga\Data\Filter\Filter;
+use Icinga\Data\Filter\FilterChain;
+use Icinga\Data\Filter\FilterException;
+use Icinga\Data\Filter\FilterExpression;
+
+class IcingaObjectFilterRenderer
+{
+ /** @var Filter */
+ protected $filter;
+
+ /** @var IcingaObjectQuery */
+ protected $query;
+
+ protected $columnMap = [
+ 'host.name' => 'host.object_name',
+ 'service.name' => 'service.object_name',
+ ];
+
+ public function __construct(Filter $filter, IcingaObjectQuery $query)
+ {
+ $this->filter = clone($filter);
+ $this->fixFilterColumns($this->filter);
+ $this->query = $query;
+ }
+
+ /**
+ * @param Filter $filter
+ * @param IcingaObjectQuery $query
+ *
+ * @return IcingaObjectQuery
+ */
+ public static function apply(Filter $filter, IcingaObjectQuery $query)
+ {
+ $self = new static($filter, $query);
+ return $self->applyFilterToQuery();
+ }
+
+ /**
+ * @return IcingaObjectQuery
+ */
+ protected function applyFilterToQuery()
+ {
+ $this->query->escapedWhere($this->renderFilter($this->filter));
+ return $this->query;
+ }
+
+ /**
+ * @param Filter $filter
+ * @return string
+ */
+ protected function renderFilter(Filter $filter)
+ {
+ if ($filter->isChain()) {
+ /** @var FilterChain $filter */
+ return $this->renderFilterChain($filter);
+ } else {
+ /** @var FilterExpression $filter */
+ return $this->renderFilterExpression($filter);
+ }
+ }
+
+ /**
+ * @param FilterChain $filter
+ *
+ * @throws FilterException
+ *
+ * @return string
+ */
+ protected function renderFilterChain(FilterChain $filter)
+ {
+ $parts = array();
+ foreach ($filter->filters() as $sub) {
+ $parts[] = $this->renderFilter($sub);
+ }
+
+ $op = $filter->getOperatorName();
+ if ($op === 'NOT') {
+ if (count($parts) !== 1) {
+ throw new FilterException(
+ 'NOT should have exactly one child, got %s',
+ count($parts)
+ );
+ }
+
+ return $op . ' ' . $parts[0];
+ } else {
+ if ($filter->isRootNode()) {
+ return implode(' ' . $op . ' ', $parts);
+ } else {
+ return '(' . implode(' ' . $op . ' ', $parts) . ')';
+ }
+ }
+ }
+
+ protected function fixFilterColumns(Filter $filter)
+ {
+ if ($filter->isExpression()) {
+ /** @var FilterExpression $filter */
+ $col = $filter->getColumn();
+ if (array_key_exists($col, $this->columnMap)) {
+ $filter->setColumn($this->columnMap[$col]);
+ }
+ if (strpos($col, 'vars.') === false) {
+ $filter->setExpression(json_decode($filter->getExpression()));
+ }
+ } else {
+ /** @var FilterChain $filter */
+ foreach ($filter->filters() as $sub) {
+ $this->fixFilterColumns($sub);
+ }
+ }
+ }
+
+ /**
+ * @param FilterExpression $filter
+ *
+ * @return string
+ */
+ protected function renderFilterExpression(FilterExpression $filter)
+ {
+ $query = $this->query;
+ $column = $query->getAliasForRequiredFilterColumn($filter->getColumn());
+ return $query->whereToSql(
+ $column,
+ $filter->getSign(),
+ $filter->getExpression()
+ );
+ }
+}
diff --git a/library/Director/Data/Db/IcingaObjectQuery.php b/library/Director/Data/Db/IcingaObjectQuery.php
new file mode 100644
index 0000000..4556ba7
--- /dev/null
+++ b/library/Director/Data/Db/IcingaObjectQuery.php
@@ -0,0 +1,255 @@
+<?php
+
+namespace Icinga\Module\Director\Data\Db;
+
+use Icinga\Data\Db\DbQuery;
+use Icinga\Data\Filter\Filter;
+use Icinga\Exception\NotFoundError;
+use Icinga\Exception\NotImplementedError;
+use Icinga\Module\Director\Db;
+use Zend_Db_Expr as ZfDbExpr;
+use Zend_Db_Select as ZfDbSelect;
+
+class IcingaObjectQuery
+{
+ const BASE_ALIAS = 'o';
+
+ /** @var Db */
+ protected $connection;
+
+ /** @var string */
+ protected $type;
+
+ /** @var \Zend_Db_Adapter_Abstract */
+ protected $db;
+
+ /** @var ZfDbSelect */
+ protected $query;
+
+ /** @var bool */
+ protected $resolved;
+
+ /** @var array joined tables, alias => table */
+ protected $requiredTables;
+
+ /** @var array maps table aliases, alias => table*/
+ protected $aliases;
+
+ /** @var DbQuery */
+ protected $dummyQuery;
+
+ /** @var array varname => alias */
+ protected $joinedVars = array();
+
+ protected $customVarTable;
+
+ protected $baseQuery;
+
+ /**
+ * IcingaObjectQuery constructor.
+ *
+ * @param string $type
+ * @param Db $connection
+ * @param bool $resolved
+ */
+ public function __construct($type, Db $connection, $resolved = true)
+ {
+ $this->type = $type;
+ $this->connection = $connection;
+ $this->db = $connection->getDbAdapter();
+ $this->resolved = $resolved;
+ $baseTable = 'icinga_' . $type;
+ $this->baseQuery = $this->db->select()
+ ->from(
+ array(self::BASE_ALIAS => $baseTable),
+ array('name' => 'object_name')
+ )->order(self::BASE_ALIAS . '.object_name');
+ }
+
+ public function joinVar($name)
+ {
+ if (! $this->hasJoinedVar($name)) {
+ $type = $this->type;
+ $alias = $this->safeVarAlias($name);
+ $varAlias = "v_$alias";
+ // TODO: optionally $varRelation = sprintf('icinga_%s_resolved_var', $type);
+ $varRelation = sprintf('icinga_%s_var', $type);
+ $idCol = sprintf('%s.%s_id', $alias, $type);
+
+ $joinOn = sprintf('%s = %s.id', $idCol, self::BASE_ALIAS);
+ $joinVarOn = $this->db->quoteInto(
+ sprintf('%s.checksum = %s.checksum AND %s.varname = ?', $alias, $varAlias, $alias),
+ $name
+ );
+
+ $this->baseQuery->join(
+ array($alias => $varRelation),
+ $joinOn,
+ array()
+ )->join(
+ array($varAlias => 'icinga_var'),
+ $joinVarOn,
+ array($alias => $varAlias . '.varvalue')
+ );
+
+ $this->joinedVars[$name] = $varAlias . '.varvalue';
+ }
+
+ return $this;
+ }
+
+ // Debug only
+ public function getSql()
+ {
+ return (string) $this->baseQuery;
+ }
+
+ public function listNames()
+ {
+ return $this->db->fetchCol(
+ $this->baseQuery
+ );
+ }
+
+ protected function hasJoinedVar($name)
+ {
+ return array_key_exists($name, $this->joinedVars);
+ }
+
+ public function getJoinedVarAlias($name)
+ {
+ return $this->joinedVars[$name];
+ }
+
+ // TODO: recheck this
+ protected function safeVarAlias($name)
+ {
+ $alias = preg_replace('/[^a-zA-Z0-9_]/', '', (string) $name);
+ $cnt = 1;
+ $checkAlias = $alias;
+ while (in_array($checkAlias, $this->joinedVars)) {
+ $cnt++;
+ $checkAlias = $alias . '_' . $cnt;
+ }
+
+ return $checkAlias;
+ }
+
+ public function escapedWhere($where)
+ {
+ $this->baseQuery->where(new ZfDbExpr($where));
+ }
+
+ /**
+ * @param $column
+ * @return string
+ * @throws NotFoundError
+ * @throws NotImplementedError
+ */
+ public function getAliasForRequiredFilterColumn($column)
+ {
+ list($key, $sub) = $this->splitFilterKey($column);
+ if ($sub === null) {
+ return $key;
+ } else {
+ $objectType = $key;
+ }
+
+ if ($objectType === $this->type) {
+ list($key, $sub) = $this->splitFilterKey($sub);
+ if ($sub === null) {
+ return $key;
+ }
+
+ if ($key === 'vars') {
+ return $this->joinVar($sub)->getJoinedVarAlias($sub);
+ } else {
+ throw new NotFoundError('Not yet, my type: %s - %s', $objectType, $key);
+ }
+ } else {
+ throw new NotImplementedError('Not yet: %s - %s', $objectType, $sub);
+ }
+ }
+
+ protected function splitFilterKey($key)
+ {
+ $dot = strpos($key, '.');
+ if ($dot === false) {
+ return [$key, null];
+ } else {
+ return [substr($key, 0, $dot), substr($key, $dot + 1)];
+ }
+ }
+
+ protected function requireTable($name)
+ {
+ if ($alias = $this->getTableAliasFromQuery($name)) {
+ return $alias;
+ }
+
+ $this->joinTable($name);
+ }
+
+ protected function joinTable($name)
+ {
+ if (!array_key_exists($name, $this->requiredTables)) {
+ $alias = $this->makeAlias($name);
+ }
+
+ return $this->tableAliases($name);
+ }
+
+ protected function hasAlias($name)
+ {
+ return array_key_exists($name, $this->aliases);
+ }
+
+ protected function makeAlias($name)
+ {
+ if (substr($name, 0, 7) === 'icinga_') {
+ $shortName = substr($name, 7);
+ } else {
+ $shortName = $name;
+ }
+
+ $parts = preg_split('/_/', $shortName, -1);
+ $alias = '';
+ foreach ($parts as $part) {
+ $alias .= $part[0];
+ if (! $this->hasAlias($alias)) {
+ return $alias;
+ }
+ }
+
+ $cnt = 1;
+ do {
+ $cnt++;
+ if (! $this->hasAlias($alias . $cnt)) {
+ return $alias . $cnt;
+ }
+ } while (! $this->hasAlias($alias));
+
+ return $alias;
+ }
+
+ protected function getTableAliasFromQuery($table)
+ {
+ $tables = $this->query->getPart('from');
+ $key = array_search($table, $tables);
+ if ($key === null || $key === false) {
+ return false;
+ }
+ /*
+ 'joinType' => $type,
+ 'schema' => $schema,
+ 'tableName' => $tableName,
+ 'joinCondition' => $cond
+ */
+ return $key;
+ }
+
+ public function whereToSql($col, $sign, $expression)
+ {
+ return $this->connection->renderFilter(Filter::expression($col, $sign, $expression));
+ }
+}
diff --git a/library/Director/Data/Db/ServiceSetQueryBuilder.php b/library/Director/Data/Db/ServiceSetQueryBuilder.php
new file mode 100644
index 0000000..7841d1e
--- /dev/null
+++ b/library/Director/Data/Db/ServiceSetQueryBuilder.php
@@ -0,0 +1,158 @@
+<?php
+
+namespace Icinga\Module\Director\Data\Db;
+
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Db\Branch\BranchSupport;
+use Icinga\Module\Director\Db\DbSelectParenthesis;
+use Icinga\Module\Director\Db\DbUtil;
+use Icinga\Module\Director\Objects\IcingaService;
+use Icinga\Module\Director\Objects\IcingaServiceSet;
+use Icinga\Module\Director\Web\Table\TableWithBranchSupport;
+use Ramsey\Uuid\Uuid;
+use Ramsey\Uuid\UuidInterface;
+
+class ServiceSetQueryBuilder
+{
+ use TableWithBranchSupport;
+
+ const TABLE = BranchSupport::TABLE_ICINGA_SERVICE;
+ const BRANCHED_TABLE = BranchSupport::BRANCHED_TABLE_ICINGA_SERVICE;
+ const SET_TABLE = BranchSupport::TABLE_ICINGA_SERVICE_SET;
+ const BRANCHED_SET_TABLE = BranchSupport::BRANCHED_TABLE_ICINGA_SERVICE_SET;
+
+ /** @var Db */
+ protected $connection;
+
+ /** @var \Zend_Db_Adapter_Abstract */
+ protected $db;
+
+ /**
+ * @param ?UuidInterface $uuid
+ */
+ public function __construct(Db $connection, $uuid = null)
+ {
+ $this->connection = $connection;
+ $this->db = $connection->getDbAdapter();
+ if ($uuid) {
+ $this->setBranchUuid($uuid);
+ }
+ }
+
+ /**
+ * @return \Zend_Db_Select
+ * @throws \Zend_Db_Select_Exception
+ */
+ public function selectServicesForSet(IcingaServiceSet $set)
+ {
+ $db = $this->connection->getDbAdapter();
+ if ($this->branchUuid) {
+ $right = $this->selectRightBranchedServices($set)->columns($this->getRightBranchedColumns());
+ $left = $this->selectLeftBranchedServices($set)->columns($this->getLeftBranchedColumns());
+ $query = $this->db->select()->from(['u' => $db->select()->union([
+ 'l' => new DbSelectParenthesis($left),
+ 'r' => new DbSelectParenthesis($right),
+ ])]);
+ $query->order('service_set');
+ } else {
+ $query = $this->selectServices($set)->columns($this->getColumns());
+ }
+
+ return $query;
+ }
+
+ protected function selectServices(IcingaServiceSet $set)
+ {
+ return $this->db
+ ->select()
+ ->from(['o' =>self::TABLE], [])
+ ->joinLeft(['os' => self::SET_TABLE], 'os.id = o.service_set_id', [])
+ ->where('os.uuid = ?', $this->connection->quoteBinary($set->getUniqueId()->getBytes()));
+ }
+
+ protected function selectLeftBranchedServices(IcingaServiceSet $set)
+ {
+ return $this
+ ->selectServices($set)
+ ->joinLeft(
+ ['bo' => self::BRANCHED_TABLE],
+ $this->db->quoteInto('bo.uuid = o.uuid AND bo.branch_uuid = ?', $this->getQuotedBranchUuid()),
+ []
+ );
+ }
+
+ protected function selectRightBranchedServices(IcingaServiceSet $set)
+ {
+ return $this->db
+ ->select()
+ ->from(['o' => self::TABLE], [])
+ ->joinRight(['bo' => self::BRANCHED_TABLE], 'bo.uuid = o.uuid', [])
+ ->where('bo.service_set = ?', $set->get('object_name'))
+ ->where('bo.branch_uuid = ?', $this->getQuotedBranchUuid());
+ }
+
+ protected static function resetQueryProperties(\Zend_Db_Select $query)
+ {
+ // TODO: Keep existing UUID, becomes important when using this for other tables too (w/o UNION)
+ // $columns = $query->getPart($query::COLUMNS);
+ $query->reset($query::COLUMNS);
+ $query->columns('uuid');
+ return $query;
+ }
+
+ public function fetchServicesWithQuery(\Zend_Db_Select $query)
+ {
+ static::resetQueryProperties($query);
+ $db = $this->connection->getDbAdapter();
+ $uuids = $db->fetchCol($query);
+
+ $services = [];
+ foreach ($uuids as $uuid) {
+ $service = IcingaService::loadWithUniqueId(Uuid::fromBytes(DbUtil::binaryResult($uuid)), $this->connection);
+ $service->set('service_set', null); // TODO: CHECK THIS!!!!
+
+ $services[$service->getObjectName()] = $service;
+ }
+
+ return $services;
+ }
+
+ protected function getColumns()
+ {
+ return [
+ 'uuid' => 'o.uuid', // MUST be first because of UNION column order, see branchifyColumns()
+ 'id' => 'o.id',
+ 'branch_uuid' => '(null)',
+ 'service_set' => 'os.object_name',
+ 'service' => 'o.object_name',
+ 'disabled' => 'o.disabled',
+ 'object_type' => 'o.object_type',
+ 'blacklisted' => "('n')",
+ ];
+ }
+
+ protected function getLeftBranchedColumns()
+ {
+ $columns = $this->getColumns();
+ $columns['branch_uuid'] = 'bo.branch_uuid';
+ $columns['service_set'] = 'COALESCE(os.object_name, bo.service_set)';
+
+ return $this->branchifyColumns($columns);
+ }
+
+ protected function getRightBranchedColumns()
+ {
+ $columns = $this->getColumns();
+ $columns = $this->branchifyColumns($columns);
+ $columns['branch_uuid'] = 'bo.branch_uuid';
+ $columns['service_set'] = 'bo.service_set';
+ $columns['id'] = '(NULL)';
+
+ return $columns;
+ }
+
+ protected function getQuotedBranchUuid()
+ {
+ return $this->connection->quoteBinary($this->branchUuid->getBytes());
+ }
+}
diff --git a/library/Director/Data/Exporter.php b/library/Director/Data/Exporter.php
new file mode 100644
index 0000000..a2e3191
--- /dev/null
+++ b/library/Director/Data/Exporter.php
@@ -0,0 +1,303 @@
+<?php
+
+namespace Icinga\Module\Director\Data;
+
+use gipfl\ZfDb\Adapter\Adapter;
+use Icinga\Module\Director\Data\Db\DbObject;
+use Icinga\Module\Director\Data\Db\DbObjectWithSettings;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Objects\DirectorDatafield;
+use Icinga\Module\Director\Objects\DirectorDatalist;
+use Icinga\Module\Director\Objects\DirectorDatalistEntry;
+use Icinga\Module\Director\Objects\DirectorJob;
+use Icinga\Module\Director\Objects\IcingaCommand;
+use Icinga\Module\Director\Objects\IcingaHost;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Icinga\Module\Director\Objects\IcingaServiceSet;
+use Icinga\Module\Director\Objects\IcingaTemplateChoice;
+use Icinga\Module\Director\Objects\ImportSource;
+use Icinga\Module\Director\Objects\InstantiatedViaHook;
+use Icinga\Module\Director\Objects\SyncRule;
+use RuntimeException;
+
+class Exporter
+{
+ /** @var Adapter|\Zend_Db_Adapter_Abstract */
+ protected $db;
+
+ /** @var FieldReferenceLoader */
+ protected $fieldReferenceLoader;
+
+ /** @var ?HostServiceLoader */
+ protected $serviceLoader = null;
+
+ protected $exportHostServices = false;
+ protected $showDefaults = false;
+ protected $showIds = false;
+ protected $resolveObjects = false;
+
+ /** @var Db */
+ protected $connection;
+
+ /** @var ?array */
+ protected $chosenProperties = null;
+
+ public function __construct(Db $connection)
+ {
+ $this->connection = $connection;
+ $this->db = $connection->getDbAdapter();
+ $this->fieldReferenceLoader = new FieldReferenceLoader($connection);
+ }
+
+ public function export(DbObject $object)
+ {
+ $props = $object instanceof IcingaObject
+ ? $this->exportIcingaObject($object)
+ : $this->exportDbObject($object);
+
+ ImportExportDeniedProperties::strip($props, $object, $this->showIds);
+ $this->appendTypeSpecificRelations($props, $object);
+
+ if ($this->chosenProperties !== null) {
+ $chosen = [];
+ foreach ($this->chosenProperties as $k) {
+ if (array_key_exists($k, $props)) {
+ $chosen[$k] = $props[$k];
+ }
+ }
+
+ $props = $chosen;
+ }
+
+ ksort($props);
+ return (object) $props;
+ }
+
+ public function enableHostServices($enable = true)
+ {
+ $this->exportHostServices = $enable;
+ return $this;
+ }
+
+ public function showDefaults($show = true)
+ {
+ $this->showDefaults = $show;
+ return $this;
+ }
+
+ public function showIds($show = true)
+ {
+ $this->showIds = $show;
+ return $this;
+ }
+
+ public function resolveObjects($resolve = true)
+ {
+ $this->resolveObjects = $resolve;
+ if ($this->serviceLoader) {
+ $this->serviceLoader->resolveObjects($resolve);
+ }
+
+ return $this;
+ }
+
+ public function filterProperties(array $properties)
+ {
+ $this->chosenProperties = $properties;
+ return $this;
+ }
+
+ protected function appendTypeSpecificRelations(array &$props, DbObject $object)
+ {
+ if ($object instanceof DirectorDatalist) {
+ $props['entries'] = $this->exportDatalistEntries($object);
+ } elseif ($object instanceof DirectorDatafield) {
+ if (isset($props['settings']->datalist_id)) {
+ $props['settings']->datalist = $this->getDatalistNameForId($props['settings']->datalist_id);
+ unset($props['settings']->datalist_id);
+ }
+
+ $props['category'] = isset($props['category_id'])
+ ? $this->getDatafieldCategoryNameForId($props['category_id'])
+ : null;
+ unset($props['category_id']);
+ } elseif ($object instanceof ImportSource) {
+ $props['modifiers'] = $this->exportRowModifiers($object);
+ } elseif ($object instanceof SyncRule) {
+ $props['properties'] = $this->exportSyncProperties($object);
+ } elseif ($object instanceof IcingaCommand) {
+ if (isset($props['arguments'])) {
+ foreach ($props['arguments'] as $key => $argument) {
+ if (property_exists($argument, 'command_id')) {
+ unset($props['arguments'][$key]->command_id);
+ }
+ }
+ }
+ } elseif ($object instanceof DirectorJob) {
+ if ($object->hasTimeperiod()) {
+ $props['timeperiod'] = $object->timeperiod()->getObjectName();
+ }
+ unset($props['timeperiod_id']);
+ } elseif ($object instanceof IcingaTemplateChoice) {
+ if (isset($props['required_template_id'])) {
+ $requiredId = $props['required_template_id'];
+ unset($props['required_template_id']);
+ $props = $this->loadTemplateName($object->getObjectTableName(), $requiredId);
+ }
+
+ $props['members'] = array_values($object->getMembers());
+ } elseif ($object instanceof IcingaServiceSet) {
+ if ($object->get('host_id')) {
+ // Sets on Host
+ throw new RuntimeException('Not yet');
+ }
+ $props['services'] = [];
+ foreach ($object->getServiceObjects() as $serviceObject) {
+ $props['services'][$serviceObject->getObjectName()] = $this->export($serviceObject);
+ }
+ ksort($props['services']);
+ } elseif ($object instanceof IcingaHost) {
+ if ($this->exportHostServices) {
+ $services = [];
+ foreach ($this->serviceLoader()->fetchServicesForHost($object) as $service) {
+ $services[] = $this->export($service);
+ }
+
+ $props['services'] = $services;
+ }
+ }
+ }
+
+ public function serviceLoader()
+ {
+ if ($this->serviceLoader === null) {
+ $this->serviceLoader = new HostServiceLoader($this->connection);
+ $this->serviceLoader->resolveObjects($this->resolveObjects);
+ }
+
+ return $this->serviceLoader;
+ }
+
+ protected function loadTemplateName($table, $id)
+ {
+ $db = $this->db;
+ $query = $db->select()
+ ->from(['o' => $table], 'o.object_name')->where("o.object_type = 'template'")
+ ->where('o.id = ?', $id);
+
+ return $db->fetchOne($query);
+ }
+
+ protected function getDatalistNameForId($id)
+ {
+ $db = $this->db;
+ $query = $db->select()->from('director_datalist', 'list_name')->where('id = ?', (int) $id);
+ return $db->fetchOne($query);
+ }
+
+ protected function getDatafieldCategoryNameForId($id)
+ {
+ $db = $this->db;
+ $query = $db->select()->from('director_datafield_category', 'category_name')->where('id = ?', (int) $id);
+ return $db->fetchOne($query);
+ }
+
+ protected function exportRowModifiers(ImportSource $object)
+ {
+ $modifiers = [];
+ // Hint: they're sorted by priority
+ foreach ($object->fetchRowModifiers() as $modifier) {
+ $modifiers[] = $this->export($modifier);
+ }
+
+ return $modifiers;
+ }
+
+ public function exportSyncProperties(SyncRule $object)
+ {
+ $all = [];
+ $db = $this->db;
+ $sourceNames = $db->fetchPairs(
+ $db->select()->from('import_source', ['id', 'source_name'])
+ );
+
+ foreach ($object->getSyncProperties() as $property) {
+ $properties = $property->getProperties();
+ $properties['source'] = $sourceNames[$properties['source_id']];
+ unset($properties['id']);
+ unset($properties['rule_id']);
+ unset($properties['source_id']);
+ ksort($properties);
+ $all[] = (object) $properties;
+ }
+
+ return $all;
+ }
+
+ /**
+ * @param DbObject $object
+ * @return array
+ */
+ protected function exportDbObject(DbObject $object)
+ {
+ $props = $object->getProperties();
+ if ($object instanceof DbObjectWithSettings) {
+ if ($object instanceof InstantiatedViaHook) {
+ $props['settings'] = (object) $object->getInstance()->exportSettings();
+ } else {
+ $props['settings'] = (object) $object->getSettings(); // Already sorted
+ }
+ }
+ unset($props['uuid']); // Not yet
+ if (! $this->showDefaults) {
+ foreach ($props as $key => $value) {
+ // We assume NULL as a default value for all non-IcingaObject properties
+ if ($value === null) {
+ unset($props[$key]);
+ }
+ }
+ }
+
+ return $props;
+ }
+
+ /**
+ * @param IcingaObject $object
+ * @return array
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ protected function exportIcingaObject(IcingaObject $object)
+ {
+ $props = (array) $object->toPlainObject($this->resolveObjects, !$this->showDefaults);
+ if ($object->supportsFields()) {
+ $props['fields'] = $this->fieldReferenceLoader->loadFor($object);
+ }
+
+ return $props;
+ }
+
+ protected function exportDatalistEntries(DirectorDatalist $list)
+ {
+ $entries = [];
+ $id = $list->get('id');
+ if ($id === null) {
+ return $entries;
+ }
+
+ $dbEntries = DirectorDatalistEntry::loadAllForList($list);
+ // Hint: they are loaded with entry_name key
+ ksort($dbEntries);
+
+ foreach ($dbEntries as $entry) {
+ if ($entry->shouldBeRemoved()) {
+ continue;
+ }
+ $plainEntry = $entry->getProperties();
+ unset($plainEntry['list_id']);
+
+ $entries[] = $plainEntry;
+ }
+
+ return $entries;
+ }
+}
diff --git a/library/Director/Data/FieldReferenceLoader.php b/library/Director/Data/FieldReferenceLoader.php
new file mode 100644
index 0000000..1e3d92e
--- /dev/null
+++ b/library/Director/Data/FieldReferenceLoader.php
@@ -0,0 +1,51 @@
+<?php
+
+namespace Icinga\Module\Director\Data;
+
+use gipfl\ZfDb\Adapter\Adapter;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Objects\IcingaObject;
+
+class FieldReferenceLoader
+{
+ /** @var Adapter|\Zend_Db_Adapter_Abstract */
+ protected $db;
+
+ public function __construct(Db $connection)
+ {
+ $this->db = $connection->getDbAdapter();
+ }
+
+ /**
+ * @param int $id
+ * @return array
+ */
+ public function loadFor(IcingaObject $object)
+ {
+ $db = $this->db;
+ $id = $object->get('id');
+ if ($id === null) {
+ return [];
+ }
+ $type = $object->getShortTableName();
+ $res = $db->fetchAll(
+ $db->select()->from(['f' => "icinga_${type}_field"], [
+ 'f.datafield_id',
+ 'f.is_required',
+ 'f.var_filter',
+ ])->join(['df' => 'director_datafield'], 'df.id = f.datafield_id', [])
+ ->where("${type}_id = ?", (int) $id)
+ ->order('varname ASC')
+ );
+
+ if (empty($res)) {
+ return [];
+ }
+
+ foreach ($res as $field) {
+ $field->datafield_id = (int) $field->datafield_id;
+ }
+
+ return $res;
+ }
+}
diff --git a/library/Director/Data/HostServiceLoader.php b/library/Director/Data/HostServiceLoader.php
new file mode 100644
index 0000000..4cc4b96
--- /dev/null
+++ b/library/Director/Data/HostServiceLoader.php
@@ -0,0 +1,170 @@
+<?php
+
+namespace Icinga\Module\Director\Data;
+
+use gipfl\IcingaWeb2\Table\QueryBasedTable;
+use gipfl\ZfDb\Select;
+use Icinga\Data\SimpleQuery;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Db\AppliedServiceSetLoader;
+use Icinga\Module\Director\Objects\IcingaHost;
+use Icinga\Module\Director\Objects\IcingaService;
+use Icinga\Module\Director\Objects\IcingaServiceSet;
+use Icinga\Module\Director\Repository\IcingaTemplateRepository;
+use Icinga\Module\Director\Web\Table\IcingaHostAppliedServicesTable;
+use Icinga\Module\Director\Web\Table\IcingaServiceSetServiceTable;
+use Icinga\Module\Director\Web\Table\ObjectsTableService;
+use Ramsey\Uuid\Uuid;
+use RuntimeException;
+use Zend_Db_Select;
+
+class HostServiceLoader
+{
+ /** @var Db */
+ protected $connection;
+
+ /** @var \Zend_Db_Adapter_Abstract */
+ protected $db;
+
+ /** @var bool */
+ protected $resolveHostServices = false;
+
+ /** @var bool */
+ protected $resolveObjects = false;
+
+ public function __construct(Db $connection)
+ {
+ $this->connection = $connection;
+ $this->db = $connection->getDbAdapter();
+ }
+
+ public function fetchServicesForHost(IcingaHost $host)
+ {
+ $table = (new ObjectsTableService($this->connection))->setHost($host);
+ $services = $this->fetchServicesForTable($table);
+ if ($this->resolveHostServices) {
+ foreach ($this->fetchAllServicesForHost($host) as $service) {
+ $services[] = $service;
+ }
+ }
+
+ return $services;
+ }
+
+ public function resolveHostServices($enable = true)
+ {
+ $this->resolveHostServices = $enable;
+ return $this;
+ }
+
+ public function resolveObjects($resolve = true)
+ {
+ $this->resolveObjects = $resolve;
+ return $this;
+ }
+
+ protected function fetchAllServicesForHost(IcingaHost $host)
+ {
+ $services = [];
+ /** @var IcingaHost[] $parents */
+ $parents = IcingaTemplateRepository::instanceByObject($host)->getTemplatesFor($host, true);
+ foreach ($parents as $parent) {
+ $table = (new ObjectsTableService($this->connection))
+ ->setHost($parent)
+ ->setInheritedBy($host);
+ foreach ($this->fetchServicesForTable($table) as $service) {
+ $services[] = $service;
+ }
+ }
+
+ foreach ($this->getHostServiceSetTables($host) as $table) {
+ foreach ($this->fetchServicesForTable($table) as $service) {
+ $services[] = $service;
+ }
+ }
+ foreach ($parents as $parent) {
+ foreach ($this->getHostServiceSetTables($parent, $host) as $table) {
+ foreach ($this->fetchServicesForTable($table) as $service) {
+ $services[] = $service;
+ }
+ }
+ }
+
+ $appliedSets = AppliedServiceSetLoader::fetchForHost($host);
+ foreach ($appliedSets as $set) {
+ $table = IcingaServiceSetServiceTable::load($set)
+ // ->setHost($host)
+ ->setAffectedHost($host);
+ foreach ($this->fetchServicesForTable($table) as $service) {
+ $services[] = $service;
+ }
+ }
+
+ $table = IcingaHostAppliedServicesTable::load($host);
+ foreach ($this->fetchServicesForTable($table) as $service) {
+ $services[] = $service;
+ }
+
+ return $services;
+ }
+
+ /**
+ * Duplicates Logic in HostController
+ *
+ * @param IcingaHost $host
+ * @param IcingaHost|null $affectedHost
+ * @return IcingaServiceSetServiceTable[]
+ */
+ protected function getHostServiceSetTables(IcingaHost $host, IcingaHost $affectedHost = null)
+ {
+ $tables = [];
+ $db = $this->connection;
+ if ($affectedHost === null) {
+ $affectedHost = $host;
+ }
+ if ($host->get('id') === null) {
+ return $tables;
+ }
+
+ $query = $db->getDbAdapter()->select()
+ ->from(['ss' => 'icinga_service_set'], 'ss.*')
+ ->join(['hsi' => 'icinga_service_set_inheritance'], 'hsi.parent_service_set_id = ss.id', [])
+ ->join(['hs' => 'icinga_service_set'], 'hs.id = hsi.service_set_id', [])
+ ->where('hs.host_id = ?', $host->get('id'));
+
+ $sets = IcingaServiceSet::loadAll($db, $query, 'object_name');
+ /** @var IcingaServiceSet $set*/
+ foreach ($sets as $name => $set) {
+ $tables[] = IcingaServiceSetServiceTable::load($set)
+ ->setHost($host)
+ ->setAffectedHost($affectedHost);
+ }
+
+ return $tables;
+ }
+
+ protected function fetchServicesForTable(QueryBasedTable $table)
+ {
+ $query = $table->getQuery();
+ if ($query instanceof Select || $query instanceof Zend_Db_Select) {
+ // What about SimpleQuery? IcingaHostAppliedServicesTable with branch in place?
+ $query->reset(Select::LIMIT_COUNT);
+ $query->reset(Select::LIMIT_OFFSET);
+ $rows = $this->db->fetchAll($query);
+ } elseif ($query instanceof SimpleQuery) {
+ $rows = $query->fetchAll();
+ } else {
+ throw new RuntimeException('Table query needs to be either a Select or a SimpleQuery instance');
+ }
+ $services = [];
+ foreach ($rows as $row) {
+ $service = IcingaService::loadWithUniqueId(Uuid::fromBytes($row->uuid), $this->connection);
+ if ($this->resolveObjects) {
+ $service = $service::fromPlainObject($service->toPlainObject(true), $this->connection);
+ }
+ $services[] = $service;
+ }
+
+ return $services;
+ }
+}
diff --git a/library/Director/Data/ImportExportDeniedProperties.php b/library/Director/Data/ImportExportDeniedProperties.php
new file mode 100644
index 0000000..747eb0f
--- /dev/null
+++ b/library/Director/Data/ImportExportDeniedProperties.php
@@ -0,0 +1,52 @@
+<?php
+
+namespace Icinga\Module\Director\Data;
+
+use Icinga\Module\Director\Data\Db\DbObject;
+use Icinga\Module\Director\Objects\DirectorJob;
+use Icinga\Module\Director\Objects\ImportRowModifier;
+use Icinga\Module\Director\Objects\ImportSource;
+use Icinga\Module\Director\Objects\SyncRule;
+
+class ImportExportDeniedProperties
+{
+ protected static $denyProperties = [
+ DirectorJob::class => [
+ 'last_attempt_succeeded',
+ 'last_error_message',
+ 'ts_last_attempt',
+ 'ts_last_error',
+ ],
+ ImportSource::class => [
+ // No state export
+ 'import_state',
+ 'last_error_message',
+ 'last_attempt',
+ ],
+ ImportRowModifier::class => [
+ // Not state, but to be removed:
+ 'source_id',
+ ],
+ SyncRule::class => [
+ 'sync_state',
+ 'last_error_message',
+ 'last_attempt',
+ ],
+ ];
+
+ public static function strip(array &$props, DbObject $object, $showIds = false)
+ {
+ // TODO: this used to exist. Double-check all imports to verify it's not in use
+ // $originalId = $props['id'];
+
+ if (! $showIds) {
+ unset($props['id']);
+ }
+ $class = get_class($object);
+ if (isset(self::$denyProperties[$class])) {
+ foreach (self::$denyProperties[$class] as $key) {
+ unset($props[$key]);
+ }
+ }
+ }
+}
diff --git a/library/Director/Data/InvalidDataException.php b/library/Director/Data/InvalidDataException.php
new file mode 100644
index 0000000..9abaf7c
--- /dev/null
+++ b/library/Director/Data/InvalidDataException.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace Icinga\Module\Director\Data;
+
+use InvalidArgumentException;
+
+class InvalidDataException extends InvalidArgumentException
+{
+ /**
+ * @param string $expected
+ * @param mixed $value
+ */
+ public function __construct($expected, $value)
+ {
+ parent::__construct("$expected expected, got " . static::getPhpType($value));
+ }
+
+ public static function getPhpType($var)
+ {
+ if (is_object($var)) {
+ return get_class($var);
+ }
+
+ return gettype($var);
+ }
+}
diff --git a/library/Director/Data/Json.php b/library/Director/Data/Json.php
new file mode 100644
index 0000000..78b3e67
--- /dev/null
+++ b/library/Director/Data/Json.php
@@ -0,0 +1,69 @@
+<?php
+
+namespace Icinga\Module\Director\Data;
+
+use Icinga\Module\Director\Exception\JsonEncodeException;
+use function json_decode;
+use function json_encode;
+use function json_last_error;
+
+class Json
+{
+ const DEFAULT_FLAGS = JSON_PRESERVE_ZERO_FRACTION | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;
+
+ /**
+ * Encode with well-known flags, as we require the result to be reproducible
+ *
+ * @param $mixed
+ * @param int|null $flags
+ * @return string
+ * @throws JsonEncodeException
+ */
+ public static function encode($mixed, $flags = null)
+ {
+ if ($flags === null) {
+ $flags = self::DEFAULT_FLAGS;
+ } else {
+ $flags = self::DEFAULT_FLAGS | $flags;
+ }
+ $result = json_encode($mixed, $flags);
+
+ if ($result === false && json_last_error() !== JSON_ERROR_NONE) {
+ throw JsonEncodeException::forLastJsonError();
+ }
+
+ return $result;
+ }
+
+ /**
+ * Decode the given JSON string and make sure we get a meaningful Exception
+ *
+ * @param string $string
+ * @return mixed
+ * @throws JsonEncodeException
+ */
+ public static function decode($string)
+ {
+ $result = json_decode($string);
+
+ if ($result === null && json_last_error() !== JSON_ERROR_NONE) {
+ throw JsonEncodeException::forLastJsonError();
+ }
+
+ return $result;
+ }
+
+ /**
+ * @param $string
+ * @return ?string
+ * @throws JsonEncodeException
+ */
+ public static function decodeOptional($string)
+ {
+ if ($string === null) {
+ return null;
+ }
+
+ return static::decode($string);
+ }
+}
diff --git a/library/Director/Data/PropertiesFilter.php b/library/Director/Data/PropertiesFilter.php
new file mode 100644
index 0000000..a8c3906
--- /dev/null
+++ b/library/Director/Data/PropertiesFilter.php
@@ -0,0 +1,25 @@
+<?php
+
+namespace Icinga\Module\Director\Data;
+
+class PropertiesFilter
+{
+ public static $CUSTOM_PROPERTY = 'CUSTOM_PROPERTY';
+ public static $HOST_PROPERTY = 'HOST_PROPERTY';
+ public static $SERVICE_PROPERTY = 'SERVICE_PROPERTY';
+
+ protected $blacklist = array(
+ 'id',
+ 'object_name',
+ 'object_type',
+ 'disabled',
+ 'has_agent',
+ 'master_should_connect',
+ 'accept_config',
+ );
+
+ public function match($type, $name, $object = null)
+ {
+ return ($type != self::$HOST_PROPERTY || !in_array($name, $this->blacklist));
+ }
+}
diff --git a/library/Director/Data/PropertiesFilter/ArrayCustomVariablesFilter.php b/library/Director/Data/PropertiesFilter/ArrayCustomVariablesFilter.php
new file mode 100644
index 0000000..ef9f2d4
--- /dev/null
+++ b/library/Director/Data/PropertiesFilter/ArrayCustomVariablesFilter.php
@@ -0,0 +1,20 @@
+<?php
+
+namespace Icinga\Module\Director\Data\PropertiesFilter;
+
+class ArrayCustomVariablesFilter extends CustomVariablesFilter
+{
+ public function match($type, $name, $object = null)
+ {
+ return parent::match($type, $name, $object)
+ && $object !== null
+ && isset($object->datatype)
+ && (
+ preg_match('/DataTypeArray[\w]*$/', $object->datatype)
+ || (
+ preg_match('/DataTypeDatalist$/', $object->datatype)
+ && $object->format === 'json'
+ )
+ );
+ }
+}
diff --git a/library/Director/Data/PropertiesFilter/CustomVariablesFilter.php b/library/Director/Data/PropertiesFilter/CustomVariablesFilter.php
new file mode 100644
index 0000000..91ef9cd
--- /dev/null
+++ b/library/Director/Data/PropertiesFilter/CustomVariablesFilter.php
@@ -0,0 +1,13 @@
+<?php
+
+namespace Icinga\Module\Director\Data\PropertiesFilter;
+
+use Icinga\Module\Director\Data\PropertiesFilter;
+
+class CustomVariablesFilter extends PropertiesFilter
+{
+ public function match($type, $name, $object = null)
+ {
+ return parent::match($type, $name, $object) && $type === self::$CUSTOM_PROPERTY;
+ }
+}
diff --git a/library/Director/Data/PropertyMangler.php b/library/Director/Data/PropertyMangler.php
new file mode 100644
index 0000000..a457f1d
--- /dev/null
+++ b/library/Director/Data/PropertyMangler.php
@@ -0,0 +1,60 @@
+<?php
+
+namespace Icinga\Module\Director\Data;
+
+use Icinga\Module\Director\Objects\IcingaObject;
+use InvalidArgumentException;
+
+class PropertyMangler
+{
+ public static function appendToArrayProperties(IcingaObject $object, $properties)
+ {
+ foreach ($properties as $key => $value) {
+ $current = $object->$key;
+ if ($current === null) {
+ $current = [$value];
+ } elseif (is_array($current)) {
+ $current[] = $value;
+ } else {
+ throw new InvalidArgumentException(sprintf(
+ 'I can only append to arrays, %s is %s',
+ $key,
+ var_export($current, 1)
+ ));
+ }
+
+ $object->$key = $current;
+ }
+ }
+
+ public static function removeProperties(IcingaObject $object, $properties)
+ {
+ foreach ($properties as $key => $value) {
+ if ($value === true) {
+ $object->$key = null;
+ }
+ $current = $object->$key;
+ if ($current === null) {
+ continue;
+ } elseif (is_array($current)) {
+ $new = [];
+ foreach ($current as $item) {
+ if ($item !== $value) {
+ $new[] = $item;
+ }
+ }
+ $object->$key = $new;
+ } elseif (is_string($current)) {
+ if ($current === $value) {
+ $object->$key = null;
+ }
+ } else {
+ throw new InvalidArgumentException(sprintf(
+ 'I can only remove strings or from arrays, %s is %s',
+ $key,
+ var_export($current, 1)
+ ));
+ }
+ }
+ }
+}
diff --git a/library/Director/Data/RecursiveUtf8Validator.php b/library/Director/Data/RecursiveUtf8Validator.php
new file mode 100644
index 0000000..cadfc21
--- /dev/null
+++ b/library/Director/Data/RecursiveUtf8Validator.php
@@ -0,0 +1,59 @@
+<?php
+
+namespace Icinga\Module\Director\Data;
+
+use InvalidArgumentException;
+use ipl\Html\Error;
+
+class RecursiveUtf8Validator
+{
+ protected static $rowNum;
+
+ protected static $column;
+
+ /**
+ * @param array $rows Usually array of stdClass
+ * @return bool
+ */
+ public static function validateRows($rows)
+ {
+ foreach ($rows as self::$rowNum => $row) {
+ foreach ($row as self::$column => $value) {
+ static::assertUtf8($value);
+ }
+ }
+
+ return true;
+ }
+
+ protected static function assertUtf8($value)
+ {
+ if (\is_string($value)) {
+ static::assertUtf8String($value);
+ } elseif (\is_array($value) || $value instanceof \stdClass) {
+ foreach ((array) $value as $k => $v) {
+ static::assertUtf8($k);
+ static::assertUtf8($v);
+ }
+ } elseif ($value !== null && !\is_scalar($value)) {
+ throw new InvalidArgumentException("Cannot validate " . Error::getPhpTypeName($value));
+ }
+ }
+
+ protected static function assertUtf8String($string)
+ {
+ if (@\iconv('UTF-8', 'UTF-8', $string) != $string) {
+ $row = self::$rowNum;
+ if (is_int($row)) {
+ $row++;
+ }
+ throw new InvalidArgumentException(\sprintf(
+ 'Invalid UTF-8 on row %s, column %s: "%s" (%s)',
+ $row,
+ self::$column,
+ \iconv('UTF-8', 'UTF-8//IGNORE', $string),
+ '0x' . \bin2hex($string)
+ ));
+ }
+ }
+}
diff --git a/library/Director/Data/Serializable.php b/library/Director/Data/Serializable.php
new file mode 100644
index 0000000..9f8cb63
--- /dev/null
+++ b/library/Director/Data/Serializable.php
@@ -0,0 +1,10 @@
+<?php
+
+namespace Icinga\Module\Director\Data;
+
+use JsonSerializable;
+
+interface Serializable extends JsonSerializable
+{
+ public static function fromSerialization($value);
+}
diff --git a/library/Director/Data/SerializableValue.php b/library/Director/Data/SerializableValue.php
new file mode 100644
index 0000000..5784224
--- /dev/null
+++ b/library/Director/Data/SerializableValue.php
@@ -0,0 +1,90 @@
+<?php
+
+namespace Icinga\Module\Director\Data;
+
+use InvalidArgumentException;
+use JsonSerializable;
+use stdClass;
+use function get_class;
+use function gettype;
+use function is_array;
+use function is_object;
+use function is_scalar;
+
+class SerializableValue implements Serializable
+{
+ protected $value = [];
+
+ /**
+ * @param stdClass|array $object
+ * @return static
+ */
+ public static function fromSerialization($value)
+ {
+ $self = new static;
+ static::assertSerializableValue($value);
+ $self->value = $value;
+
+ return $self;
+ }
+
+ public static function wantSerializable($value)
+ {
+ if ($value instanceof SerializableValue) {
+ return $value;
+ }
+
+ return static::fromSerialization($value);
+ }
+
+ /**
+ * TODO: Check whether json_encode() is faster
+ *
+ * @param mixed $value
+ * @return bool
+ */
+ protected static function assertSerializableValue($value)
+ {
+ if ($value === null || is_scalar($value)) {
+ return true;
+ }
+ if (is_object($value)) {
+ if ($value instanceof JsonSerializable) {
+ return true;
+ }
+
+ if ($value instanceof stdClass) {
+ foreach ((array) $value as $val) {
+ static::assertSerializableValue($val);
+ }
+
+ return true;
+ }
+ }
+
+ if (is_array($value)) {
+ foreach ($value as $val) {
+ static::assertSerializableValue($val);
+ }
+
+ return true;
+ }
+
+ throw new InvalidArgumentException('Serializable value expected, got ' . static::getPhpType($value));
+ }
+
+ protected static function getPhpType($var)
+ {
+ if (is_object($var)) {
+ return get_class($var);
+ }
+
+ return gettype($var);
+ }
+
+ #[\ReturnTypeWillChange]
+ public function jsonSerialize()
+ {
+ return $this->value;
+ }
+}
diff --git a/library/Director/Data/ValueFilter.php b/library/Director/Data/ValueFilter.php
new file mode 100644
index 0000000..926214f
--- /dev/null
+++ b/library/Director/Data/ValueFilter.php
@@ -0,0 +1,10 @@
+<?php
+
+// TODO: move elsewhere, this is for forms
+namespace Icinga\Module\Director\Data;
+
+use Zend_Filter_Interface;
+
+interface ValueFilter extends Zend_Filter_Interface
+{
+}
diff --git a/library/Director/Data/ValueFilter/FilterBoolean.php b/library/Director/Data/ValueFilter/FilterBoolean.php
new file mode 100644
index 0000000..1fadec3
--- /dev/null
+++ b/library/Director/Data/ValueFilter/FilterBoolean.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace Icinga\Module\Director\Data\ValueFilter;
+
+use Icinga\Module\Director\Data\ValueFilter;
+
+class FilterBoolean implements ValueFilter
+{
+ public function filter($value)
+ {
+ if ($value === 'y' || $value === true) {
+ return true;
+ } elseif ($value === 'n' || $value === false) {
+ return false;
+ }
+
+ return null;
+ }
+}
diff --git a/library/Director/Data/ValueFilter/FilterInt.php b/library/Director/Data/ValueFilter/FilterInt.php
new file mode 100644
index 0000000..d51ce8d
--- /dev/null
+++ b/library/Director/Data/ValueFilter/FilterInt.php
@@ -0,0 +1,21 @@
+<?php
+
+namespace Icinga\Module\Director\Data\ValueFilter;
+
+use Icinga\Module\Director\Data\ValueFilter;
+
+class FilterInt implements ValueFilter
+{
+ public function filter($value)
+ {
+ if ($value === '' || $value === null) {
+ return null;
+ }
+
+ if (is_string($value) && ! ctype_digit($value)) {
+ return $value;
+ }
+
+ return (int) ((string) $value);
+ }
+}
diff --git a/library/Director/DataType/DataTypeArray.php b/library/Director/DataType/DataTypeArray.php
new file mode 100644
index 0000000..a7667d9
--- /dev/null
+++ b/library/Director/DataType/DataTypeArray.php
@@ -0,0 +1,14 @@
+<?php
+
+namespace Icinga\Module\Director\DataType;
+
+use Icinga\Module\Director\Hook\DataTypeHook;
+use Icinga\Module\Director\Web\Form\QuickForm;
+
+class DataTypeArray extends DataTypeHook
+{
+ public function getFormElement($name, QuickForm $form)
+ {
+ return $form->createElement('extensibleSet', $name);
+ }
+}
diff --git a/library/Director/DataType/DataTypeBoolean.php b/library/Director/DataType/DataTypeBoolean.php
new file mode 100644
index 0000000..d9edc60
--- /dev/null
+++ b/library/Director/DataType/DataTypeBoolean.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace Icinga\Module\Director\DataType;
+
+use Icinga\Module\Director\Hook\DataTypeHook;
+use Icinga\Module\Director\Web\Form\Decorator\ViewHelperRaw;
+use Icinga\Module\Director\Web\Form\QuickForm;
+use Zend_Form_Element as ZfElement;
+
+class DataTypeBoolean extends DataTypeHook
+{
+ public function getFormElement($name, QuickForm $form)
+ {
+ return $this->applyRawViewHelper(
+ $form->createElement('boolean', $name)
+ );
+ }
+
+ protected function applyRawViewHelper(ZfElement $element)
+ {
+ $vhClass = 'Zend_Form_Decorator_ViewHelper';
+ $decorators = $element->getDecorators();
+ if (array_key_exists($vhClass, $decorators)) {
+ $decorators[$vhClass] = new ViewHelperRaw;
+ $element->setDecorators($decorators);
+ }
+
+ return $element;
+ }
+}
diff --git a/library/Director/DataType/DataTypeDatalist.php b/library/Director/DataType/DataTypeDatalist.php
new file mode 100644
index 0000000..354c7c3
--- /dev/null
+++ b/library/Director/DataType/DataTypeDatalist.php
@@ -0,0 +1,159 @@
+<?php
+
+namespace Icinga\Module\Director\DataType;
+
+use Icinga\Module\Director\Acl;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Hook\DataTypeHook;
+use Icinga\Module\Director\Objects\DirectorDatalistEntry;
+use Icinga\Module\Director\Web\Form\DirectorForm;
+use Icinga\Module\Director\Web\Form\QuickForm;
+use Icinga\Module\Director\Web\Form\DirectorObjectForm;
+use Icinga\Module\Director\Web\Form\Validate\IsDataListEntry;
+
+class DataTypeDatalist extends DataTypeHook
+{
+ /**
+ * @param $name
+ * @param QuickForm $form
+ * @return \Zend_Form_Element
+ * @throws \Zend_Form_Exception
+ */
+ public function getFormElement($name, QuickForm $form)
+ {
+ $params = [];
+ $behavior = $this->getSetting('behavior', 'strict');
+ $targetDataType = $this->getSetting('data_type', 'string');
+ $listId = $this->getSetting('datalist_id');
+
+ if ($behavior === 'strict') {
+ $enum = $this->getEntries($form);
+ if ($targetDataType === 'string') {
+ $params['sorted'] = true;
+ $params = ['multiOptions' => $form->optionalEnum($enum)];
+ $type = 'select';
+ } else {
+ $params = ['multiOptions' => $form->optionalEnum($enum)];
+ $type = 'extensibleSet';
+ }
+ } else {
+ if ($targetDataType === 'string') {
+ $type = 'text';
+ } else {
+ $type = 'extensibleSet';
+ }
+ $params['class'] = 'director-suggest';
+ $params['data-suggestion-context'] = "dataListValuesForListId!$listId";
+ }
+ $element = $form->createElement($type, $name, $params);
+ if ($behavior === 'suggest_strict') {
+ $element->addValidator(new IsDataListEntry($listId, $form->getDb()));
+ }
+
+ if ($behavior === 'suggest_extend') {
+ $form->callOnSuccess(function (DirectorForm $form) use ($name, $listId) {
+ $value = (array) $form->getValue($name);
+ if ($value === null) {
+ return;
+ }
+
+ $db = $form->getDb();
+ foreach ($value as $entry) {
+ if ($entry !== '') {
+ $this->createEntryIfNotExists($db, $listId, $entry);
+ }
+ }
+ });
+ }
+
+ return $element;
+ }
+
+ /**
+ * @param Db $db
+ * @param $listId
+ * @param $entry
+ */
+ protected function createEntryIfNotExists(Db $db, $listId, $entry)
+ {
+ if (! DirectorDatalistEntry::exists([
+ 'list_id' => $listId,
+ 'entry_name' => $entry,
+ ], $db)) {
+ DirectorDatalistEntry::create([
+ 'list_id' => $listId,
+ 'entry_name' => $entry,
+ 'entry_value' => $entry,
+ ])->store($db);
+ }
+ }
+
+ protected function getEntries(QuickForm $form)
+ {
+ /** @var DirectorObjectForm $form */
+ $db = $form->getDb()->getDbAdapter();
+ $roles = Acl::instance()->listRoleNames();
+ $select = $db->select()
+ ->from('director_datalist_entry', ['entry_name', 'entry_value'])
+ ->where('list_id = ?', $this->getSetting('datalist_id'))
+ ->order('entry_value ASC');
+
+ if (empty($roles)) {
+ $select->where('allowed_roles IS NULL');
+ } else {
+ $parts = ['allowed_roles IS NULL'];
+ foreach ($roles as $role) {
+ $parts[] = $db->quoteInto("allowed_roles LIKE ?", '%' . \json_encode($role) . '%');
+ }
+ $select->where('(' . \implode(' OR ', $parts) . ')');
+ }
+
+ return $db->fetchPairs($select);
+ }
+
+ /**
+ * @param QuickForm $form
+ * @throws \Zend_Form_Exception
+ */
+ public static function addSettingsFormFields(QuickForm $form)
+ {
+ /** @var DirectorObjectForm $form */
+ $db = $form->getDb();
+
+ $form->addElement('select', 'datalist_id', [
+ 'label' => 'List name',
+ 'required' => true,
+ 'multiOptions' => $form->optionalEnum($db->enumDatalist()),
+ ]);
+
+ $form->addElement('select', 'data_type', [
+ 'label' => $form->translate('Target data type'),
+ 'multiOptions' => $form->optionalEnum([
+ 'string' => $form->translate('String'),
+ 'array' => $form->translate('Array'),
+ ]),
+ 'description' => $form->translate(
+ 'Whether this should be a String or an Array in the generated'
+ . ' Icinga configuration. In case you opt for Array, Director'
+ . ' users will be able to select multiple elements from the list'
+ ),
+ 'required' => true,
+ ]);
+
+ $form->addElement('select', 'behavior', [
+ 'label' => $form->translate('Element behavior'),
+ 'value' => 'strict',
+ 'description' => $form->translate(
+ 'This allows to show either a drop-down list or an auto-completion'
+ ),
+ 'multiOptions' => [
+ 'strict' => $form->translate('Dropdown (list values only)'),
+ $form->translate('Autocomplete') => [
+ 'suggest_strict' => $form->translate('Strict, list values only'),
+ 'suggest_optional' => $form->translate('Allow for values not on the list'),
+ 'suggest_extend' => $form->translate('Extend the list with new values'),
+ ]
+ ]
+ ]);
+ }
+}
diff --git a/library/Director/DataType/DataTypeDictionary.php b/library/Director/DataType/DataTypeDictionary.php
new file mode 100644
index 0000000..7880698
--- /dev/null
+++ b/library/Director/DataType/DataTypeDictionary.php
@@ -0,0 +1,107 @@
+<?php
+
+namespace Icinga\Module\Director\DataType;
+
+use Icinga\Module\Director\Hook\DataTypeHook;
+use Icinga\Module\Director\Objects\IcingaHost;
+use Icinga\Module\Director\Objects\IcingaService;
+use Icinga\Module\Director\Web\Form\DirectorObjectForm;
+use Icinga\Module\Director\Web\Form\QuickForm;
+use InvalidArgumentException;
+use ipl\Html\Html;
+use RuntimeException;
+
+class DataTypeDictionary extends DataTypeHook
+{
+ public function getFormElement($name, QuickForm $form)
+ {
+ if (strpos($name, 'var_') !== 0) {
+ throw new InvalidArgumentException(
+ "'$name' is not a valid candidate for a Nested Dictionary, 'var_*' expected"
+ );
+ }
+ /** @var DirectorObjectForm $form */
+ $object = $form->getObject();
+ if ($form->isTemplate()) {
+ return $form->createElement('simpleNote', $name, [
+ 'ignore' => true,
+ 'value' => Html::tag('span', $form->translate('To be managed on objects only')),
+ ]);
+ }
+ if (! $object->hasBeenLoadedFromDb()) {
+ return $form->createElement('simpleNote', $name, [
+ 'ignore' => true,
+ 'value' => Html::tag(
+ 'span',
+ $form->translate('Can be managed once this object has been created')
+ ),
+ ]);
+ }
+ $params = [
+ 'varname' => substr($name, 4),
+ ];
+ if ($object instanceof IcingaHost) {
+ $params['host'] = $object->getObjectName();
+ } elseif ($object instanceof IcingaService) {
+ $params['host'] = $object->get('host');
+ $params['service'] = $object->getObjectName();
+ }
+ return $form->createElement('InstanceSummary', $name, [
+ 'linkParams' => $params
+ ]);
+ }
+
+ public static function addSettingsFormFields(QuickForm $form)
+ {
+ /** @var DirectorObjectForm $form */
+ $db = $form->getDb()->getDbAdapter();
+ $enum = [
+ 'host' => $form->translate('Hosts'),
+ 'service' => $form->translate('Services'),
+ ];
+
+ $form->addElement('select', 'template_object_type', [
+ 'label' => $form->translate('Template (Object) Type'),
+ 'description' => $form->translate(
+ 'Please choose a specific Icinga object type'
+ ),
+ 'class' => 'autosubmit',
+ 'required' => true,
+ 'multiOptions' => $form->optionalEnum($enum),
+ 'sorted' => true,
+ ]);
+
+ // There should be a helper method for this
+ if ($form->hasBeenSent()) {
+ $type = $form->getSentOrObjectValue('template_object_type');
+ } else {
+ $type = $form->getObject()->getSetting('template_object_type');
+ }
+ if (empty($type)) {
+ return $form;
+ }
+
+ if (array_key_exists($type, $enum)) {
+ $form->addElement('select', 'template_name', [
+ 'label' => $form->translate('Template'),
+ 'multiOptions' => $form->optionalEnum(self::fetchTemplateNames($db, $type)),
+ 'required' => true,
+ ]);
+ } else {
+ throw new RuntimeException("$type is not a valid Dictionary object type");
+ }
+
+ return $form;
+ }
+
+ protected static function fetchTemplateNames($db, $type)
+ {
+ $query = $db->select()
+ ->from("icinga_$type", ['a' => 'object_name', 'b' => 'object_name'])
+ ->where('object_type = ?', 'template')
+ ->where('template_choice_id IS NULL')
+ ->order('object_name');
+
+ return $db->fetchPairs($query);
+ }
+}
diff --git a/library/Director/DataType/DataTypeDirectorObject.php b/library/Director/DataType/DataTypeDirectorObject.php
new file mode 100644
index 0000000..7f313e0
--- /dev/null
+++ b/library/Director/DataType/DataTypeDirectorObject.php
@@ -0,0 +1,87 @@
+<?php
+
+namespace Icinga\Module\Director\DataType;
+
+use Icinga\Module\Director\Hook\DataTypeHook;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Icinga\Module\Director\Web\Form\DirectorObjectForm;
+use Icinga\Module\Director\Web\Form\QuickForm;
+
+class DataTypeDirectorObject extends DataTypeHook
+{
+ public function getFormElement($name, QuickForm $form)
+ {
+ /** @var DirectorObjectForm $form */
+ $db = $form->getDb()->getDbAdapter();
+
+ $type = $this->getSetting('icinga_object_type');
+ $dummy = IcingaObject::createByType($type);
+
+ $display = in_array($type, ['service_set', 'notification'])
+ ? 'object_name'
+ : 'COALESCE(display_name, object_name)';
+ $query = $db->select()->from($dummy->getTableName(), [
+ 'object_name' => 'object_name',
+ 'display_name' => $display
+ ])->order($display);
+
+ if ($type === 'service_set') {
+ $query->where('host_id IS NULL');
+ } elseif ($type === 'notification') {
+ $query->where('object_type = ?', 'apply');
+ } else {
+ $query->where('object_type = ?', 'object');
+ }
+
+ $enum = $db->fetchPairs($query);
+
+ $params = [];
+ if ($this->getSetting('data_type') === 'array') {
+ $elementType = $type === 'notification' ? 'select' : 'extensibleSet';
+ $params['sorted'] = true;
+ $params = ['multiOptions' => $enum];
+ } else {
+ $params = ['multiOptions' => [
+ null => $form->translate('- please choose -'),
+ ] + $enum];
+ $elementType = 'select';
+ }
+
+ return $form->createElement($elementType, $name, $params);
+ }
+
+ public static function addSettingsFormFields(QuickForm $form)
+ {
+ $enum = [
+ 'host' => $form->translate('Hosts'),
+ 'hostgroup' => $form->translate('Host groups'),
+ 'notification' => $form->translate('Notification Apply Rules'),
+ 'service' => $form->translate('Services'),
+ 'servicegroup' => $form->translate('Service groups'),
+ 'service_set' => $form->translate('Service Set'),
+ 'user' => $form->translate('Users'),
+ 'usergroup' => $form->translate('User groups'),
+ ];
+
+ $form->addElement('select', 'icinga_object_type', [
+ 'label' => $form->translate('Object'),
+ 'description' => $form->translate(
+ 'Please choose a specific Icinga object type'
+ ),
+ 'required' => true,
+ 'multiOptions' => $form->optionalEnum($enum),
+ 'sorted' => true,
+ ]);
+
+ $form->addElement('select', 'data_type', [
+ 'label' => $form->translate('Target data type'),
+ 'multiOptions' => $form->optionalEnum([
+ 'string' => $form->translate('String'),
+ 'array' => $form->translate('Array'),
+ ]),
+ 'required' => true,
+ ]);
+
+ return $form;
+ }
+}
diff --git a/library/Director/DataType/DataTypeNumber.php b/library/Director/DataType/DataTypeNumber.php
new file mode 100644
index 0000000..cd47f88
--- /dev/null
+++ b/library/Director/DataType/DataTypeNumber.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace Icinga\Module\Director\DataType;
+
+use Icinga\Module\Director\Hook\DataTypeHook;
+use Icinga\Module\Director\Web\Form\QuickForm;
+use Icinga\Module\Director\Data\ValueFilter\FilterInt;
+
+class DataTypeNumber extends DataTypeHook
+{
+ public function getFormElement($name, QuickForm $form)
+ {
+ $element = $form->createElement('text', $name)
+ ->addValidator('int')
+ ->addFilter(new FilterInt);
+
+ return $element;
+ }
+}
diff --git a/library/Director/DataType/DataTypeSqlQuery.php b/library/Director/DataType/DataTypeSqlQuery.php
new file mode 100644
index 0000000..07e7418
--- /dev/null
+++ b/library/Director/DataType/DataTypeSqlQuery.php
@@ -0,0 +1,96 @@
+<?php
+
+namespace Icinga\Module\Director\DataType;
+
+use Exception;
+use Icinga\Data\Db\DbConnection;
+use Icinga\Module\Director\Hook\DataTypeHook;
+use Icinga\Module\Director\Web\Form\QuickForm;
+use Icinga\Module\Director\Util;
+
+class DataTypeSqlQuery extends DataTypeHook
+{
+ /** @var \Zend_Db_Adapter_Pdo_Abstract */
+ protected $db;
+
+ protected static $cachedResult;
+
+ protected static $cacheTime = 0;
+
+ public function getFormElement($name, QuickForm $form)
+ {
+ try {
+ $data = $this->fetchData();
+ $error = false;
+ } catch (Exception $e) {
+ $data = array();
+ $error = sprintf($form->translate('Unable to fetch data: %s'), $e->getMessage());
+ }
+
+ $params = [];
+ if ($this->getSetting('data_type') === 'array') {
+ $type = 'extensibleSet';
+ $params['sorted'] = true;
+ $params = ['multiOptions' => $data];
+ } else {
+ $params = ['multiOptions' => [
+ null => $form->translate('- please choose -'),
+ ] + $data];
+ $type = 'select';
+ }
+
+ $element = $form->createElement($type, $name, $params);
+
+ if ($error) {
+ $element->addError($error);
+ }
+
+ return $element;
+ }
+
+ protected function fetchData()
+ {
+ // TODO: Hash _:)
+ //if (self::$cachedResult === null || (time() - self::$cacheTime > 3)) {
+ self::$cachedResult = $this->db()->fetchPairs($this->settings['query']);
+ self::$cacheTime = time();
+ // }
+
+ return self::$cachedResult;
+ }
+
+ public static function addSettingsFormFields(QuickForm $form)
+ {
+ Util::addDbResourceFormElement($form, 'resource');
+
+ $form->addElement('textarea', 'query', array(
+ 'label' => 'DB Query',
+ 'description' => 'This query should return exactly two columns, value and label',
+ 'required' => true,
+ 'rows' => 10,
+ ));
+
+ $form->addElement('select', 'data_type', [
+ 'label' => $form->translate('Target data type'),
+ 'multiOptions' => $form->optionalEnum([
+ 'string' => $form->translate('String'),
+ 'array' => $form->translate('Array'),
+ ]),
+ 'value' => 'string',
+ 'required' => true,
+ ]);
+
+ return $form;
+ }
+
+ protected function db()
+ {
+ if ($this->db === null) {
+ $this->db = DbConnection::fromResourceName($this->settings['resource'])->getDbAdapter();
+ // TODO: should be handled by resources:
+ $this->db->exec("SET NAMES 'utf8'");
+ }
+
+ return $this->db;
+ }
+}
diff --git a/library/Director/DataType/DataTypeString.php b/library/Director/DataType/DataTypeString.php
new file mode 100644
index 0000000..a2dc196
--- /dev/null
+++ b/library/Director/DataType/DataTypeString.php
@@ -0,0 +1,35 @@
+<?php
+
+namespace Icinga\Module\Director\DataType;
+
+use Icinga\Module\Director\Hook\DataTypeHook;
+use Icinga\Module\Director\Web\Form\QuickForm;
+
+class DataTypeString extends DataTypeHook
+{
+ public function getFormElement($name, QuickForm $form)
+ {
+ if ($this->getSetting('visibility', 'visible') === 'visible') {
+ $element = $form->createElement('text', $name);
+ } else {
+ $element = $form->createElement('storedPassword', $name);
+ }
+
+ return $element;
+ }
+
+ public static function addSettingsFormFields(QuickForm $form)
+ {
+ $form->addElement('select', 'visibility', [
+ 'label' => $form->translate('Visibility'),
+ 'multiOptions' => $form->optionalEnum([
+ 'visible' => $form->translate('Visible'),
+ 'hidden' => $form->translate('Hidden'),
+ ]),
+ 'value' => 'visible',
+ 'required' => true,
+ ]);
+
+ return $form;
+ }
+}
diff --git a/library/Director/DataType/DataTypeTime.php b/library/Director/DataType/DataTypeTime.php
new file mode 100644
index 0000000..13b9635
--- /dev/null
+++ b/library/Director/DataType/DataTypeTime.php
@@ -0,0 +1,16 @@
+<?php
+
+namespace Icinga\Module\Director\DataType;
+
+use Icinga\Module\Director\Hook\DataTypeHook;
+use Icinga\Module\Director\Web\Form\QuickForm;
+
+class DataTypeTime extends DataTypeHook
+{
+ public function getFormElement($name, QuickForm $form)
+ {
+ $element = $form->createElement('text', $name);
+
+ return $element;
+ }
+}
diff --git a/library/Director/Db.php b/library/Director/Db.php
new file mode 100644
index 0000000..2a8d6c0
--- /dev/null
+++ b/library/Director/Db.php
@@ -0,0 +1,755 @@
+<?php
+
+namespace Icinga\Module\Director;
+
+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 Zend_Db_Select;
+
+class Db extends DbConnection
+{
+ /** @var Settings */
+ protected $settings;
+
+ /** @var string */
+ protected $masterZoneName;
+
+ protected function db()
+ {
+ return $this->getDbAdapter();
+ }
+
+ /**
+ * @param $callable
+ * @return $this
+ * @throws Exception
+ */
+ public function runFailSafeTransaction($callable)
+ {
+ if (! is_callable($callable)) {
+ throw new RuntimeException(__METHOD__ . ' needs a Callable');
+ }
+
+ $db = $this->db();
+ $db->beginTransaction();
+ try {
+ $callable();
+ $db->commit();
+ } catch (Exception $e) {
+ try {
+ $db->rollback();
+ } catch (Exception $e) {
+ // Well... there is nothing we can do here.
+ }
+ throw $e;
+ }
+
+ return $this;
+ }
+
+ public static function fromResourceName($name)
+ {
+ $connection = new static(ResourceFactory::getResourceConfig($name));
+
+ if ($connection->isMysql()) {
+ $connection->setClientTimezoneForMysql();
+ } elseif ($connection->isPgsql()) {
+ $connection->setClientTimezoneForPgsql();
+ }
+
+ return $connection;
+ }
+
+ protected function getTimezoneOffset()
+ {
+ $tz = new DateTimeZone(date_default_timezone_get());
+ $offset = $tz->getOffset(new DateTime());
+ $prefix = $offset >= 0 ? '+' : '-';
+ $offset = abs($offset);
+
+ $hours = (int) floor($offset / 3600);
+ $minutes = (int) floor(($offset % 3600) / 60);
+
+ return sprintf('%s%d:%02d', $prefix, $hours, $minutes);
+ }
+
+ protected function setClientTimezoneForMysql()
+ {
+ $db = $this->getDbAdapter();
+ $db->query($db->quoteInto('SET time_zone = ?', $this->getTimezoneOffset()));
+ }
+
+ protected function setClientTimezoneForPgsql()
+ {
+ $db = $this->getDbAdapter();
+ $db->query($db->quoteInto('SET TIME ZONE INTERVAL ? HOUR TO MINUTE', $this->getTimezoneOffset()));
+ }
+
+ public function countActivitiesSinceLastDeployedConfig(IcingaObject $object = null)
+ {
+ $db = $this->db();
+
+ $query = 'SELECT COUNT(*) FROM director_activity_log WHERE id > COALESCE(('
+ . ' SELECT id FROM director_activity_log WHERE checksum = ('
+ . ' SELECT last_activity_checksum FROM director_generated_config WHERE checksum = ('
+ . ' SELECT config_checksum FROM director_deployment_log ORDER by id desc limit 1'
+ . ' )'
+ . ' )'
+ . '), 0)';
+
+ if ($object !== null) {
+ $query .= $db->quoteInto(' AND object_type = ?', $object->getTableName());
+ $query .= $db->quoteInto(' AND object_name = ?', $object->getObjectName());
+ }
+ return (int) $db->fetchOne($query);
+ }
+
+ // TODO: use running config?!
+ public function getLastDeploymentActivityLogId()
+ {
+ $db = $this->db();
+
+ $query = ' SELECT COALESCE(id, 0) AS id FROM director_activity_log WHERE checksum = ('
+ . ' SELECT last_activity_checksum FROM director_generated_config WHERE checksum = ('
+ . ' SELECT config_checksum FROM director_deployment_log ORDER by id desc limit 1'
+ . ' )'
+ . ')';
+
+ return (int) $db->fetchOne($query);
+ }
+
+ public function settings()
+ {
+ if ($this->settings === null) {
+ $this->settings = new Settings($this);
+ }
+
+ return $this->settings;
+ }
+
+ public function getMasterZoneName()
+ {
+ if ($this->masterZoneName === null) {
+ $this->masterZoneName = $this->detectMasterZoneName();
+ }
+
+ return $this->masterZoneName;
+ }
+
+ protected function detectMasterZoneName()
+ {
+ if ($zone = $this->settings()->master_zone) {
+ return $zone;
+ }
+
+ $db = $this->db();
+ $query = $db->select()
+ ->from('icinga_zone', 'object_name')
+ ->where('parent_id IS NULL')
+ ->where('is_global = ?', 'n');
+
+ $zones = $db->fetchCol($query);
+
+ if (count($zones) === 1) {
+ return $zones[0];
+ }
+
+ return 'master';
+ }
+
+ public function getDefaultGlobalZoneName()
+ {
+ return $this->settings()->default_global_zone;
+ }
+
+ public function hasDeploymentEndpoint()
+ {
+ $db = $this->db();
+ $query = $db->select()->from(
+ array('z' => 'icinga_zone'),
+ array('cnt' => 'COUNT(*)')
+ )->join(
+ array('e' => 'icinga_endpoint'),
+ 'e.zone_id = z.id',
+ array()
+ )->join(
+ array('au' => 'icinga_apiuser'),
+ 'e.apiuser_id = au.id',
+ array()
+ )->where('z.object_name = ?', $this->getMasterZoneName());
+
+ return $db->fetchOne($query) > 0;
+ }
+
+ public function getEndpointNamesInDeploymentZone()
+ {
+ $db = $this->db();
+ $query = $db->select()->from(
+ array('z' => 'icinga_zone'),
+ array('object_name' => 'e.object_name')
+ )->join(
+ array('e' => 'icinga_endpoint'),
+ 'e.zone_id = z.id',
+ array()
+ )->join(
+ array('au' => 'icinga_apiuser'),
+ 'e.apiuser_id = au.id',
+ array()
+ )->where('z.object_name = ?', $this->getMasterZoneName())
+ ->order('e.object_name ASC');
+
+ return $db->fetchCol($query) ?: [];
+ }
+
+ public function getDeploymentEndpointName()
+ {
+ $db = $this->db();
+ $query = $db->select()->from(
+ array('z' => 'icinga_zone'),
+ array('object_name' => 'e.object_name')
+ )->join(
+ array('e' => 'icinga_endpoint'),
+ 'e.zone_id = z.id',
+ array()
+ )->join(
+ array('au' => 'icinga_apiuser'),
+ 'e.apiuser_id = au.id',
+ array()
+ )->where('z.object_name = ?', $this->getMasterZoneName())
+ ->order('e.object_name ASC')
+ ->limit(1);
+
+ $name = $db->fetchOne($query);
+
+ if (! $name) {
+ throw new ConfigurationError(
+ 'Unable to detect your deployment endpoint. I was looking for'
+ . ' the first endpoint configured with an assigned API user'
+ . ' in the "%s" zone.',
+ $this->getMasterZoneName()
+ );
+ }
+
+ return $name;
+ }
+
+ /**
+ * @return IcingaEndpoint
+ */
+ public function getDeploymentEndpoint()
+ {
+ return IcingaEndpoint::load($this->getDeploymentEndpointName(), $this);
+ }
+
+ public function getActivitylogNeighbors($id, $type = null, $name = null)
+ {
+ $db = $this->db();
+
+ $greater = $db->select()->from(
+ array('g' => 'director_activity_log'),
+ array('id' => 'MIN(g.id)')
+ )->where('id > ?', (int) $id);
+
+ $smaller = $db->select()->from(
+ array('l' => 'director_activity_log'),
+ array('id' => 'MAX(l.id)')
+ )->where('id < ?', (int) $id);
+
+ if ($type !== null) {
+ $greater->where('object_type = ?', $type);
+ $smaller->where('object_type = ?', $type);
+ }
+
+ if ($name !== null) {
+ $greater->where('object_name = ?', $name);
+ $smaller->where('object_name = ?', $name);
+ }
+
+ $query = $db->select()->from(
+ array('gt' => $greater),
+ array(
+ 'prev' => 'lt.id',
+ 'next' => 'gt.id'
+ )
+ )->join(
+ array('lt' => $smaller),
+ '1 = 1',
+ array()
+ );
+
+ return $db->fetchRow($query);
+ }
+
+ public function fetchActivityLogEntryById($id)
+ {
+ $sql = 'SELECT id, object_type, object_name, action_name,'
+ . ' old_properties, new_properties, author, change_time,'
+ . ' UNIX_TIMESTAMP(change_time) AS change_time_ts,'
+ . ' %s AS checksum, %s AS parent_checksum'
+ . ' FROM director_activity_log WHERE id = %d';
+
+ $sql = sprintf(
+ $sql,
+ $this->dbHexFunc('checksum'),
+ $this->dbHexFunc('parent_checksum'),
+ $id
+ );
+
+ return $this->db()->fetchRow($sql);
+ }
+
+ public function fetchActivityLogChecksumById($id, $binary = true)
+ {
+ $sql = sprintf(
+ 'SELECT' . ' %s AS checksum FROM director_activity_log WHERE id = %d',
+ $this->dbHexFunc('checksum'),
+ (int) $id
+ );
+
+ $result = $this->db()->fetchOne($sql);
+
+ if ($binary) {
+ return hex2bin($result);
+ } else {
+ return $result;
+ }
+ }
+
+ public function fetchActivityLogIdByChecksum($checksum)
+ {
+ $sql = 'SELECT id FROM director_activity_log WHERE checksum = ?';
+ return $this->db()->fetchOne(
+ $this->db()->quoteInto($sql, $this->quoteBinary($checksum))
+ );
+ }
+
+ public function fetchActivityLogEntry($checksum)
+ {
+ $db = $this->db();
+
+ $sql = 'SELECT id, object_type, object_name, action_name,'
+ . ' old_properties, new_properties, author, change_time,'
+ . ' UNIX_TIMESTAMP(change_time) AS change_time_ts,'
+ . ' %s AS checksum, %s AS parent_checksum'
+ . ' FROM director_activity_log WHERE checksum = ?';
+
+ $sql = sprintf(
+ $sql,
+ $this->dbHexFunc('checksum'),
+ $this->dbHexFunc('parent_checksum')
+ );
+
+ return $db->fetchRow(
+ $db->quoteInto($sql, $this->quoteBinary(hex2bin($checksum)))
+ );
+ }
+
+ public function getLastActivityChecksum()
+ {
+ $select = "SELECT checksum FROM (SELECT * FROM (SELECT 1 AS pos, "
+ . $this->dbHexFunc('checksum')
+ . " AS checksum"
+ . " FROM director_activity_log ORDER BY id DESC LIMIT 1) a"
+ . " UNION SELECT 2 AS pos, '' AS checksum) u ORDER BY pos LIMIT 1";
+
+ return $this->db()->fetchOne($select);
+ }
+
+ public function fetchImportStatistics()
+ {
+ $query = "SELECT 'imported_properties' AS stat_name, COUNT(*) AS stat_value"
+ . " FROM import_run i"
+ . " JOIN imported_rowset_row rs ON i.rowset_checksum = rs.rowset_checksum"
+ . " JOIN imported_row_property rp ON rp.row_checksum = rs.row_checksum"
+ . " UNION ALL"
+ . " SELECT 'imported_rows' AS stat_name, COUNT(*) AS stat_value"
+ . " FROM import_run i"
+ . " JOIN imported_rowset_row rs ON i.rowset_checksum = rs.rowset_checksum"
+ . " UNION ALL"
+ . " SELECT 'unique_rows' AS stat_name, COUNT(*) AS stat_value"
+ . " FROM imported_row"
+ . " UNION ALL"
+ . " SELECT 'unique_properties' AS stat_name, COUNT(*) AS stat_value"
+ . " FROM imported_property"
+ ;
+ return $this->db()->fetchPairs($query);
+ }
+
+ public function getImportrunRowsetChecksum($id)
+ {
+ $db = $this->db();
+ $query = $db->select()
+ ->from(array('r' => 'import_run'), $this->dbHexFunc('r.rowset_checksum'))
+ ->where('r.id = ?', $id);
+
+ return $db->fetchOne($query);
+ }
+
+ protected function fetchTemplateRelations($type)
+ {
+ $db = $this->db();
+ $query = $db->select()->from(
+ array('p' => 'icinga_' . $type),
+ array(
+ 'name' => 'o.object_name',
+ 'parent' => 'p.object_name'
+ )
+ )->join(
+ array('i' => 'icinga_' . $type . '_inheritance'),
+ 'p.id = i.parent_' . $type . '_id',
+ array()
+ )->join(
+ array('o' => 'icinga_' . $type),
+ 'o.id = i.' . $type . '_id',
+ array()
+ )->where("o.object_type = 'template'")
+ ->order('p.object_name')
+ ->order('o.object_name');
+
+ return $db->fetchAll($query);
+ }
+
+ public function fetchTemplateTree($type)
+ {
+ $relations = $this->fetchTemplateRelations($type);
+ $children = array();
+ $objects = array();
+ foreach ($relations as $rel) {
+ foreach (array('name', 'parent') as $col) {
+ if (! array_key_exists($rel->$col, $objects)) {
+ $objects[$rel->$col] = (object) array(
+ 'name' => $rel->$col,
+ 'children' => array()
+ );
+ }
+ }
+ }
+
+ foreach ($relations as $rel) {
+ $objects[$rel->parent]->children[$rel->name] = $objects[$rel->name];
+ $children[$rel->name] = $rel->parent;
+ }
+
+ foreach ($children as $name => $object) {
+ unset($objects[$name]);
+ }
+
+ return $objects;
+ }
+
+ public function getLatestImportedChecksum($source)
+ {
+ $db = $this->db();
+ $lastRun = $db->select()->from(
+ array('r' => 'import_run'),
+ array('last_checksum' => $this->dbHexFunc('r.rowset_checksum'))
+ );
+
+ if (is_int($source) || ctype_digit($source)) {
+ $lastRun->where('source_id = ?', (int) $source);
+ } else {
+ $lastRun->where('source_name = ?', $source);
+ }
+
+ $lastRun->order('start_time DESC')->limit(1);
+ return $db->fetchOne($lastRun);
+ }
+
+ public function getObjectSummary()
+ {
+ $types = array(
+ 'host',
+ 'hostgroup',
+ 'service',
+ 'servicegroup',
+ 'user',
+ 'usergroup',
+ 'command',
+ 'timeperiod',
+ 'scheduled_downtime',
+ 'notification',
+ 'apiuser',
+ 'endpoint',
+ 'zone',
+ 'dependency',
+ );
+
+ $queries = array();
+ $db = $this->db();
+ $cnt = "COALESCE(SUM(CASE WHEN o.object_type = '%s' THEN 1 ELSE 0 END), 0)";
+
+ foreach ($types as $type) {
+ $queries[] = $db->select()->from(
+ array('o' => 'icinga_' . $type),
+ array(
+ 'icinga_type' => "('" . $type . "')",
+ 'cnt_object' => sprintf($cnt, 'object'),
+ 'cnt_template' => sprintf($cnt, 'template'),
+ 'cnt_external' => sprintf($cnt, 'external_object'),
+ 'cnt_total' => 'COUNT(*)',
+ )
+ );
+ }
+
+ $query = $this->db()->select()->union($queries, Zend_Db_Select::SQL_UNION_ALL);
+
+ $result = array();
+
+ foreach ($db->fetchAll($query) as $row) {
+ $result[$row->icinga_type] = $row;
+ }
+
+ return $result;
+ }
+
+ public function enumCommands()
+ {
+ return $this->enumIcingaObjects('command');
+ }
+
+ public function enumCommandTemplates()
+ {
+ return $this->enumIcingaTemplates('command');
+ }
+
+ public function enumTimeperiods()
+ {
+ return $this->enumIcingaObjects('timeperiod');
+ }
+
+ public function enumCheckcommands()
+ {
+ $filters = array(
+ 'methods_execute IN (?)' => array('PluginCheck', 'IcingaCheck'),
+
+ );
+ return $this->enumIcingaObjects('command', $filters);
+ }
+
+ public function enumEventcommands()
+ {
+ $filters = array(
+ 'methods_execute = ?' => 'PluginEvent',
+
+ );
+ return $this->enumIcingaObjects('command', $filters);
+ }
+
+ public function enumNotificationCommands()
+ {
+ $filters = array(
+ 'methods_execute IN (?)' => array('PluginNotification'),
+ );
+ return $this->enumIcingaObjects('command', $filters);
+ }
+
+ public function getZoneName($id)
+ {
+ $objects = $this->enumZones();
+ return $objects[$id];
+ }
+
+ public function getCommandName($id)
+ {
+ $objects = $this->enumCommands();
+ return $objects[$id];
+ }
+
+ public function enumZones()
+ {
+ return $this->enumIcingaObjects('zone');
+ }
+
+ public function enumNonglobalZones()
+ {
+ $filters = array('is_global = ?' => 'n');
+ return $this->enumIcingaObjects('zone', $filters);
+ }
+
+ public function enumZoneTemplates()
+ {
+ return $this->enumIcingaTemplates('zone');
+ }
+
+ public function enumHosts()
+ {
+ return $this->enumIcingaObjects('host');
+ }
+
+ public function enumHostTemplates()
+ {
+ return $this->enumIcingaTemplates('host');
+ }
+
+ public function enumHostgroups()
+ {
+ return $this->enumIcingaObjects('hostgroup');
+ }
+
+ public function enumServices()
+ {
+ return $this->enumIcingaObjects('service');
+ }
+
+ public function enumServiceTemplates()
+ {
+ return $this->enumIcingaTemplates('service');
+ }
+
+ public function enumServicegroups()
+ {
+ return $this->enumIcingaObjects('servicegroup');
+ }
+
+ public function enumUsers()
+ {
+ return $this->enumIcingaObjects('user');
+ }
+
+ public function enumUserTemplates()
+ {
+ return $this->enumIcingaTemplates('user');
+ }
+
+ public function enumUsergroups()
+ {
+ return $this->enumIcingaObjects('usergroup');
+ }
+
+ public function enumApiUsers()
+ {
+ return $this->enumIcingaObjects('apiuser');
+ }
+
+ public function enumSyncRule()
+ {
+ return $this->enum('sync_rule', array('id', 'rule_name'));
+ }
+
+ public function enumImportSource()
+ {
+ return $this->enum('import_source', array('id', 'source_name'));
+ }
+
+ public function enumDatalist()
+ {
+ return $this->enum('director_datalist', array('id', 'list_name'));
+ }
+
+ public function enumDatafields()
+ {
+ return $this->enum('director_datafield', array(
+ 'id',
+ "caption || ' (' || varname || ')'",
+ ));
+ }
+
+ public function enum($table, $columns = null, $filters = array())
+ {
+ if ($columns === null) {
+ $columns = array('id', 'object_name');
+ }
+
+ $select = $this->db()->select()->from($table, $columns)->order($columns[1]);
+ foreach ($filters as $key => $val) {
+ $select->where($key, $val);
+ }
+
+ return $this->db()->fetchPairs($select);
+ }
+
+ public function enumIcingaObjects($type, $filters = array())
+ {
+ $filters = array(
+ 'object_type IN (?)' => array('object', 'external_object')
+ ) + $filters;
+
+ return $this->enum('icinga_' . $type, null, $filters);
+ }
+
+ public function enumIcingaTemplates($type, $filters = array())
+ {
+ $filters = array('object_type = ?' => 'template') + $filters;
+ return $this->enum('icinga_' . $type, null, $filters);
+ }
+
+ public function fetchDistinctHostVars()
+ {
+ $select = $this->db()->select()->distinct()->from(
+ array('hv' => 'icinga_host_var'),
+ array(
+ 'varname' => 'hv.varname',
+ 'format' => 'hv.format',
+ 'caption' => 'df.caption',
+ 'datatype' => 'df.datatype'
+ )
+ )->joinLeft(
+ array('df' => 'director_datafield'),
+ 'df.varname = hv.varname',
+ array()
+ )->order('varname');
+
+ return $this->db()->fetchAll($select);
+ }
+
+ public function fetchDistinctServiceVars()
+ {
+ $select = $this->db()->select()->distinct()->from(
+ array('sv' => 'icinga_service_var'),
+ array(
+ 'varname' => 'sv.varname',
+ 'format' => 'sv.format',
+ 'caption' => 'df.caption',
+ 'datatype' => 'df.datatype'
+ )
+ )->joinLeft(
+ array('df' => 'director_datafield'),
+ 'df.varname = sv.varname',
+ array()
+ )->order('varname');
+
+ return $this->db()->fetchAll($select);
+ }
+
+ public function dbHexFunc($column)
+ {
+ if ($this->isPgsql()) {
+ return sprintf("LOWER(ENCODE(%s, 'hex'))", $column);
+ } else {
+ return sprintf("LOWER(HEX(%s))", $column);
+ }
+ }
+
+ public function enumDeployedConfigs()
+ {
+ $db = $this->db();
+
+ $columns = array(
+ 'checksum' => $this->dbHexFunc('c.checksum'),
+ );
+
+ if ($this->isPgsql()) {
+ $columns['caption'] = 'SUBSTRING(' . $columns['checksum'] . ' FROM 1 FOR 7)';
+ } else {
+ $columns['caption'] = 'SUBSTRING(' . $columns['checksum'] . ', 1, 7)';
+ }
+
+ $query = $db->select()->from(
+ array('l' => 'director_deployment_log'),
+ $columns
+ )->joinLeft(
+ array('c' => 'director_generated_config'),
+ 'c.checksum = l.config_checksum',
+ array()
+ )->order('l.start_time DESC');
+
+ return $db->fetchPairs($query);
+ }
+}
diff --git a/library/Director/Db/AppliedServiceSetLoader.php b/library/Director/Db/AppliedServiceSetLoader.php
new file mode 100644
index 0000000..b1e9408
--- /dev/null
+++ b/library/Director/Db/AppliedServiceSetLoader.php
@@ -0,0 +1,58 @@
+<?php
+
+namespace Icinga\Module\Director\Db;
+
+use Icinga\Data\Filter\Filter;
+use Icinga\Module\Director\Objects\HostApplyMatches;
+use Icinga\Module\Director\Objects\IcingaHost;
+use Icinga\Module\Director\Objects\IcingaServiceSet;
+
+class AppliedServiceSetLoader
+{
+ protected $host;
+
+ public function __construct(IcingaHost $host)
+ {
+ $this->host = $host;
+ }
+
+ /**
+ * @return IcingaServiceSet[]
+ */
+ public static function fetchForHost(IcingaHost $host)
+ {
+ $loader = new static($host);
+ return $loader->fetchAppliedServiceSets();
+ }
+
+ /**
+ * @return IcingaServiceSet[]
+ */
+ protected function fetchAppliedServiceSets()
+ {
+ $sets = array();
+ $matcher = HostApplyMatches::prepare($this->host);
+ foreach ($this->fetchAllServiceSets() as $set) {
+ $filter = Filter::fromQueryString($set->get('assign_filter'));
+ if ($matcher->matchesFilter($filter)) {
+ $sets[] = $set;
+ }
+ }
+
+ return $sets;
+ }
+
+ /**
+ * @return IcingaServiceSet[]
+ */
+ protected function fetchAllServiceSets()
+ {
+ $db = $this->host->getDb();
+ $query = $db
+ ->select()
+ ->from('icinga_service_set')
+ ->where('assign_filter IS NOT NULL');
+
+ return IcingaServiceSet::loadAll($this->host->getConnection(), $query);
+ }
+}
diff --git a/library/Director/Db/Branch/Branch.php b/library/Director/Db/Branch/Branch.php
new file mode 100644
index 0000000..cd68ff0
--- /dev/null
+++ b/library/Director/Db/Branch/Branch.php
@@ -0,0 +1,216 @@
+<?php
+
+namespace Icinga\Module\Director\Db\Branch;
+
+use Icinga\Application\Icinga;
+use Icinga\Authentication\Auth;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Hook\BranchSupportHook;
+use Icinga\Web\Hook;
+use Icinga\Web\Request;
+use Ramsey\Uuid\Uuid;
+use Ramsey\Uuid\UuidInterface;
+use RuntimeException;
+use stdClass;
+
+/**
+ * Knows whether we're in a branch
+ */
+class Branch
+{
+ const PREFIX_SYNC_PREVIEW = '/syncpreview';
+
+ /** @var UuidInterface|null */
+ protected $branchUuid;
+
+ /** @var string */
+ protected $name;
+
+ /** @var string */
+ protected $owner;
+
+ /** @var @var string */
+ protected $description;
+
+ /** @var ?int */
+ protected $tsMergeRequest;
+
+ /** @var int */
+ protected $cntActivities;
+
+ public static function fromDbRow(stdClass $row)
+ {
+ $self = new static;
+ if (is_resource($row->uuid)) {
+ $row->uuid = stream_get_contents($row->uuid);
+ }
+ if (strlen($row->uuid) !== 16) {
+ throw new RuntimeException('Valid UUID expected, got ' . var_export($row->uuid, 1));
+ }
+ $self->branchUuid = Uuid::fromBytes(Db\DbUtil::binaryResult($row->uuid));
+ $self->name = $row->branch_name;
+ $self->owner = $row->owner;
+ $self->description = $row->description;
+ $self->tsMergeRequest = $row->ts_merge_request;
+ if (isset($row->cnt_activities)) {
+ $self->cntActivities = $row->cnt_activities;
+ } else {
+ $self->cntActivities = 0;
+ }
+
+ return $self;
+ }
+
+ /**
+ * @return Branch
+ */
+ public static function detect(BranchStore $store)
+ {
+ try {
+ return static::forRequest(Icinga::app()->getRequest(), $store, Auth::getInstance());
+ } catch (\Exception $e) {
+ return new static();
+ }
+ }
+
+ /**
+ * @param Request $request
+ * @param Db $db
+ * @param Auth $auth
+ * @return Branch
+ */
+ public static function forRequest(Request $request, BranchStore $store, Auth $auth)
+ {
+ if ($hook = static::optionalHook()) {
+ return $hook->getBranchForRequest($request, $store, $auth);
+ }
+
+ return new Branch;
+ }
+
+ /**
+ * @return BranchSupportHook
+ */
+ public static function requireHook()
+ {
+ if ($hook = static::optionalHook()) {
+ return $hook;
+ }
+
+ throw new RuntimeException('BranchSupport Hook requested where not available');
+ }
+
+ /**
+ * @return BranchSupportHook|null
+ */
+ public static function optionalHook()
+ {
+ return Hook::first('director/BranchSupport');
+ }
+
+ /**
+ * @param UuidInterface $uuid
+ * @return Branch
+ */
+ public static function withUuid(UuidInterface $uuid)
+ {
+ $self = new static();
+ $self->branchUuid = $uuid;
+ return $self;
+ }
+
+ /**
+ * @return bool
+ */
+ public function isBranch()
+ {
+ return $this->branchUuid !== null;
+ }
+
+ public function assertBranch()
+ {
+ if ($this->isMain()) {
+ throw new RuntimeException('Branch expected, but working in main branch');
+ }
+ }
+
+ /**
+ * @return bool
+ */
+ public function isMain()
+ {
+ return $this->branchUuid === null;
+ }
+
+ /**
+ * @return bool
+ */
+ public function shouldBeMerged()
+ {
+ return $this->tsMergeRequest !== null;
+ }
+
+ /**
+ * @return bool
+ */
+ public function isEmpty()
+ {
+ return $this->cntActivities === 0;
+ }
+
+ /**
+ * @return int
+ */
+ public function getActivityCount()
+ {
+ return $this->cntActivities;
+ }
+
+ /**
+ * @return UuidInterface|null
+ */
+ public function getUuid()
+ {
+ return $this->branchUuid;
+ }
+
+ /**
+ * @return string
+ */
+ public function getName()
+ {
+ return $this->name;
+ }
+
+ /**
+ * @since v1.10.0
+ * @return string
+ */
+ public function getDescription()
+ {
+ return $this->description;
+ }
+
+ /**
+ * @since v1.10.0
+ * @param ?string $description
+ * @return void
+ */
+ public function setDescription($description)
+ {
+ $this->description = $description;
+ }
+
+ /**
+ * @return string
+ */
+ public function getOwner()
+ {
+ return $this->owner;
+ }
+
+ public function isSyncPreview()
+ {
+ return (bool) preg_match('/^' . preg_quote(self::PREFIX_SYNC_PREVIEW, '/') . '\//', $this->getName());
+ }
+}
diff --git a/library/Director/Db/Branch/BranchActivity.php b/library/Director/Db/Branch/BranchActivity.php
new file mode 100644
index 0000000..3812e75
--- /dev/null
+++ b/library/Director/Db/Branch/BranchActivity.php
@@ -0,0 +1,390 @@
+<?php
+
+namespace Icinga\Module\Director\Db\Branch;
+
+use Icinga\Authentication\Auth;
+use Icinga\Exception\NotFoundError;
+use Icinga\Module\Director\Data\Db\DbObject;
+use Icinga\Module\Director\Data\Db\DbObjectTypeRegistry;
+use Icinga\Module\Director\Data\Json;
+use Icinga\Module\Director\Data\SerializableValue;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Objects\DirectorActivityLog;
+use Icinga\Module\Director\Objects\IcingaObject;
+use InvalidArgumentException;
+use Ramsey\Uuid\Uuid;
+use Ramsey\Uuid\UuidInterface;
+use RuntimeException;
+
+class BranchActivity
+{
+ const DB_TABLE = 'director_branch_activity';
+
+ const ACTION_CREATE = DirectorActivityLog::ACTION_CREATE;
+ const ACTION_MODIFY = DirectorActivityLog::ACTION_MODIFY;
+ const ACTION_DELETE = DirectorActivityLog::ACTION_DELETE;
+
+ /** @var int */
+ protected $timestampNs;
+
+ /** @var UuidInterface */
+ protected $objectUuid;
+
+ /** @var UuidInterface */
+ protected $branchUuid;
+
+ /** @var string create, modify, delete */
+ protected $action;
+
+ /** @var string */
+ protected $objectTable;
+
+ /** @var string */
+ protected $author;
+
+ /** @var SerializableValue */
+ protected $modifiedProperties;
+
+ /** @var ?SerializableValue */
+ protected $formerProperties;
+
+ public function __construct(
+ UuidInterface $objectUuid,
+ UuidInterface $branchUuid,
+ $action,
+ $objectType,
+ $author,
+ SerializableValue $modifiedProperties,
+ SerializableValue $formerProperties
+ ) {
+ $this->objectUuid = $objectUuid;
+ $this->branchUuid = $branchUuid;
+ $this->action = $action;
+ $this->objectTable = $objectType;
+ $this->author = $author;
+ $this->modifiedProperties = $modifiedProperties;
+ $this->formerProperties = $formerProperties;
+ }
+
+ public static function deleteObject(DbObject $object, Branch $branch)
+ {
+ return new static(
+ $object->getUniqueId(),
+ $branch->getUuid(),
+ self::ACTION_DELETE,
+ $object->getTableName(),
+ Auth::getInstance()->getUser()->getUsername(),
+ SerializableValue::fromSerialization(null),
+ SerializableValue::fromSerialization(self::getFormerObjectProperties($object))
+ );
+ }
+
+ public static function forDbObject(DbObject $object, Branch $branch)
+ {
+ if (! $object->hasBeenModified()) {
+ throw new InvalidArgumentException('Cannot get modifications for unmodified object');
+ }
+ if (! $branch->isBranch()) {
+ throw new InvalidArgumentException('Branch activity requires an active branch');
+ }
+
+ $author = Auth::getInstance()->getUser()->getUsername();
+ if ($object instanceof IcingaObject && $object->shouldBeRemoved()) {
+ $action = self::ACTION_DELETE;
+ $old = self::getFormerObjectProperties($object);
+ $new = null;
+ } elseif ($object->hasBeenLoadedFromDb()) {
+ $action = self::ACTION_MODIFY;
+ $old = self::getFormerObjectProperties($object);
+ $new = self::getObjectProperties($object);
+ } else {
+ $action = self::ACTION_CREATE;
+ $old = null;
+ $new = self::getObjectProperties($object);
+ }
+
+ if ($new !== null) {
+ $new = PlainObjectPropertyDiff::calculate(
+ $old,
+ $new
+ );
+ }
+
+ return new static(
+ $object->getUniqueId(),
+ $branch->getUuid(),
+ $action,
+ $object->getTableName(),
+ $author,
+ SerializableValue::fromSerialization($new),
+ SerializableValue::fromSerialization($old)
+ );
+ }
+
+ public static function fixFakeTimestamp($timestampNs)
+ {
+ if ($timestampNs < 1600000000 * 1000000) {
+ // fake TS for cloned branch in sync preview
+ return (int) $timestampNs * 1000000;
+ }
+
+ return $timestampNs;
+ }
+
+ public function applyToDbObject(DbObject $object)
+ {
+ if (!$this->isActionModify()) {
+ throw new RuntimeException('Only BranchActivity instances with action=modify can be applied');
+ }
+
+ foreach ($this->getModifiedProperties()->jsonSerialize() as $key => $value) {
+ $object->set($key, $value);
+ }
+
+ return $object;
+ }
+
+ /**
+ * Hint: $connection is required, because setting groups triggered loading them.
+ * Should be investigated, as in theory $hostWithoutConnection->groups = 'group'
+ * is expected to work
+ * @param Db $connection
+ * @return DbObject|string
+ */
+ public function createDbObject(Db $connection)
+ {
+ if (!$this->isActionCreate()) {
+ throw new RuntimeException('Only BranchActivity instances with action=create can create objects');
+ }
+
+ $class = DbObjectTypeRegistry::classByType($this->getObjectTable());
+ $object = $class::create([], $connection);
+ $object->setUniqueId($this->getObjectUuid());
+ foreach ($this->getModifiedProperties()->jsonSerialize() as $key => $value) {
+ $object->set($key, $value);
+ }
+
+ return $object;
+ }
+
+ public function deleteDbObject(DbObject $object)
+ {
+ if (!$this->isActionDelete()) {
+ throw new RuntimeException('Only BranchActivity instances with action=delete can delete objects');
+ }
+
+ return $object->delete();
+ }
+
+ public static function load($ts, Db $connection)
+ {
+ $db = $connection->getDbAdapter();
+ $row = $db->fetchRow(
+ $db->select()->from('director_branch_activity')->where('timestamp_ns = ?', $ts)
+ );
+
+ if ($row) {
+ return static::fromDbRow($row);
+ }
+
+ throw new NotFoundError('Not found');
+ }
+
+ protected static function fixPgResource(&$value)
+ {
+ if (is_resource($value)) {
+ $value = stream_get_contents($value);
+ }
+ }
+
+ public static function fromDbRow($row)
+ {
+ static::fixPgResource($row->object_uuid);
+ static::fixPgResource($row->branch_uuid);
+ $activity = new static(
+ Uuid::fromBytes($row->object_uuid),
+ Uuid::fromBytes($row->branch_uuid),
+ $row->action,
+ $row->object_table,
+ $row->author,
+ SerializableValue::fromSerialization(Json::decodeOptional($row->modified_properties)),
+ SerializableValue::fromSerialization(Json::decodeOptional($row->former_properties))
+ );
+ $activity->timestampNs = $row->timestamp_ns;
+
+ return $activity;
+ }
+
+ /**
+ * Must be run in a transaction! Repeatable read?
+ * @param Db $connection
+ * @throws \Icinga\Module\Director\Exception\JsonEncodeException
+ * @throws \Zend_Db_Adapter_Exception
+ */
+ public function store(Db $connection)
+ {
+ if ($this->timestampNs !== null) {
+ throw new InvalidArgumentException(sprintf(
+ 'Cannot store activity with a given timestamp: %s',
+ $this->timestampNs
+ ));
+ }
+ $db = $connection->getDbAdapter();
+ $last = $db->fetchRow(
+ $db->select()->from('director_branch_activity', ['timestamp_ns' => 'MAX(timestamp_ns)'])
+ );
+ if (PHP_INT_SIZE !== 8) {
+ throw new RuntimeException('PHP with 64bit integer support is required');
+ }
+ $timestampNs = (int) floor(microtime(true) * 1000000);
+ if ($last) {
+ if ($last->timestamp_ns >= $timestampNs) {
+ $timestampNs = $last + 1;
+ }
+ }
+ $old = Json::encode($this->formerProperties);
+ $new = Json::encode($this->modifiedProperties);
+
+ $db->insert(self::DB_TABLE, [
+ 'timestamp_ns' => $timestampNs,
+ 'object_uuid' => $connection->quoteBinary($this->objectUuid->getBytes()),
+ 'branch_uuid' => $connection->quoteBinary($this->branchUuid->getBytes()),
+ 'action' => $this->action,
+ 'object_table' => $this->objectTable,
+ 'author' => $this->author,
+ 'former_properties' => $old,
+ 'modified_properties' => $new,
+ ]);
+ }
+
+ /**
+ * @return int
+ */
+ public function getTimestampNs()
+ {
+ return $this->timestampNs;
+ }
+
+ /**
+ * @return int
+ */
+ public function getTimestamp()
+ {
+ return (int) floor(BranchActivity::fixFakeTimestamp($this->timestampNs) / 1000000);
+ }
+
+ /**
+ * @return UuidInterface
+ */
+ public function getObjectUuid()
+ {
+ return $this->objectUuid;
+ }
+
+ /**
+ * @return UuidInterface
+ */
+ public function getBranchUuid()
+ {
+ return $this->branchUuid;
+ }
+
+ /**
+ * @return string
+ */
+ public function getObjectName()
+ {
+ return $this->getProperty('object_name', 'unknown object name');
+ }
+
+ /**
+ * @return string
+ */
+ public function getAction()
+ {
+ return $this->action;
+ }
+
+ public function isActionDelete()
+ {
+ return $this->action === self::ACTION_DELETE;
+ }
+
+ public function isActionCreate()
+ {
+ return $this->action === self::ACTION_CREATE;
+ }
+
+ public function isActionModify()
+ {
+ return $this->action === self::ACTION_MODIFY;
+ }
+
+ /**
+ * @return string
+ */
+ public function getObjectTable()
+ {
+ return $this->objectTable;
+ }
+
+ /**
+ * @return string
+ */
+ public function getAuthor()
+ {
+ return $this->author;
+ }
+
+ /**
+ * @return ?SerializableValue
+ */
+ public function getModifiedProperties()
+ {
+ return $this->modifiedProperties;
+ }
+
+ /**
+ * @return ?SerializableValue
+ */
+ public function getFormerProperties()
+ {
+ return $this->formerProperties;
+ }
+
+ public function getProperty($key, $default = null)
+ {
+ if ($this->modifiedProperties) {
+ $properties = $this->modifiedProperties->jsonSerialize();
+ if (isset($properties->$key)) {
+ return $properties->$key;
+ }
+ }
+ if ($this->formerProperties) {
+ $properties = $this->formerProperties->jsonSerialize();
+ if (isset($properties->$key)) {
+ return $properties->$key;
+ }
+ }
+
+ return $default;
+ }
+
+ protected static function getFormerObjectProperties(DbObject $object)
+ {
+ if (! $object instanceof IcingaObject) {
+ throw new RuntimeException('Plain object helpers for DbObject must be implemented');
+ }
+
+ return (array) $object->getPlainUnmodifiedObject();
+ }
+
+ protected static function getObjectProperties(DbObject $object)
+ {
+ if (! $object instanceof IcingaObject) {
+ throw new RuntimeException('Plain object helpers for DbObject must be implemented');
+ }
+
+ return (array) $object->toPlainObject(false, true);
+ }
+}
diff --git a/library/Director/Db/Branch/BranchMerger.php b/library/Director/Db/Branch/BranchMerger.php
new file mode 100644
index 0000000..2e84863
--- /dev/null
+++ b/library/Director/Db/Branch/BranchMerger.php
@@ -0,0 +1,157 @@
+<?php
+
+namespace Icinga\Module\Director\Db\Branch;
+
+use Icinga\Module\Director\Data\Db\DbObject;
+use Icinga\Module\Director\Data\Db\DbObjectTypeRegistry;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Objects\DirectorActivityLog;
+use Ramsey\Uuid\UuidInterface;
+
+class BranchMerger
+{
+ /** @var Branch */
+ protected $branchUuid;
+
+ /** @var Db */
+ protected $connection;
+
+ /** @var \Zend_Db_Adapter_Abstract */
+ protected $db;
+
+ /** @var array */
+ protected $ignoreActivities = [];
+
+ /** @var bool */
+ protected $ignoreDeleteWhenMissing = false;
+
+ /** @var bool */
+ protected $ignoreModificationWhenMissing = false;
+
+ /**
+ * Apply branch modifications
+ *
+ * TODO: allow to skip or ignore modifications, in case modified properties have
+ * been changed in the meantime
+ *
+ * @param UuidInterface $branchUuid
+ * @param Db $connection
+ */
+ public function __construct(UuidInterface $branchUuid, Db $connection)
+ {
+ $this->branchUuid = $branchUuid;
+ $this->db = $connection->getDbAdapter();
+ $this->connection = $connection;
+ }
+
+ /**
+ * Skip a delete operation, when the object to be deleted does not exist
+ *
+ * @param bool $ignore
+ */
+ public function ignoreDeleteWhenMissing($ignore = true)
+ {
+ $this->ignoreDeleteWhenMissing = $ignore;
+ }
+
+ /**
+ * Skip a modification, when the related object does not exist
+ * @param bool $ignore
+ */
+ public function ignoreModificationWhenMissing($ignore = true)
+ {
+ $this->ignoreModificationWhenMissing = $ignore;
+ }
+
+ /**
+ * @param int $key
+ */
+ public function ignoreActivity($key)
+ {
+ $this->ignoreActivities[$key] = true;
+ }
+
+ /**
+ * @param BranchActivity $activity
+ * @return bool
+ */
+ public function ignoresActivity(BranchActivity $activity)
+ {
+ return isset($this->ignoreActivities[$activity->getTimestampNs()]);
+ }
+
+ /**
+ * @throws MergeError
+ */
+ public function merge($comment = null)
+ {
+ $username = DirectorActivityLog::username();
+ $this->connection->runFailSafeTransaction(function () use ($comment, $username) {
+ $formerActivityId = (int) DirectorActivityLog::loadLatest($this->connection)->get('id');
+ $query = $this->db->select()
+ ->from(BranchActivity::DB_TABLE)
+ ->where('branch_uuid = ?', $this->connection->quoteBinary($this->branchUuid->getBytes()))
+ ->order('timestamp_ns ASC');
+ $rows = $this->db->fetchAll($query);
+ foreach ($rows as $row) {
+ $activity = BranchActivity::fromDbRow($row);
+ $author = $activity->getAuthor();
+ if ($username !== $author) {
+ DirectorActivityLog::overrideUsername("$author/$username");
+ }
+ $this->applyModification($activity);
+ }
+ (new BranchStore($this->connection))->deleteByUuid($this->branchUuid);
+ $currentActivityId = (int) DirectorActivityLog::loadLatest($this->connection)->get('id');
+ $firstActivityId = (int) $this->db->fetchOne(
+ $this->db->select()->from('director_activity_log', 'MIN(id)')->where('id > ?', $formerActivityId)
+ );
+ if ($comment && strlen($comment)) {
+ $this->db->insert('director_activity_log_remark', [
+ 'first_related_activity' => $firstActivityId,
+ 'last_related_activity' => $currentActivityId,
+ 'remark' => $comment,
+ ]);
+ }
+ });
+ DirectorActivityLog::restoreUsername();
+ }
+
+ /**
+ * @param BranchActivity $activity
+ * @throws MergeError
+ * @throws \Icinga\Exception\NotFoundError
+ * @throws \Icinga\Module\Director\Exception\DuplicateKeyException
+ */
+ protected function applyModification(BranchActivity $activity)
+ {
+ /** @var string|DbObject $class */
+ $class = DbObjectTypeRegistry::classByType($activity->getObjectTable());
+ $uuid = $activity->getObjectUuid();
+
+ $exists = $class::uniqueIdExists($uuid, $this->connection);
+ if ($activity->isActionCreate()) {
+ if ($exists) {
+ if (! $this->ignoresActivity($activity)) {
+ throw new MergeErrorRecreateOnMerge($activity);
+ }
+ } else {
+ $activity->createDbObject($this->connection)->store($this->connection);
+ }
+ } elseif ($activity->isActionDelete()) {
+ if ($exists) {
+ $activity->deleteDbObject($class::requireWithUniqueId($uuid, $this->connection));
+ } elseif (! $this->ignoreDeleteWhenMissing && ! $this->ignoresActivity($activity)) {
+ throw new MergeErrorDeleteMissingObject($activity);
+ }
+ } else {
+ if ($exists) {
+ $activity->applyToDbObject($class::requireWithUniqueId($uuid, $this->connection))->store();
+ // TODO: you modified an object, and related properties have been changed in the meantime.
+ // We're able to detect this with the given data, and might want to offer a rebase.
+ } elseif (! $this->ignoreModificationWhenMissing && ! $this->ignoresActivity($activity)) {
+ throw new MergeErrorModificationForMissingObject($activity);
+ }
+ }
+ }
+}
diff --git a/library/Director/Db/Branch/BranchModificationInspection.php b/library/Director/Db/Branch/BranchModificationInspection.php
new file mode 100644
index 0000000..978ca5d
--- /dev/null
+++ b/library/Director/Db/Branch/BranchModificationInspection.php
@@ -0,0 +1,93 @@
+<?php
+
+namespace Icinga\Module\Director\Db\Branch;
+
+use gipfl\Translation\StaticTranslator;
+use gipfl\Translation\TranslationHelper;
+use Icinga\Module\Director\Db;
+use ipl\Html\Html;
+use ipl\Html\HtmlDocument;
+use Ramsey\Uuid\UuidInterface;
+
+class BranchModificationInspection
+{
+ use TranslationHelper;
+
+ protected $connection;
+
+ protected $db;
+
+ public function __construct(Db $connection)
+ {
+ $this->connection = $connection;
+ $this->db = $connection->getDbAdapter();
+ }
+
+ public function describe($table, UuidInterface $uuid)
+ {
+ return static::describeModificationStatistics($this->loadSingleTableStats($table, $uuid));
+ }
+
+ public function describeBranch(UuidInterface $uuid)
+ {
+ $tables = [
+ $this->translate('API Users') => BranchSupport::BRANCHED_TABLE_ICINGA_APIUSER,
+ $this->translate('Endpoints') => BranchSupport::BRANCHED_TABLE_ICINGA_COMMAND,
+ $this->translate('Zones') => BranchSupport::BRANCHED_TABLE_ICINGA_DEPENDENCY,
+ $this->translate('Commands') => BranchSupport::BRANCHED_TABLE_ICINGA_ENDPOINT,
+ $this->translate('Hosts') => BranchSupport::BRANCHED_TABLE_ICINGA_HOST,
+ $this->translate('Hostgroups') => BranchSupport::BRANCHED_TABLE_ICINGA_HOSTGROUP,
+ $this->translate('Services') => BranchSupport::BRANCHED_TABLE_ICINGA_NOTIFICATION,
+ $this->translate('Servicegroups') => BranchSupport::BRANCHED_TABLE_ICINGA_SCHEDULED_DOWNTIME,
+ $this->translate('Servicesets') => BranchSupport::BRANCHED_TABLE_ICINGA_SERVICE_SET,
+ $this->translate('Users') => BranchSupport::BRANCHED_TABLE_ICINGA_SERVICE,
+ $this->translate('Usergroups') => BranchSupport::BRANCHED_TABLE_ICINGA_SERVICEGROUP,
+ $this->translate('Timeperiods') => BranchSupport::BRANCHED_TABLE_ICINGA_TIMEPERIOD,
+ $this->translate('Notifications') => BranchSupport::BRANCHED_TABLE_ICINGA_USER,
+ $this->translate('Dependencies') => BranchSupport::BRANCHED_TABLE_ICINGA_USERGROUP,
+ $this->translate('Scheduled Downtimes') => BranchSupport::BRANCHED_TABLE_ICINGA_ZONE,
+ ];
+
+ $parts = new HtmlDocument();
+ $parts->setSeparator(Html::tag('br'));
+ foreach ($tables as $label => $table) {
+ $info = $this->describe($table, $uuid);
+ if (! empty($info) && $info !== '-') {
+ $parts->add("$label: $info");
+ }
+ }
+
+ return $parts;
+ }
+
+ public static function describeModificationStatistics($stats)
+ {
+ $t = StaticTranslator::get();
+ $relevantStats = [];
+ if ($stats->cnt_created > 0) {
+ $relevantStats[] = sprintf($t->translate('%d created'), $stats->cnt_created);
+ }
+ if ($stats->cnt_deleted > 0) {
+ $relevantStats[] = sprintf($t->translate('%d deleted'), $stats->cnt_deleted);
+ }
+ if ($stats->cnt_modified > 0) {
+ $relevantStats[] = sprintf($t->translate('%d modified'), $stats->cnt_modified);
+ }
+ if (empty($relevantStats)) {
+ return '-';
+ }
+
+ return implode(', ', $relevantStats);
+ }
+
+ public function loadSingleTableStats($table, UuidInterface $uuid)
+ {
+ $query = $this->db->select()->from($table, [
+ 'cnt_created' => "SUM(CASE WHEN branch_created = 'y' THEN 1 ELSE 0 END)",
+ 'cnt_deleted' => "SUM(CASE WHEN branch_deleted = 'y' THEN 1 ELSE 0 END)",
+ 'cnt_modified' => "SUM(CASE WHEN branch_deleted = 'n' AND branch_created = 'n' THEN 1 ELSE 0 END)",
+ ])->where('branch_uuid = ?', $this->connection->quoteBinary($uuid->getBytes()));
+
+ return $this->db->fetchRow($query);
+ }
+}
diff --git a/library/Director/Db/Branch/BranchSettings.php b/library/Director/Db/Branch/BranchSettings.php
new file mode 100644
index 0000000..b3fd164
--- /dev/null
+++ b/library/Director/Db/Branch/BranchSettings.php
@@ -0,0 +1,121 @@
+<?php
+
+namespace Icinga\Module\Director\Db\Branch;
+
+use Icinga\Module\Director\Data\Json;
+use function in_array;
+
+/**
+ * Hardcoded branch-related settings
+ */
+class BranchSettings
+{
+ // TODO: Ranges is weird. key = scheduled_downtime_id, range_type, range_key
+ const ENCODED_ARRAYS = ['imports', 'groups', 'ranges', 'users', 'usergroups'];
+
+ const ENCODED_DICTIONARIES = ['vars', 'arguments'];
+
+ const BRANCH_SPECIFIC_PROPERTIES = [
+ 'uuid',
+ 'branch_uuid',
+ 'branch_created',
+ 'branch_deleted',
+ 'set_null',
+ ];
+
+ const BRANCH_BOOLEANS = [
+ 'branch_created',
+ 'branch_deleted',
+ ];
+
+ const RELATED_SETS = [
+ 'types',
+ 'states',
+ ];
+
+ public static function propertyIsEncodedArray($property)
+ {
+ return in_array($property, self::ENCODED_ARRAYS, true);
+ }
+
+ public static function propertyIsRelatedSet($property)
+ {
+ // TODO: get from object class
+ return in_array($property, self::RELATED_SETS, true);
+ }
+
+ public static function propertyIsEncodedDictionary($property)
+ {
+ return in_array($property, self::ENCODED_DICTIONARIES, true);
+ }
+
+ public static function propertyIsBranchSpecific($property)
+ {
+ return in_array($property, self::BRANCH_SPECIFIC_PROPERTIES, true);
+ }
+
+ public static function flattenEncodedDicationaries(array &$properties)
+ {
+ foreach (self::ENCODED_DICTIONARIES as $property) {
+ self::flattenProperty($properties, $property);
+ }
+ }
+
+ public static function normalizeBranchedObjectFromDb($row)
+ {
+ $normalized = [];
+ $row = (array) $row;
+ foreach ($row as $key => $value) {
+ if (! static::propertyIsBranchSpecific($key)) {
+ if (is_resource($value)) {
+ $value = stream_get_contents($value);
+ }
+ if ($value !== null && static::propertyIsEncodedArray($key)) {
+ $value = Json::decode($value);
+ }
+ if ($value !== null && static::propertyIsRelatedSet($key)) {
+ // TODO: We might want to combine them (current VS branched)
+ $value = Json::decode($value);
+ }
+ if ($value !== null && static::propertyIsEncodedDictionary($key)) {
+ $value = Json::decode($value);
+ }
+ if ($value !== null) {
+ $normalized[$key] = $value;
+ }
+ }
+ }
+ static::flattenEncodedDicationaries($row);
+ if (isset($row['set_null'])) {
+ foreach (Json::decode($row['set_null']) as $property) {
+ $normalized[$property] = null;
+ }
+ }
+ foreach (self::BRANCH_BOOLEANS as $key) {
+ if ($row[$key] === 'y') {
+ $row[$key] = true;
+ } elseif ($row[$key] === 'n') {
+ $row[$key] = false;
+ } else {
+ throw new \RuntimeException(sprintf(
+ "Boolean DB property expected, got '%s' for '%s'",
+ $row[$key],
+ $key
+ ));
+ }
+ }
+
+ return $normalized;
+ }
+
+ public static function flattenProperty(array &$properties, $property)
+ {
+ // TODO: dots in varnames -> throw or escape?
+ if (isset($properties[$property])) {
+ foreach ((array) $properties[$property] as $key => $value) {
+ $properties["$property.$key"] = $value;
+ }
+ unset($properties[$property]);
+ }
+ }
+}
diff --git a/library/Director/Db/Branch/BranchStore.php b/library/Director/Db/Branch/BranchStore.php
new file mode 100644
index 0000000..196d079
--- /dev/null
+++ b/library/Director/Db/Branch/BranchStore.php
@@ -0,0 +1,240 @@
+<?php
+
+namespace Icinga\Module\Director\Db\Branch;
+
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Db\DbUtil;
+use Ramsey\Uuid\Uuid;
+use Ramsey\Uuid\UuidInterface;
+
+class BranchStore
+{
+ const TABLE = 'director_branch';
+ const TABLE_ACTIVITY = 'director_branch_activity';
+
+ protected $connection;
+
+ protected $db;
+
+ public function __construct(Db $connection)
+ {
+ $this->connection = $connection;
+ $this->db = $connection->getDbAdapter();
+ }
+
+ /**
+ * @param UuidInterface $uuid
+ * @return ?Branch
+ */
+ public function fetchBranchByUuid(UuidInterface $uuid)
+ {
+ return $this->newFromDbResult(
+ $this->select()->where('b.uuid = ?', $this->connection->quoteBinary($uuid->getBytes()))
+ );
+ }
+
+ /**
+ * @param string $name
+ * @return ?Branch
+ */
+ public function fetchBranchByName($name)
+ {
+ return $this->newFromDbResult($this->select()->where('b.branch_name = ?', $name));
+ }
+
+ public function cloneBranchForSync(Branch $branch, $newName, $owner)
+ {
+ $this->runTransaction(function ($db) use ($branch, $newName, $owner) {
+ $tables = BranchSupport::BRANCHED_TABLES;
+ $tables[] = self::TABLE_ACTIVITY;
+ $newBranch = $this->createBranchByName($newName, $owner);
+ $oldQuotedUuid = DbUtil::quoteBinaryCompat($branch->getUuid()->getBytes(), $db);
+ $quotedUuid = DbUtil::quoteBinaryCompat($newBranch->getUuid()->getBytes(), $db);
+ // $timestampNs = (int)floor(microtime(true) * 1000000);
+ // Hint: would love to do SELECT *, $quotedUuid AS branch_uuid FROM $table INTO $table
+ foreach ($tables as $table) {
+ $rows = $db->fetchAll($db->select()->from($table)->where('branch_uuid = ?', $oldQuotedUuid));
+ foreach ($rows as $row) {
+ $modified = (array)$row;
+ $modified['branch_uuid'] = $quotedUuid;
+ if ($table === self::TABLE_ACTIVITY) {
+ $modified['timestamp_ns'] = round($modified['timestamp_ns'] / 1000000);
+ }
+ $db->insert($table, $modified);
+ }
+ }
+ });
+
+ return $this->fetchBranchByName($newName);
+ }
+
+ protected function runTransaction($callback)
+ {
+ $db = $this->db;
+ $db->beginTransaction();
+ try {
+ $callback($db);
+ $db->commit();
+ } catch (\Exception $e) {
+ try {
+ $db->rollBack();
+ } catch (\Exception $ignored) {
+ //
+ }
+ throw $e;
+ }
+ }
+
+ public function wipeBranch(Branch $branch, $after = null)
+ {
+ $this->runTransaction(function ($db) use ($branch, $after) {
+ $tables = BranchSupport::BRANCHED_TABLES;
+ $tables[] = self::TABLE_ACTIVITY;
+ $quotedUuid = DbUtil::quoteBinaryCompat($branch->getUuid()->getBytes(), $db);
+ $where = $db->quoteInto('branch_uuid = ?', $quotedUuid);
+ foreach ($tables as $table) {
+ if ($after && $table === self::TABLE_ACTIVITY) {
+ $db->delete($table, $where . ' AND timestamp_ns > ' . (int) $after);
+ } else {
+ $db->delete($table, $where);
+ }
+ }
+ });
+
+ }
+
+ protected function newFromDbResult($query)
+ {
+ if ($row = $this->db->fetchRow($query)) {
+ if (is_resource($row->uuid)) {
+ $row->uuid = stream_get_contents($row->uuid);
+ }
+ return Branch::fromDbRow($row);
+ }
+
+ return null;
+ }
+
+ public function setReadyForMerge(Branch $branch)
+ {
+ $update = [
+ 'ts_merge_request' => (int) floor(microtime(true) * 1000000),
+ 'description' => $branch->getDescription(),
+ ];
+
+ $name = $branch->getName();
+ if (preg_match('#^/enforced/(.+)$#', $name, $match)) {
+ $update['branch_name'] = '/merge/' . substr(sha1($branch->getUuid()->getBytes()), 0, 7) . '/' . $match[1];
+ }
+ $this->db->update('director_branch', $update, $this->db->quoteInto(
+ 'uuid = ?',
+ $this->connection->quoteBinary($branch->getUuid()->getBytes())
+ ));
+ }
+
+ protected function select()
+ {
+ return $this->db->select()->from(['b' => 'director_branch'], [
+ 'uuid' => 'b.uuid',
+ 'owner' => 'b.owner',
+ 'branch_name' => 'b.branch_name',
+ 'description' => 'b.description',
+ 'ts_merge_request' => 'b.ts_merge_request',
+ 'cnt_activities' => 'COUNT(ba.timestamp_ns)',
+ ])->joinLeft(
+ ['ba' => self::TABLE_ACTIVITY],
+ 'b.uuid = ba.branch_uuid',
+ []
+ )->group('b.uuid');
+ }
+
+ /**
+ * @param string $name
+ * @return Branch
+ * @throws \Zend_Db_Adapter_Exception
+ */
+ public function fetchOrCreateByName($name, $owner)
+ {
+ if ($branch = $this->fetchBranchByName($name)) {
+ return $branch;
+ }
+
+ return $this->createBranchByName($name, $owner);
+ }
+
+ /**
+ * @param string $branchName
+ * @param string $owner
+ * @return Branch
+ * @throws \Zend_Db_Adapter_Exception
+ */
+ public function createBranchByName($branchName, $owner)
+ {
+ $uuid = Uuid::uuid4();
+ $properties = [
+ 'uuid' => $this->connection->quoteBinary($uuid->getBytes()),
+ 'branch_name' => $branchName,
+ 'owner' => $owner,
+ 'description' => null,
+ 'ts_merge_request' => null,
+ ];
+ $this->db->insert(self::TABLE, $properties);
+
+ if ($branch = static::fetchBranchByUuid($uuid)) {
+ return $branch;
+ }
+
+ throw new \RuntimeException(sprintf(
+ 'Branch with UUID=%s has been created, but could not be fetched from DB',
+ $uuid->toString()
+ ));
+ }
+
+ public function deleteByUuid(UuidInterface $uuid)
+ {
+ return $this->db->delete(self::TABLE, $this->db->quoteInto(
+ 'uuid = ?',
+ $this->connection->quoteBinary($uuid->getBytes())
+ ));
+ }
+
+ /**
+ * @param string $name
+ * @return int
+ */
+ public function deleteByName($name)
+ {
+ return $this->db->delete(self::TABLE, $this->db->quoteInto(
+ 'branch_name = ?',
+ $name
+ ));
+ }
+
+ public function delete(Branch $branch)
+ {
+ return $this->deleteByUuid($branch->getUuid());
+ }
+
+ /**
+ * @param Branch $branch
+ * @param ?int $after
+ * @return float|null
+ */
+ public function getLastActivityTime(Branch $branch, $after = null)
+ {
+ $db = $this->db;
+ $query = $db->select()
+ ->from(self::TABLE_ACTIVITY, 'MAX(timestamp_ns)')
+ ->where('branch_uuid = ?', DbUtil::quoteBinaryCompat($branch->getUuid()->getBytes(), $db));
+ if ($after) {
+ $query->where('timestamp_ns > ?', (int) $after);
+ }
+
+ $last = $db->fetchOne($query);
+ if ($last) {
+ return $last / 1000000;
+ }
+
+ return null;
+ }
+}
diff --git a/library/Director/Db/Branch/BranchSupport.php b/library/Director/Db/Branch/BranchSupport.php
new file mode 100644
index 0000000..74be021
--- /dev/null
+++ b/library/Director/Db/Branch/BranchSupport.php
@@ -0,0 +1,91 @@
+<?php
+
+namespace Icinga\Module\Director\Db\Branch;
+
+use Icinga\Module\Director\Data\Db\DbObjectTypeRegistry;
+use Icinga\Module\Director\Objects\SyncRule;
+
+class BranchSupport
+{
+ const BRANCHED_TABLE_PREFIX = 'branched_';
+
+ const TABLE_ICINGA_APIUSER = 'icinga_apiuser';
+ const TABLE_ICINGA_COMMAND = 'icinga_command';
+ const TABLE_ICINGA_DEPENDENCY = 'icinga_dependency';
+ const TABLE_ICINGA_ENDPOINT = 'icinga_endpoint';
+ const TABLE_ICINGA_HOST = 'icinga_host';
+ const TABLE_ICINGA_HOSTGROUP = 'icinga_hostgroup';
+ const TABLE_ICINGA_NOTIFICATION = 'icinga_notification';
+ const TABLE_ICINGA_SCHEDULED_DOWNTIME = 'icinga_scheduled_downtime';
+ const TABLE_ICINGA_SERVICE = 'icinga_service';
+ const TABLE_ICINGA_SERVICEGROUP = 'icinga_servicegroup';
+ const TABLE_ICINGA_SERVICE_SET = 'icinga_service_set';
+ const TABLE_ICINGA_TIMEPERIOD = 'icinga_timeperiod';
+ const TABLE_ICINGA_USER = 'icinga_user';
+ const TABLE_ICINGA_USERGROUP = 'icinga_usergroup';
+ const TABLE_ICINGA_ZONE = 'icinga_zone';
+
+ const BRANCHED_TABLE_ICINGA_APIUSER = self::BRANCHED_TABLE_PREFIX. self::TABLE_ICINGA_APIUSER;
+ const BRANCHED_TABLE_ICINGA_COMMAND = self::BRANCHED_TABLE_PREFIX. self::TABLE_ICINGA_COMMAND;
+ const BRANCHED_TABLE_ICINGA_DEPENDENCY = self::BRANCHED_TABLE_PREFIX. self::TABLE_ICINGA_DEPENDENCY;
+ const BRANCHED_TABLE_ICINGA_ENDPOINT = self::BRANCHED_TABLE_PREFIX. self::TABLE_ICINGA_ENDPOINT;
+ const BRANCHED_TABLE_ICINGA_HOST = self::BRANCHED_TABLE_PREFIX. self::TABLE_ICINGA_HOST;
+ const BRANCHED_TABLE_ICINGA_HOSTGROUP = self::BRANCHED_TABLE_PREFIX. self::TABLE_ICINGA_HOSTGROUP;
+ const BRANCHED_TABLE_ICINGA_NOTIFICATION = self::BRANCHED_TABLE_PREFIX. self::TABLE_ICINGA_NOTIFICATION;
+ const BRANCHED_TABLE_ICINGA_SCHEDULED_DOWNTIME = self::BRANCHED_TABLE_PREFIX. self::TABLE_ICINGA_SCHEDULED_DOWNTIME;
+ const BRANCHED_TABLE_ICINGA_SERVICE = self::BRANCHED_TABLE_PREFIX. self::TABLE_ICINGA_SERVICE;
+ const BRANCHED_TABLE_ICINGA_SERVICEGROUP = self::BRANCHED_TABLE_PREFIX. self::TABLE_ICINGA_SERVICEGROUP;
+ const BRANCHED_TABLE_ICINGA_SERVICE_SET = self::BRANCHED_TABLE_PREFIX. self::TABLE_ICINGA_SERVICE_SET;
+ const BRANCHED_TABLE_ICINGA_TIMEPERIOD = self::BRANCHED_TABLE_PREFIX. self::TABLE_ICINGA_TIMEPERIOD;
+ const BRANCHED_TABLE_ICINGA_USER = self::BRANCHED_TABLE_PREFIX. self::TABLE_ICINGA_USER;
+ const BRANCHED_TABLE_ICINGA_USERGROUP = self::BRANCHED_TABLE_PREFIX. self::TABLE_ICINGA_USERGROUP;
+ const BRANCHED_TABLE_ICINGA_ZONE = self::BRANCHED_TABLE_PREFIX. self::TABLE_ICINGA_ZONE;
+
+ const OBJECT_TABLES = [
+ self::TABLE_ICINGA_APIUSER,
+ self::TABLE_ICINGA_COMMAND,
+ self::TABLE_ICINGA_DEPENDENCY,
+ self::TABLE_ICINGA_ENDPOINT,
+ self::TABLE_ICINGA_HOST,
+ self::TABLE_ICINGA_HOSTGROUP,
+ self::TABLE_ICINGA_NOTIFICATION,
+ self::TABLE_ICINGA_SCHEDULED_DOWNTIME,
+ self::TABLE_ICINGA_SERVICE,
+ self::TABLE_ICINGA_SERVICEGROUP,
+ self::TABLE_ICINGA_SERVICE_SET,
+ self::TABLE_ICINGA_TIMEPERIOD,
+ self::TABLE_ICINGA_USER,
+ self::TABLE_ICINGA_USERGROUP,
+ self::TABLE_ICINGA_ZONE,
+ ];
+
+ const BRANCHED_TABLES = [
+ self::BRANCHED_TABLE_ICINGA_APIUSER,
+ self::BRANCHED_TABLE_ICINGA_COMMAND,
+ self::BRANCHED_TABLE_ICINGA_DEPENDENCY,
+ self::BRANCHED_TABLE_ICINGA_ENDPOINT,
+ self::BRANCHED_TABLE_ICINGA_HOST,
+ self::BRANCHED_TABLE_ICINGA_HOSTGROUP,
+ self::BRANCHED_TABLE_ICINGA_NOTIFICATION,
+ self::BRANCHED_TABLE_ICINGA_SCHEDULED_DOWNTIME,
+ self::BRANCHED_TABLE_ICINGA_SERVICE,
+ self::BRANCHED_TABLE_ICINGA_SERVICEGROUP,
+ self::BRANCHED_TABLE_ICINGA_SERVICE_SET,
+ self::BRANCHED_TABLE_ICINGA_TIMEPERIOD,
+ self::BRANCHED_TABLE_ICINGA_USER,
+ self::BRANCHED_TABLE_ICINGA_USERGROUP,
+ self::BRANCHED_TABLE_ICINGA_ZONE,
+ ];
+
+ public static function existsForTableName($table)
+ {
+ return in_array($table, self::OBJECT_TABLES, true);
+ }
+
+ public static function existsForSyncRule(SyncRule $rule)
+ {
+ return static::existsForTableName(
+ DbObjectTypeRegistry::tableNameByType($rule->get('object_type'))
+ );
+ }
+}
diff --git a/library/Director/Db/Branch/BranchedObject.php b/library/Director/Db/Branch/BranchedObject.php
new file mode 100644
index 0000000..0f276c2
--- /dev/null
+++ b/library/Director/Db/Branch/BranchedObject.php
@@ -0,0 +1,404 @@
+<?php
+
+namespace Icinga\Module\Director\Db\Branch;
+
+use Icinga\Exception\NotFoundError;
+use Icinga\Module\Director\Data\Db\DbObject;
+use Icinga\Module\Director\Data\Db\DbObjectTypeRegistry;
+use Icinga\Module\Director\Data\Json;
+use Icinga\Module\Director\Db;
+use Ramsey\Uuid\UuidInterface;
+use stdClass;
+
+class BranchedObject
+{
+ /** @var UuidInterface */
+ protected $branchUuid;
+
+ /** @var ?DbObject */
+ protected $object;
+
+ /** @var ?stdClass */
+ protected $changes;
+
+ /** @var bool */
+ protected $branchDeleted;
+
+ /** @var bool */
+ protected $branchCreated;
+
+ /** @var UuidInterface */
+ private $objectUuid;
+
+ /** @var string */
+ private $objectTable;
+
+ /** @var bool */
+ private $loadedAsBranchedObject = false;
+
+ /**
+ * @param BranchActivity $activity
+ * @param Db $connection
+ * @return static
+ */
+ public static function withActivity(BranchActivity $activity, Db $connection)
+ {
+ return self::loadOptional(
+ $connection,
+ $activity->getObjectTable(),
+ $activity->getObjectUuid(),
+ $activity->getBranchUuid()
+ )->applyActivity($activity, $connection);
+ }
+
+ public function store(Db $connection)
+ {
+ if ($this->object && ! $this->object->hasBeenModified() && empty($this->changes)) {
+ return false;
+ }
+ $db = $connection->getDbAdapter();
+
+ $properties = [
+ 'branch_deleted' => $this->branchDeleted ? 'y' : 'n',
+ 'branch_created' => $this->branchCreated ? 'y' : 'n',
+ ] + $this->prepareChangedProperties();
+
+ $table = 'branched_' . $this->objectTable;
+ if ($this->loadedAsBranchedObject) {
+ return $db->update(
+ $table,
+ $properties,
+ $this->prepareWhereString($connection)
+ ) === 1;
+ } else {
+ try {
+ return $db->insert($table, $this->prepareKeyProperties($connection) + $properties) === 1;
+ } catch (\Exception $e) {
+ var_dump($e->getMessage());
+ var_dump($this->prepareKeyProperties($connection) + $properties);
+ exit;
+ }
+ }
+ }
+
+ public function delete(Db $connection)
+ {
+ $db = $connection->getDbAdapter();
+ $table = 'branched_' . $this->objectTable;
+ $branchCreated = $db->fetchOne($this->filterQuery($db->select()->from($table, 'branch_created'), $connection));
+ // We do not want to nullify all properties, therefore: delete & insert
+ $db->delete($table, $this->prepareWhereString($connection));
+
+ if (! $branchCreated) {
+ // No need to insert a deleted object in case this object lived in this branch only
+ return $db->insert($table, $this->prepareKeyProperties($connection) + [
+ 'branch_deleted' => 'y',
+ 'branch_created' => 'n',
+ ]) === 1;
+ }
+
+ return true;
+ }
+
+ protected function prepareKeyProperties(Db $connection)
+ {
+ return [
+ 'uuid' => $connection->quoteBinary($this->objectUuid->getBytes()),
+ 'branch_uuid' => $connection->quoteBinary($this->branchUuid->getBytes()),
+ ];
+ }
+
+ protected function prepareWhereString(Db $connection)
+ {
+ $db = $connection->getDbAdapter();
+ $objectUuid = $connection->quoteBinary($this->objectUuid->getBytes());
+ $branchUuid = $connection->quoteBinary($this->branchUuid->getBytes());
+
+ return $db->quoteInto('uuid = ?', $objectUuid) . $db->quoteInto(' AND branch_uuid = ?', $branchUuid);
+ }
+
+ /**
+ * @param \Zend_Db_Select $query
+ * @param Db $connection
+ * @return \Zend_Db_Select
+ */
+ protected function filterQuery(\Zend_Db_Select $query, Db $connection)
+ {
+ return $query->where('uuid = ?', $connection->quoteBinary($this->objectUuid->getBytes()))
+ ->where('branch_uuid = ?', $connection->quoteBinary($this->branchUuid->getBytes()));
+ }
+
+ protected function prepareChangedProperties()
+ {
+ $properties = (array) $this->changes;
+
+ foreach (BranchSettings::ENCODED_DICTIONARIES as $property) {
+ $this->combineFlatDictionaries($properties, $property);
+ }
+ foreach (BranchSettings::ENCODED_DICTIONARIES as $property) {
+ if (isset($properties[$property])) {
+ $properties[$property] = Json::encode($properties[$property]);
+ }
+ }
+ foreach (BranchSettings::ENCODED_ARRAYS as $property) {
+ if (isset($properties[$property])) {
+ $properties[$property] = Json::encode($properties[$property]);
+ }
+ }
+ foreach (BranchSettings::RELATED_SETS as $property) {
+ if (isset($properties[$property])) {
+ $properties[$property] = Json::encode($properties[$property]);
+ }
+ }
+ $setNull = [];
+ if (array_key_exists('disabled', $properties) && $properties['disabled'] === null) {
+ unset($properties['disabled']);
+ }
+ foreach ($properties as $key => $value) {
+ if ($value === null) {
+ $setNull[] = $key;
+ }
+ }
+ if (empty($setNull)) {
+ $properties['set_null'] = null;
+ } else {
+ $properties['set_null'] = Json::encode($setNull);
+ }
+
+ return $properties;
+ }
+
+ protected function combineFlatDictionaries(&$properties, $prefix)
+ {
+ $vars = [];
+ $length = strlen($prefix) + 1;
+ foreach ($properties as $key => $value) {
+ if (substr($key, 0, $length) === "$prefix.") {
+ $vars[substr($key, $length)] = $value;
+ }
+ }
+ if (! empty($vars)) {
+ foreach (array_keys($vars) as $key) {
+ unset($properties["$prefix.$key"]);
+ }
+ $properties[$prefix] = (object) $vars;
+ }
+ }
+
+ public function applyActivity(BranchActivity $activity, Db $connection)
+ {
+ if ($activity->isActionDelete()) {
+ throw new \RuntimeException('Cannot apply a delete action');
+ }
+ if ($activity->isActionCreate()) {
+ if ($this->hasBeenTouchedByBranch()) {
+ throw new \RuntimeException('Cannot apply a CREATE activity to an already branched object');
+ } else {
+ $this->branchCreated = true;
+ }
+ }
+
+ foreach ($activity->getModifiedProperties()->jsonSerialize() as $key => $value) {
+ $this->changes[$key] = $value;
+ }
+
+ return $this;
+ }
+
+ /**
+ * @param Db $connection
+ * @param string $objectTable
+ * @param UuidInterface $uuid
+ * @param Branch $branch
+ * @return static
+ * @throws NotFoundError
+ */
+ public static function load(Db $connection, $objectTable, UuidInterface $uuid, Branch $branch)
+ {
+ $object = static::loadOptional($connection, $objectTable, $uuid, $branch->getUuid());
+ if ($object->getOriginalDbObject() === null && ! $object->hasBeenTouchedByBranch()) {
+ throw new NotFoundError('Not found');
+ }
+
+ return $object;
+ }
+
+ /**
+ * @return bool
+ */
+ public function hasBeenTouchedByBranch()
+ {
+ return $this->loadedAsBranchedObject;
+ }
+
+ /**
+ * @return bool
+ */
+ public function hasBeenDeletedByBranch()
+ {
+ return $this->branchDeleted;
+ }
+
+ /**
+ * @return bool
+ */
+ public function hasBeenCreatedByBranch()
+ {
+ return $this->branchCreated;
+ }
+
+ /**
+ * @return ?DbObject
+ */
+ public function getOriginalDbObject()
+ {
+ return $this->object;
+ }
+
+ /**
+ * @return ?DbObject
+ */
+ public function getBranchedDbObject(Db $connection)
+ {
+ if ($this->object) {
+ $branched = DbObjectTypeRegistry::newObject($this->objectTable, [], $connection);
+ // object_type first, to avoid:
+ // I can only assign for applied objects or objects with native support for assignments
+ if ($this->object->hasProperty('object_type')) {
+ $branched->set('object_type', $this->object->get('object_type'));
+ }
+ $branched->set('id', $this->object->get('id'));
+ $branched->set('uuid', $this->object->get('uuid'));
+ foreach ((array) $this->object->toPlainObject(false, true) as $key => $value) {
+ if ($key === 'object_type') {
+ continue;
+ }
+ $branched->set($key, $value);
+ }
+ } else {
+ $branched = DbObjectTypeRegistry::newObject($this->objectTable, [], $connection);
+ $branched->setUniqueId($this->objectUuid);
+ }
+ if ($this->changes === null) {
+ return $branched;
+ }
+ foreach ($this->changes as $key => $value) {
+ if ($key === 'set_null') {
+ if ($value !== null) {
+ foreach ($value as $k) {
+ $branched->set($k, null);
+ }
+ }
+ } else {
+ $branched->set($key, $value);
+ }
+ }
+
+ return $branched;
+ }
+
+ /**
+ * @return UuidInterface
+ */
+ public function getBranchUuid()
+ {
+ return $this->branchUuid;
+ }
+
+ /**
+ * @param Db $connection
+ * @param string $table
+ * @param UuidInterface $uuid
+ * @param ?UuidInterface $branchUuid
+ * @return static
+ */
+ protected static function loadOptional(
+ Db $connection,
+ $table,
+ UuidInterface $uuid,
+ UuidInterface $branchUuid = null
+ ) {
+ $class = DbObjectTypeRegistry::classByType($table);
+ if ($row = static::optionalTableRowByUuid($connection, $table, $uuid)) {
+ $object = $class::fromDbRow((array) $row, $connection);
+ } else {
+ $object = null;
+ }
+
+ $self = new static();
+ $self->object = $object;
+ $self->objectUuid = $uuid;
+ $self->branchUuid = $branchUuid;
+ $self->objectTable = $table;
+
+ if ($branchUuid && $row = static::optionalBranchedTableRowByUuid($connection, $table, $uuid, $branchUuid)) {
+ if ($row->branch_deleted === 'y') {
+ $self->branchDeleted = true;
+ } elseif ($row->branch_created === 'y') {
+ $self->branchCreated = true;
+ }
+ $self->changes = BranchSettings::normalizeBranchedObjectFromDb($row);
+ $self->loadedAsBranchedObject = true;
+ }
+
+ return $self;
+ }
+
+ public static function exists(
+ Db $connection,
+ $table,
+ UuidInterface $uuid,
+ UuidInterface $branchUuid = null
+ ) {
+ if (static::optionalTableRowByUuid($connection, $table, $uuid)) {
+ return true;
+ }
+
+ if ($branchUuid && static::optionalBranchedTableRowByUuid($connection, $table, $uuid, $branchUuid)) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * @param Db $connection
+ * @param string $table
+ * @param UuidInterface $uuid
+ * @return stdClass|boolean
+ */
+ protected static function optionalTableRowByUuid(Db $connection, $table, UuidInterface $uuid)
+ {
+ $db = $connection->getDbAdapter();
+
+ return $db->fetchRow(
+ $db->select()->from($table)->where('uuid = ?', $connection->quoteBinary($uuid->getBytes()))
+ );
+ }
+
+ /**
+ * @param Db $connection
+ * @param string $table
+ * @param UuidInterface $uuid
+ * @return stdClass|boolean
+ */
+ protected static function optionalBranchedTableRowByUuid(
+ Db $connection,
+ $table,
+ UuidInterface $uuid,
+ UuidInterface $branchUuid
+ ) {
+ $db = $connection->getDbAdapter();
+
+ $query = $db->select()
+ ->from("branched_$table")
+ ->where('uuid = ?', $connection->quoteBinary($uuid->getBytes()))
+ ->where('branch_uuid = ?', $connection->quoteBinary($branchUuid->getBytes()));
+
+ return $db->fetchRow($query);
+ }
+
+ protected function __construct()
+ {
+ }
+}
diff --git a/library/Director/Db/Branch/MergeError.php b/library/Director/Db/Branch/MergeError.php
new file mode 100644
index 0000000..45c7b5e
--- /dev/null
+++ b/library/Director/Db/Branch/MergeError.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace Icinga\Module\Director\Db\Branch;
+
+use Exception;
+use gipfl\Translation\TranslationHelper;
+
+abstract class MergeError extends Exception
+{
+ use TranslationHelper;
+
+ /** @var BranchActivity */
+ protected $activity;
+
+ public function __construct(BranchActivity $activity)
+ {
+ $this->activity = $activity;
+ parent::__construct($this->prepareMessage());
+ }
+
+ abstract protected function prepareMessage();
+
+ public function getObjectTypeName()
+ {
+ return preg_replace('/^icinga_/', '', $this->getActivity()->getObjectTable());
+ }
+
+ public function getNiceObjectName()
+ {
+ return $this->activity->getObjectName();
+ }
+
+ public function getActivity()
+ {
+ return $this->activity;
+ }
+}
diff --git a/library/Director/Db/Branch/MergeErrorDeleteMissingObject.php b/library/Director/Db/Branch/MergeErrorDeleteMissingObject.php
new file mode 100644
index 0000000..71f89d1
--- /dev/null
+++ b/library/Director/Db/Branch/MergeErrorDeleteMissingObject.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace Icinga\Module\Director\Db\Branch;
+
+class MergeErrorDeleteMissingObject extends MergeError
+{
+ public function prepareMessage()
+ {
+ return sprintf(
+ $this->translate('Cannot delete %s %s, it does not exist'),
+ $this->getObjectTypeName(),
+ $this->getNiceObjectName()
+ );
+ }
+}
diff --git a/library/Director/Db/Branch/MergeErrorModificationForMissingObject.php b/library/Director/Db/Branch/MergeErrorModificationForMissingObject.php
new file mode 100644
index 0000000..fa4e724
--- /dev/null
+++ b/library/Director/Db/Branch/MergeErrorModificationForMissingObject.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace Icinga\Module\Director\Db\Branch;
+
+class MergeErrorModificationForMissingObject extends MergeError
+{
+ public function prepareMessage()
+ {
+ return sprintf(
+ $this->translate('Cannot apply modification for %s %s, object does not exist'),
+ $this->getObjectTypeName(),
+ $this->getNiceObjectName()
+ );
+ }
+}
diff --git a/library/Director/Db/Branch/MergeErrorRecreateOnMerge.php b/library/Director/Db/Branch/MergeErrorRecreateOnMerge.php
new file mode 100644
index 0000000..0bb8c40
--- /dev/null
+++ b/library/Director/Db/Branch/MergeErrorRecreateOnMerge.php
@@ -0,0 +1,15 @@
+<?php
+
+namespace Icinga\Module\Director\Db\Branch;
+
+class MergeErrorRecreateOnMerge extends MergeError
+{
+ public function prepareMessage()
+ {
+ return sprintf(
+ $this->translate('Cannot recreate %s %s'),
+ $this->getObjectTypeName(),
+ $this->getNiceObjectName()
+ );
+ }
+}
diff --git a/library/Director/Db/Branch/PlainObjectPropertyDiff.php b/library/Director/Db/Branch/PlainObjectPropertyDiff.php
new file mode 100644
index 0000000..0256798
--- /dev/null
+++ b/library/Director/Db/Branch/PlainObjectPropertyDiff.php
@@ -0,0 +1,50 @@
+<?php
+
+namespace Icinga\Module\Director\Db\Branch;
+
+class PlainObjectPropertyDiff
+{
+ public static function calculate(array $old = null, array $new = null)
+ {
+ if ($new === null) {
+ throw new \RuntimeException('Cannot diff for delete');
+ }
+ if ($old === null) {
+ foreach (BranchSettings::ENCODED_DICTIONARIES as $property) {
+ self::flattenProperty($new, $property);
+ }
+
+ return $new;
+ }
+ $unchangedKeys = [];
+ foreach (BranchSettings::ENCODED_DICTIONARIES as $property) {
+ self::flattenProperty($old, $property);
+ self::flattenProperty($new, $property);
+ }
+ foreach ($old as $key => $value) {
+ if (array_key_exists($key, $new)) {
+ if ($value === $new[$key]) {
+ $unchangedKeys[] = $key;
+ }
+ } else {
+ $new[$key] = null;
+ }
+ }
+ foreach ($unchangedKeys as $key) {
+ unset($new[$key]);
+ }
+
+ return $new;
+ }
+
+ protected static function flattenProperty(array &$properties, $property)
+ {
+ // TODO: dots in varnames -> throw or escape?
+ if (isset($properties[$property])) {
+ foreach ((array) $properties[$property] as $key => $value) {
+ $properties["$property.$key"] = $value;
+ }
+ unset($properties[$property]);
+ }
+ }
+}
diff --git a/library/Director/Db/Branch/UuidLookup.php b/library/Director/Db/Branch/UuidLookup.php
new file mode 100644
index 0000000..b340e07
--- /dev/null
+++ b/library/Director/Db/Branch/UuidLookup.php
@@ -0,0 +1,141 @@
+<?php
+
+namespace Icinga\Module\Director\Db\Branch;
+
+use Icinga\Exception\NotFoundError;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Objects\IcingaHost;
+use Icinga\Module\Director\Objects\IcingaServiceSet;
+use Ramsey\Uuid\Uuid;
+use Ramsey\Uuid\UuidInterface;
+use function is_int;
+use function is_resource;
+use function is_string;
+
+class UuidLookup
+{
+ /**
+ * @param Db $connection
+ * @param Branch $branch
+ * @param string $objectType
+ * @param int|string $key
+ * @param IcingaHost|null $host
+ * @param IcingaServiceSet $set
+ * @return ?UuidInterface
+ */
+ public static function findServiceUuid(
+ Db $connection,
+ Branch $branch,
+ $objectType = null,
+ $key = null,
+ IcingaHost $host = null,
+ IcingaServiceSet $set = null
+ ) {
+ $db = $connection->getDbAdapter();
+ $query = $db->select()->from('icinga_service', 'uuid');
+ if ($objectType) {
+ $query->where('object_type = ?', $objectType);
+ }
+ $query = self::addKeyToQuery($connection, $query, $key);
+ if ($host) {
+ $query->where('host_id = ?', $host->get('id'));
+ }
+ if ($set) {
+ $query->where('service_set_id = ?', $set->get('id'));
+ }
+ $uuid = self::fetchOptionalUuid($connection, $query);
+
+ if ($uuid === null && $branch->isBranch()) {
+ // TODO: use different tables?
+ $query = $db->select()
+ ->from('branched_icinga_service', 'uuid')
+ ->where('branch_uuid = ?', $connection->quoteBinary($branch->getUuid()->getBytes()));
+ if ($objectType) {
+ $query->where('object_type = ?', $objectType);
+ }
+ $query = self::addKeyToQuery($connection, $query, $key);
+ if ($host) {
+ // TODO: uuid?
+ $query->where('host = ?', $host->getObjectName());
+ }
+ if ($set) {
+ $query->where('service_set = ?', $set->getObjectName());
+ }
+
+ $uuid = self::fetchOptionalUuid($connection, $query);
+ }
+
+ return $uuid;
+ }
+
+ /**
+ * @param int|string|array $key
+ * @param string $table
+ * @param Db $connection
+ * @param Branch $branch
+ * @return UuidInterface
+ * @throws NotFoundError
+ */
+ public static function requireUuidForKey($key, $table, Db $connection, Branch $branch)
+ {
+ $uuid = self::findUuidForKey($key, $table, $connection, $branch);
+ if ($uuid === null) {
+ throw new NotFoundError('No such object available');
+ }
+
+ return $uuid;
+ }
+
+ /**
+ * @param int|string|array $key
+ * @param string $table
+ * @param Db $connection
+ * @param Branch $branch
+ * @return ?UuidInterface
+ */
+ public static function findUuidForKey($key, $table, Db $connection, Branch $branch)
+ {
+ $db = $connection->getDbAdapter();
+ $query = self::addKeyToQuery($connection, $db->select()->from($table, 'uuid'), $key);
+ $uuid = self::fetchOptionalUuid($connection, $query);
+ if ($uuid === null && $branch->isBranch()) {
+ if (is_array($key) && isset($key['host_id'])) {
+ $key['host'] = IcingaHost::load($key['host_id'], $connection)->getObjectName();
+ unset($key['host_id']);
+ }
+ $query = self::addKeyToQuery($connection, $db->select()->from("branched_$table", 'uuid'), $key);
+ $query->where('branch_uuid = ?', $connection->quoteBinary($branch->getUuid()->getBytes()));
+ $uuid = self::fetchOptionalUuid($connection, $query);
+ }
+
+ return $uuid;
+ }
+
+ protected static function addKeyToQuery(Db $connection, $query, $key)
+ {
+ if (is_int($key)) {
+ $query->where('id = ?', $key);
+ } elseif (is_string($key)) {
+ $query->where('object_name = ?', $key);
+ } else {
+ foreach ($key as $k => $v) {
+ $query->where($connection->getDbAdapter()->quoteIdentifier($k) . ' = ?', $v);
+ }
+ }
+
+ return $query;
+ }
+
+ protected static function fetchOptionalUuid(Db $connection, $query)
+ {
+ $result = $connection->getDbAdapter()->fetchOne($query);
+ if (is_resource($result)) {
+ $result = stream_get_contents($result);
+ }
+ if (is_string($result)) {
+ return Uuid::fromBytes($result);
+ }
+
+ return null;
+ }
+}
diff --git a/library/Director/Db/Cache/CustomVariableCache.php b/library/Director/Db/Cache/CustomVariableCache.php
new file mode 100644
index 0000000..243ecae
--- /dev/null
+++ b/library/Director/Db/Cache/CustomVariableCache.php
@@ -0,0 +1,84 @@
+<?php
+
+namespace Icinga\Module\Director\Db\Cache;
+
+use Icinga\Application\Benchmark;
+use Icinga\Module\Director\CustomVariable\CustomVariables;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Objects\IcingaObject;
+
+class CustomVariableCache
+{
+ protected $type;
+
+ protected $rowsById = array();
+
+ protected $varsById = array();
+
+ public function __construct(IcingaObject $object)
+ {
+ Benchmark::measure('Initializing CustomVariableCache');
+ $connection = $object->getConnection();
+ $db = $connection->getDbAdapter();
+
+ $columns = array(
+ 'id' => sprintf('v.%s', $object->getVarsIdColumn()),
+ 'varname' => 'v.varname',
+ 'varvalue' => 'v.varvalue',
+ 'format' => 'v.format',
+ 'checksum' => '(NULL)',
+ );
+
+ if ($connection->isPgsql()) {
+ if ($connection->hasPgExtension('pgcrypto')) {
+ $columns['checksum'] = "DIGEST(v.varvalue || ';' || v.format, 'sha1')";
+ }
+ } else {
+ $columns['checksum'] = "UNHEX(SHA1(v.varvalue || ';' || v.format))";
+ }
+
+ $query = $db->select()->from(
+ array('v' => $object->getVarsTableName()),
+ $columns
+ );
+
+ foreach ($db->fetchAll($query) as $row) {
+ $id = $row->id;
+ unset($row->id);
+
+ if (is_resource($row->checksum)) {
+ $row->checksum = stream_get_contents($row->checksum);
+ }
+
+ if (array_key_exists($id, $this->rowsById)) {
+ $this->rowsById[$id][] = $row;
+ } else {
+ $this->rowsById[$id] = array($row);
+ }
+ }
+
+ Benchmark::measure('Filled CustomVariableCache');
+ }
+
+ public function getVarsForObject(IcingaObject $object)
+ {
+ $id = $object->id;
+
+ if (array_key_exists($id, $this->rowsById)) {
+ if (! array_key_exists($id, $this->varsById)) {
+ $this->varsById[$id] = CustomVariables::forStoredRows(
+ $this->rowsById[$id]
+ );
+ }
+
+ return $this->varsById[$id];
+ } else {
+ return new CustomVariables();
+ }
+ }
+
+ public function __destruct()
+ {
+ unset($this->db);
+ }
+}
diff --git a/library/Director/Db/Cache/GroupMembershipCache.php b/library/Director/Db/Cache/GroupMembershipCache.php
new file mode 100644
index 0000000..d6d9e8b
--- /dev/null
+++ b/library/Director/Db/Cache/GroupMembershipCache.php
@@ -0,0 +1,104 @@
+<?php
+
+namespace Icinga\Module\Director\Db\Cache;
+
+use Icinga\Application\Benchmark;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Objects\IcingaObject;
+
+class GroupMembershipCache
+{
+ protected $type;
+
+ protected $table;
+
+ protected $groupClass;
+
+ protected $memberships;
+
+ /** @var Db Director database connection */
+ protected $connection;
+
+ public function __construct(IcingaObject $object)
+ {
+ $this->table = $object->getTableName();
+ $this->type = $object->getShortTableName();
+
+ $this->groupClass = 'Icinga\\Module\\Director\\Objects\\Icinga'
+ . ucfirst($this->type) . 'Group';
+
+ Benchmark::measure('Initializing GroupMemberShipCache');
+ $this->connection = $object->getConnection();
+ $this->loadAllMemberships();
+ Benchmark::measure('Filled GroupMemberShipCache');
+ }
+
+ protected function loadAllMemberships()
+ {
+ $db = $this->connection->getDbAdapter();
+ $this->memberships = array();
+
+ $type = $this->type;
+ $table = $this->table;
+
+ $query = $db->select()->from(
+ array('o' => $table),
+ array(
+ 'object_id' => 'o.id',
+ 'group_id' => 'g.id',
+ 'group_name' => 'g.object_name',
+ )
+ )->join(
+ array('go' => $table . 'group_' . $type),
+ 'o.id = go.' . $type . '_id',
+ array()
+ )->join(
+ array('g' => $table . 'group'),
+ 'go.' . $type . 'group_id = g.id',
+ array()
+ )->order('g.object_name');
+
+ foreach ($db->fetchAll($query) as $row) {
+ if (! array_key_exists($row->object_id, $this->memberships)) {
+ $this->memberships[$row->object_id] = array();
+ }
+
+ $this->memberships[$row->object_id][$row->group_id] = $row->group_name;
+ }
+ }
+
+ public function listGroupNamesForObject(IcingaObject $object)
+ {
+ if (array_key_exists($object->id, $this->memberships)) {
+ return array_values($this->memberships[$object->id]);
+ }
+
+ return array();
+ }
+
+ public function listGroupIdsForObject(IcingaObject $object)
+ {
+ if (array_key_exists($object->id, $this->memberships)) {
+ return array_keys($this->memberships[$object->id]);
+ }
+
+ return array();
+ }
+
+ public function getGroupsForObject(IcingaObject $object)
+ {
+ $groups = array();
+ $class = $this->groupClass;
+ foreach ($this->listGroupIdsForObject($object) as $id) {
+ $object = $class::loadWithAutoIncId($id, $this->connection);
+ $groups[$object->object_name] = $object;
+ }
+
+ return $groups;
+ }
+
+ public function __destruct()
+ {
+ unset($this->connection);
+ }
+}
diff --git a/library/Director/Db/Cache/PrefetchCache.php b/library/Director/Db/Cache/PrefetchCache.php
new file mode 100644
index 0000000..aa9f950
--- /dev/null
+++ b/library/Director/Db/Cache/PrefetchCache.php
@@ -0,0 +1,166 @@
+<?php
+
+namespace Icinga\Module\Director\Db\Cache;
+
+use Icinga\Module\Director\CustomVariable\CustomVariable;
+use Icinga\Module\Director\Data\Db\DbObject;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Icinga\Module\Director\Resolver\HostServiceBlacklist;
+use Icinga\Module\Director\Resolver\TemplateTree;
+use LogicException;
+
+/**
+ * Central prefetch cache
+ *
+ * Might be improved, accept various caches based on an interface and then
+ * finally replace prefetch logic in DbObject itself. This would also allow
+ * to get rid of IcingaObject-related code in this place
+ */
+class PrefetchCache
+{
+ protected $db;
+
+ protected static $instance;
+
+ protected $varsCaches = array();
+
+ protected $groupsCaches = array();
+
+ protected $templateResolvers = array();
+
+ protected $renderedVars = array();
+
+ protected $templateTrees = array();
+
+ protected $hostServiceBlacklist;
+
+ public static function initialize(Db $db)
+ {
+ self::forget();
+ self::$instance = new static($db);
+ }
+
+ protected function __construct(Db $db)
+ {
+ $this->db = $db;
+ }
+
+ /**
+ * @throws LogicException
+ *
+ * @return self
+ */
+ public static function instance()
+ {
+ if (static::$instance === null) {
+ throw new LogicException('Prefetch cache has not been loaded');
+ }
+
+ return static::$instance;
+ }
+
+ public static function forget()
+ {
+ DbObject::clearAllPrefetchCaches();
+ self::$instance = null;
+ }
+
+ public static function shouldBeUsed()
+ {
+ return self::$instance !== null;
+ }
+
+ public function vars(IcingaObject $object)
+ {
+ return $this->varsCache($object)->getVarsForObject($object);
+ }
+
+ public function groups(IcingaObject $object)
+ {
+ return $this->groupsCache($object)->getGroupsForObject($object);
+ }
+
+ /* Hint: not implemented, this happens in DbObject right now
+ public function byObjectType($type)
+ {
+ if (! array_key_exists($type, $this->caches)) {
+ $this->caches[$type] = new ObjectCache($type);
+ }
+
+ return $this->caches[$type];
+ }
+ */
+
+ public function renderVar(CustomVariable $var, $renderExpressions = false)
+ {
+ $checksum = $var->getChecksum();
+ if (null === $checksum) {
+ return $var->toConfigString($renderExpressions);
+ } else {
+ $checksum .= (int) $renderExpressions;
+ if (! array_key_exists($checksum, $this->renderedVars)) {
+ $this->renderedVars[$checksum] = $var->toConfigString($renderExpressions);
+ }
+
+ return $this->renderedVars[$checksum];
+ }
+ }
+
+ public function hostServiceBlacklist()
+ {
+ if ($this->hostServiceBlacklist === null) {
+ $this->hostServiceBlacklist = new HostServiceBlacklist($this->db);
+ $this->hostServiceBlacklist->preloadMappings();
+ }
+
+ return $this->hostServiceBlacklist;
+ }
+
+ /**
+ * @param IcingaObject $object
+ * @return CustomVariableCache
+ */
+ protected function varsCache(IcingaObject $object)
+ {
+ $key = $object->getShortTableName();
+
+ if (! array_key_exists($key, $this->varsCaches)) {
+ $this->varsCaches[$key] = new CustomVariableCache($object);
+ }
+
+ return $this->varsCaches[$key];
+ }
+
+ protected function groupsCache(IcingaObject $object)
+ {
+ $key = $object->getShortTableName();
+
+ if (! array_key_exists($key, $this->groupsCaches)) {
+ $this->groupsCaches[$key] = new GroupMembershipCache($object);
+ }
+
+ return $this->groupsCaches[$key];
+ }
+
+ protected function templateTree(IcingaObject $object)
+ {
+ $key = $object->getShortTableName();
+ if (! array_key_exists($key, $this->templateTrees)) {
+ $this->templateTrees[$key] = new TemplateTree(
+ $key,
+ $object->getConnection()
+ );
+ }
+
+ return $this->templateTrees[$key];
+ }
+
+ public function __destruct()
+ {
+ unset($this->groupsCaches);
+ unset($this->varsCaches);
+ unset($this->templateResolvers);
+ unset($this->renderedVars);
+ }
+}
diff --git a/library/Director/Db/DbSelectParenthesis.php b/library/Director/Db/DbSelectParenthesis.php
new file mode 100644
index 0000000..191ad85
--- /dev/null
+++ b/library/Director/Db/DbSelectParenthesis.php
@@ -0,0 +1,24 @@
+<?php
+
+namespace Icinga\Module\Director\Db;
+
+class DbSelectParenthesis extends \Zend_Db_Expr
+{
+ protected $select;
+
+ public function __construct(\Zend_Db_Select $select)
+ {
+ parent::__construct('');
+ $this->select = $select;
+ }
+
+ public function getSelect()
+ {
+ return $this->select;
+ }
+
+ public function __toString()
+ {
+ return '(' . $this->select . ')';
+ }
+}
diff --git a/library/Director/Db/DbUtil.php b/library/Director/Db/DbUtil.php
new file mode 100644
index 0000000..f98e213
--- /dev/null
+++ b/library/Director/Db/DbUtil.php
@@ -0,0 +1,96 @@
+<?php
+
+namespace Icinga\Module\Director\Db;
+
+use gipfl\ZfDb\Adapter\Adapter;
+use gipfl\ZfDb\Adapter\Pdo\Pgsql;
+use gipfl\ZfDb\Expr;
+use Zend_Db_Adapter_Abstract;
+use Zend_Db_Adapter_Pdo_Pgsql;
+use Zend_Db_Expr;
+use function bin2hex;
+use function is_array;
+use function is_resource;
+use function stream_get_contents;
+
+class DbUtil
+{
+ public static function binaryResult($value)
+ {
+ if (is_resource($value)) {
+ return stream_get_contents($value);
+ }
+
+ return $value;
+ }
+
+
+ /**
+ * @param string|array $binary
+ * @param Zend_Db_Adapter_Abstract $db
+ * @return Zend_Db_Expr|Zend_Db_Expr[]
+ */
+ public static function quoteBinaryLegacy($binary, $db)
+ {
+ if (is_array($binary)) {
+ return static::quoteArray($binary, 'quoteBinaryLegacy', $db);
+ }
+
+ if ($binary === null) {
+ return null;
+ }
+
+ if ($db instanceof Zend_Db_Adapter_Pdo_Pgsql) {
+ return new Zend_Db_Expr("'\\x" . bin2hex($binary) . "'");
+ }
+
+ return new Zend_Db_Expr('0x' . bin2hex($binary));
+ }
+
+ /**
+ * @param string|array $binary
+ * @param Adapter $db
+ * @return Expr|Expr[]
+ */
+ public static function quoteBinary($binary, $db)
+ {
+ if (is_array($binary)) {
+ return static::quoteArray($binary, 'quoteBinary', $db);
+ }
+
+ if ($binary === null) {
+ return null;
+ }
+
+ if ($db instanceof Pgsql) {
+ return new Expr("'\\x" . bin2hex($binary) . "'");
+ }
+
+ return new Expr('0x' . bin2hex($binary));
+ }
+
+ /**
+ * @param string|array $binary
+ * @param Adapter|Zend_Db_Adapter_Abstract $db
+ * @return Expr|Zend_Db_Expr|Expr[]|Zend_Db_Expr[]
+ */
+ public static function quoteBinaryCompat($binary, $db)
+ {
+ if ($db instanceof Adapter) {
+ return static::quoteBinary($binary, $db);
+ }
+
+ return static::quoteBinaryLegacy($binary, $db);
+ }
+
+ protected static function quoteArray($array, $method, $db)
+ {
+ $result = [];
+ foreach ($array as $bin) {
+ $quoted = static::$method($bin, $db);
+ $result[] = $quoted;
+ }
+
+ return $result;
+ }
+}
diff --git a/library/Director/Db/HostMembershipHousekeeping.php b/library/Director/Db/HostMembershipHousekeeping.php
new file mode 100644
index 0000000..3a2de05
--- /dev/null
+++ b/library/Director/Db/HostMembershipHousekeeping.php
@@ -0,0 +1,8 @@
+<?php
+
+namespace Icinga\Module\Director\Db;
+
+class HostMembershipHousekeeping extends MembershipHousekeeping
+{
+ protected $type = 'host';
+}
diff --git a/library/Director/Db/Housekeeping.php b/library/Director/Db/Housekeeping.php
new file mode 100644
index 0000000..82fd6b9
--- /dev/null
+++ b/library/Director/Db/Housekeeping.php
@@ -0,0 +1,249 @@
+<?php
+
+namespace Icinga\Module\Director\Db;
+
+use Icinga\Exception\NotFoundError;
+use Icinga\Module\Director\Db;
+
+class Housekeeping
+{
+ /**
+ * @var Db
+ */
+ protected $connection;
+
+ /**
+ * @var \Zend_Db_Adapter_Abstract
+ */
+ protected $db;
+
+ /**
+ * @var int
+ */
+ protected $version;
+
+ public function __construct(Db $connection)
+ {
+ $this->connection = $connection;
+ $this->db = $connection->getDbAdapter();
+ }
+
+ public function getTaskSummary()
+ {
+ $summary = array();
+ foreach ($this->listTasks() as $name => $title) {
+ $func = 'count' . ucfirst($name);
+ $summary[$name] = (object) array(
+ 'name' => $name,
+ 'title' => $title,
+ 'count' => $this->$func()
+ );
+ }
+
+ return $summary;
+ }
+
+ public function listTasks()
+ {
+ return array(
+ 'oldUndeployedConfigs' => N_('Undeployed configurations'),
+ 'unusedFiles' => N_('Unused rendered files'),
+ 'unlinkedImportedRowSets' => N_('Unlinked imported row sets'),
+ 'unlinkedImportedRows' => N_('Unlinked imported rows'),
+ 'unlinkedImportedProperties' => N_('Unlinked imported properties'),
+ 'resolveCache' => N_('(Host) group resolve cache'),
+ );
+ }
+
+ public function getPendingTaskSummary()
+ {
+ return array_filter(
+ $this->getTaskSummary(),
+ function ($task) {
+ return $task->count > 0;
+ }
+ );
+ }
+
+ public function hasPendingTasks()
+ {
+ return count($this->getPendingTaskSummary()) > 0;
+ }
+
+ public function runAllTasks()
+ {
+ $result = array();
+
+ foreach ($this->listTasks() as $name => $task) {
+ $this->runTask($name);
+ }
+
+ return $this;
+ }
+
+ public function runTask($name)
+ {
+ $func = 'wipe' . ucfirst($name);
+ if (!method_exists($this, $func)) {
+ throw new NotFoundError(
+ 'There is no such task: %s',
+ $name
+ );
+ }
+
+ return $this->$func();
+ }
+
+ public function countOldUndeployedConfigs()
+ {
+ $conn = $this->connection;
+ $lastActivity = $conn->getLastActivityChecksum();
+
+ $sql = 'SELECT COUNT(*) FROM director_generated_config c'
+ . ' LEFT JOIN director_deployment_log d ON c.checksum = d.config_checksum'
+ . ' WHERE d.config_checksum IS NULL'
+ . ' AND ? != ' . $conn->dbHexFunc('c.last_activity_checksum');
+
+ return $this->db->fetchOne($sql, $lastActivity);
+ }
+
+ public function wipeOldUndeployedConfigs()
+ {
+ $conn = $this->connection;
+ $lastActivity = $conn->getLastActivityChecksum();
+
+ if ($this->connection->isPgsql()) {
+ $sql = 'DELETE FROM director_generated_config'
+ . ' USING director_generated_config AS c'
+ . ' LEFT JOIN director_deployment_log d ON c.checksum = d.config_checksum'
+ . ' WHERE director_generated_config.checksum = c.checksum'
+ . ' AND d.config_checksum IS NULL'
+ . ' AND ? != ' . $conn->dbHexFunc('c.last_activity_checksum');
+ } else {
+ $sql = 'DELETE c.* FROM director_generated_config c'
+ . ' LEFT JOIN director_deployment_log d ON c.checksum = d.config_checksum'
+ . ' WHERE d.config_checksum IS NULL'
+ . ' AND ? != ' . $conn->dbHexFunc('c.last_activity_checksum');
+ }
+
+ return $this->db->query($sql, $lastActivity);
+ }
+
+ public function countUnusedFiles()
+ {
+ $sql = 'SELECT COUNT(*) FROM director_generated_file f'
+ . ' LEFT JOIN director_generated_config_file cf ON f.checksum = cf.file_checksum'
+ . ' WHERE cf.file_checksum IS NULL';
+
+ return $this->db->fetchOne($sql);
+ }
+
+ public function wipeUnusedFiles()
+ {
+ if ($this->connection->isPgsql()) {
+ $sql = 'DELETE FROM director_generated_file'
+ . ' USING director_generated_file AS f'
+ . ' LEFT JOIN director_generated_config_file cf ON f.checksum = cf.file_checksum'
+ . ' WHERE director_generated_file.checksum = f.checksum'
+ . ' AND cf.file_checksum IS NULL';
+ } else {
+ $sql = 'DELETE f FROM director_generated_file f'
+ . ' LEFT JOIN director_generated_config_file cf ON f.checksum = cf.file_checksum'
+ . ' WHERE cf.file_checksum IS NULL';
+ }
+
+ return $this->db->exec($sql);
+ }
+
+ public function countUnlinkedImportedRowSets()
+ {
+ $sql = 'SELECT COUNT(*) FROM imported_rowset rs LEFT JOIN import_run r'
+ . ' ON r.rowset_checksum = rs.checksum WHERE r.id IS NULL';
+
+ return $this->db->fetchOne($sql);
+ }
+
+ public function wipeUnlinkedImportedRowSets()
+ {
+ // This one removes imported_rowset and imported_rowset_row
+ // entries no longer used by any historic import<F12>
+ if ($this->connection->isPgsql()) {
+ $sql = 'DELETE FROM imported_rowset'
+ . ' USING imported_rowset AS rs'
+ . ' LEFT JOIN import_run r ON r.rowset_checksum = rs.checksum'
+ . ' WHERE imported_rowset.checksum = rs.checksum'
+ . ' AND r.id IS NULL';
+ } else {
+ $sql = 'DELETE rs.* FROM imported_rowset rs'
+ . ' LEFT JOIN import_run r ON r.rowset_checksum = rs.checksum'
+ . ' WHERE r.id IS NULL';
+ }
+
+ return $this->db->exec($sql);
+ }
+
+ public function countUnlinkedImportedRows()
+ {
+ $sql = 'SELECT COUNT(*) FROM imported_row r LEFT JOIN imported_rowset_row rsr'
+ . ' ON rsr.row_checksum = r.checksum WHERE rsr.row_checksum IS NULL';
+
+ return $this->db->fetchOne($sql);
+ }
+
+ public function wipeUnlinkedImportedRows()
+ {
+ // This query removes imported_row and imported_row_property columns
+ // without related rowset
+ if ($this->connection->isPgsql()) {
+ $sql = 'DELETE FROM imported_row'
+ . ' USING imported_row AS r'
+ . ' LEFT JOIN imported_rowset_row rsr ON rsr.row_checksum = r.checksum'
+ . ' WHERE imported_row.checksum = r.checksum'
+ . ' AND rsr.row_checksum IS NULL';
+ } else {
+ $sql = 'DELETE r.* FROM imported_row r'
+ . ' LEFT JOIN imported_rowset_row rsr ON rsr.row_checksum = r.checksum'
+ . ' WHERE rsr.row_checksum IS NULL';
+ }
+
+ return $this->db->exec($sql);
+ }
+
+ public function countUnlinkedImportedProperties()
+ {
+ $sql = 'SELECT COUNT(*) FROM imported_property p LEFT JOIN imported_row_property rp'
+ . ' ON rp.property_checksum = p.checksum WHERE rp.property_checksum IS NULL';
+
+ return $this->db->fetchOne($sql);
+ }
+
+ public function wipeUnlinkedImportedProperties()
+ {
+ // This query removes unlinked imported properties
+ if ($this->connection->isPgsql()) {
+ $sql = 'DELETE FROM imported_property'
+ . ' USING imported_property AS p'
+ . ' LEFT JOIN imported_row_property rp ON rp.property_checksum = p.checksum'
+ . ' WHERE imported_property.checksum = p.checksum'
+ . ' AND rp.property_checksum IS NULL';
+ } else {
+ $sql = 'DELETE p.* FROM imported_property p'
+ . ' LEFT JOIN imported_row_property rp ON rp.property_checksum = p.checksum'
+ . ' WHERE rp.property_checksum IS NULL';
+ }
+
+ return $this->db->exec($sql);
+ }
+
+ public function countResolveCache()
+ {
+ $helper = MembershipHousekeeping::instance('host', $this->connection);
+ return array_sum($helper->check());
+ }
+
+ public function wipeResolveCache()
+ {
+ $helper = MembershipHousekeeping::instance('host', $this->connection);
+ return $helper->update();
+ }
+}
diff --git a/library/Director/Db/IcingaObjectFilterHelper.php b/library/Director/Db/IcingaObjectFilterHelper.php
new file mode 100644
index 0000000..2eef406
--- /dev/null
+++ b/library/Director/Db/IcingaObjectFilterHelper.php
@@ -0,0 +1,133 @@
+<?php
+
+namespace Icinga\Module\Director\Db;
+
+use Icinga\Module\Director\Objects\IcingaObject;
+use Icinga\Module\Director\Resolver\TemplateTree;
+use InvalidArgumentException;
+use RuntimeException;
+use Zend_Db_Select as ZfSelect;
+
+class IcingaObjectFilterHelper
+{
+ const INHERIT_DIRECT = 'direct';
+ const INHERIT_INDIRECT = 'indirect';
+ const INHERIT_DIRECT_OR_INDIRECT = 'total';
+
+ /**
+ * @param IcingaObject|int|string $id
+ * @return int
+ */
+ public static function wantId($id)
+ {
+ if (is_int($id)) {
+ return $id;
+ } elseif ($id instanceof IcingaObject) {
+ return (int) $id->get('id');
+ } elseif (is_string($id) && ctype_digit($id)) {
+ return (int) $id;
+ } else {
+ throw new InvalidArgumentException(sprintf(
+ 'Numeric ID or IcingaObject expected, got %s',
+ // TODO: just type/class info?
+ var_export($id, 1)
+ ));
+ }
+ }
+
+ /**
+ * @param ZfSelect $query
+ * @param IcingaObject|int|string $template
+ * @param string $tableAlias
+ * @param string $inheritanceType
+ * @return ZfSelect
+ */
+ public static function filterByTemplate(
+ ZfSelect $query,
+ $template,
+ $tableAlias = 'o',
+ $inheritanceType = self::INHERIT_DIRECT
+ ) {
+ $i = $tableAlias . 'i';
+ $o = $tableAlias;
+ $type = $template->getShortTableName();
+ $db = $template->getDb();
+ $id = static::wantId($template);
+ $sub = $db->select()->from(
+ array($i => "icinga_${type}_inheritance"),
+ array('e' => '(1)')
+ )->where("$i.${type}_id = $o.id");
+
+ if ($inheritanceType === self::INHERIT_DIRECT) {
+ $sub->where("$i.parent_${type}_id = ?", $id);
+ } elseif ($inheritanceType === self::INHERIT_INDIRECT
+ || $inheritanceType === self::INHERIT_DIRECT_OR_INDIRECT
+ ) {
+ $tree = new TemplateTree($type, $template->getConnection());
+ $ids = $tree->listDescendantIdsFor($template);
+ if ($inheritanceType === self::INHERIT_DIRECT_OR_INDIRECT) {
+ $ids[] = $template->getAutoincId();
+ }
+
+ if (empty($ids)) {
+ $sub->where('(1 = 0)');
+ } else {
+ $sub->where("$i.parent_${type}_id IN (?)", $ids);
+ }
+ } else {
+ throw new RuntimeException(sprintf(
+ 'Unable to understand "%s" inheritance',
+ $inheritanceType
+ ));
+ }
+
+ return $query->where('EXISTS ?', $sub);
+ }
+
+ public static function filterByHostgroups(
+ ZfSelect $query,
+ $type,
+ $groups,
+ $tableAlias = 'o'
+ ) {
+ if (empty($groups)) {
+ // Asked for an empty set of groups? Give no result
+ $query->where('(1 = 0)');
+ } else {
+ $sub = $query->getAdapter()->select()->from(
+ array('go' => "icinga_${type}group_${type}"),
+ array('e' => '(1)')
+ )->join(
+ array('g' => "icinga_${type}group"),
+ "go.${type}group_id = g.id"
+ )->where("go.${type}_id = ${tableAlias}.id")
+ ->where('g.object_name IN (?)', $groups);
+
+ $query->where('EXISTS ?', $sub);
+ }
+ }
+
+ public static function filterByResolvedHostgroups(
+ ZfSelect $query,
+ $type,
+ $groups,
+ $tableAlias = 'o'
+ ) {
+ if (empty($groups)) {
+ // Asked for an empty set of groups? Give no result
+ $query->where('(1 = 0)');
+ } else {
+ $sub = $query->getAdapter()->select()->from(
+ array('go' => "icinga_${type}group_${type}_resolved"),
+ array('e' => '(1)')
+ )->join(
+ array('g' => "icinga_${type}group"),
+ "go.${type}group_id = g.id",
+ []
+ )->where("go.${type}_id = ${tableAlias}.id")
+ ->where('g.object_name IN (?)', $groups);
+
+ $query->where('EXISTS ?', $sub);
+ }
+ }
+}
diff --git a/library/Director/Db/MembershipHousekeeping.php b/library/Director/Db/MembershipHousekeeping.php
new file mode 100644
index 0000000..4d1ae88
--- /dev/null
+++ b/library/Director/Db/MembershipHousekeeping.php
@@ -0,0 +1,135 @@
+<?php
+
+namespace Icinga\Module\Director\Db;
+
+use Icinga\Module\Director\Application\MemoryLimit;
+use Icinga\Module\Director\Data\Db\DbConnection;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Db\Cache\PrefetchCache;
+use Icinga\Module\Director\Objects\GroupMembershipResolver;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Icinga\Module\Director\Objects\IcingaObjectGroup;
+
+abstract class MembershipHousekeeping
+{
+ protected $type;
+
+ protected $groupType;
+
+ protected $connection;
+
+ /** @var GroupMembershipResolver */
+ protected $resolver;
+
+ /** @var IcingaObject[] */
+ protected $objects;
+
+ /** @var IcingaObjectGroup[] */
+ protected $groups;
+
+ protected $prepared = false;
+
+ protected static $instances = [];
+
+ public function __construct(Db $connection)
+ {
+ $this->connection = $connection;
+
+ if ($this->groupType === null) {
+ $this->groupType = $this->type . 'Group';
+ }
+ }
+
+ /**
+ * @param string $type
+ * @param DbConnection $connection
+ *
+ * @return static
+ */
+ public static function instance($type, $connection)
+ {
+ if (! array_key_exists($type, self::$instances)) {
+ /** @var MembershipHousekeeping $class */
+ $class = 'Icinga\\Module\\Director\\Db\\' . ucfirst($type) . 'MembershipHousekeeping';
+
+ /** @var MembershipHousekeeping $helper */
+ self::$instances[$type] = new $class($connection);
+ }
+
+ return self::$instances[$type];
+ }
+
+ protected function prepare()
+ {
+ if ($this->prepared) {
+ return $this;
+ }
+
+ $this->prepareCache();
+ $this->resolver()->defer();
+
+ $this->objects = IcingaObject::loadAllByType($this->type, $this->connection);
+ $this->resolver()->addObjects($this->objects);
+
+ $this->groups = IcingaObject::loadAllByType($this->groupType, $this->connection);
+ $this->resolver()->addGroups($this->groups);
+
+ MemoryLimit::raiseTo('1024M');
+
+ $this->prepared = true;
+
+ return $this;
+ }
+
+ public function check()
+ {
+ $this->prepare();
+
+ $resolver = $this->resolver()->checkDb();
+
+ return array($resolver->getNewMappings(), $resolver->getOutdatedMappings());
+ }
+
+ public function update()
+ {
+ $this->prepare();
+
+ $this->resolver()->refreshDb(true);
+
+ return true;
+ }
+
+ protected function prepareCache()
+ {
+ PrefetchCache::initialize($this->connection);
+
+ IcingaObject::prefetchAllRelationsByType($this->type, $this->connection);
+ }
+
+ protected function resolver()
+ {
+ if ($this->resolver === null) {
+ /** @var GroupMembershipResolver $class */
+ $class = 'Icinga\\Module\\Director\\Objects\\' . ucfirst($this->type) . 'GroupMembershipResolver';
+ $this->resolver = new $class($this->connection);
+ }
+
+ return $this->resolver;
+ }
+
+ /**
+ * @return IcingaObject[]
+ */
+ public function getObjects()
+ {
+ return $this->objects;
+ }
+
+ /**
+ * @return IcingaObjectGroup[]
+ */
+ public function getGroups()
+ {
+ return $this->groups;
+ }
+}
diff --git a/library/Director/Db/Migration.php b/library/Director/Db/Migration.php
new file mode 100644
index 0000000..5685121
--- /dev/null
+++ b/library/Director/Db/Migration.php
@@ -0,0 +1,70 @@
+<?php
+
+namespace Icinga\Module\Director\Db;
+
+use Exception;
+use Icinga\Module\Director\Data\Db\DbConnection;
+use RuntimeException;
+
+class Migration
+{
+ /**
+ * @var string
+ */
+ protected $sql;
+
+ /**
+ * @var int
+ */
+ protected $version;
+
+ public function __construct($version, $sql)
+ {
+ $this->version = $version;
+ $this->sql = $sql;
+ }
+
+ /**
+ * @param DbConnection $connection
+ * @return $this
+ */
+ public function apply(DbConnection $connection)
+ {
+ /** @var \Zend_Db_Adapter_Pdo_Abstract $db */
+ $db = $connection->getDbAdapter();
+
+ // TODO: this is fragile and depends on accordingly written schema files:
+ $queries = preg_split(
+ '/[\n\s\t]*\;[\n\s\t]+/s',
+ $this->sql,
+ -1,
+ PREG_SPLIT_NO_EMPTY
+ );
+
+ if (empty($queries)) {
+ throw new RuntimeException(sprintf(
+ 'Migration %d has no queries',
+ $this->version
+ ));
+ }
+
+ try {
+ foreach ($queries as $query) {
+ if (preg_match('/^(?:OPTIMIZE|EXECUTE) /i', $query)) {
+ $db->query($query);
+ } else {
+ $db->exec($query);
+ }
+ }
+ } catch (Exception $e) {
+ throw new RuntimeException(sprintf(
+ 'Migration %d failed (%s) while running %s',
+ $this->version,
+ $e->getMessage(),
+ $query
+ ));
+ }
+
+ return $this;
+ }
+}
diff --git a/library/Director/Db/Migrations.php b/library/Director/Db/Migrations.php
new file mode 100644
index 0000000..2310408
--- /dev/null
+++ b/library/Director/Db/Migrations.php
@@ -0,0 +1,239 @@
+<?php
+
+namespace Icinga\Module\Director\Db;
+
+use DirectoryIterator;
+use Exception;
+use Icinga\Application\Icinga;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Module\Director\Data\Db\DbConnection;
+use RuntimeException;
+
+class Migrations
+{
+ /** @var \Zend_Db_Adapter_Abstract */
+ protected $db;
+
+ /**
+ * @var DbConnection
+ */
+ protected $connection;
+
+ protected $migrationsDir;
+
+ public function __construct(DbConnection $connection)
+ {
+ if (version_compare(PHP_VERSION, '5.4.0') < 0) {
+ throw new RuntimeException(
+ "PHP version 5.4.x is required for Director >= 1.4.0, you're running %s."
+ . ' Please either upgrade PHP or downgrade Icinga Director',
+ PHP_VERSION
+ );
+ }
+ $this->connection = $connection;
+ $this->db = $connection->getDbAdapter();
+ }
+
+ public function getLastMigrationNumber()
+ {
+ try {
+ $query = $this->db->select()->from(
+ array('m' => $this->getTableName()),
+ array('schema_version' => 'MAX(schema_version)')
+ );
+
+ return (int) $this->db->fetchOne($query);
+ } catch (Exception $e) {
+ return 0;
+ }
+ }
+
+ protected function getTableName()
+ {
+ return $this->getModuleName() . '_schema_migration';
+ }
+
+ public function hasSchema()
+ {
+ return $this->listPendingMigrations() !== array(0);
+ }
+
+ public function hasPendingMigrations()
+ {
+ return $this->countPendingMigrations() > 0;
+ }
+
+ public function countPendingMigrations()
+ {
+ return count($this->listPendingMigrations());
+ }
+
+ /**
+ * @return Migration[]
+ */
+ public function getPendingMigrations()
+ {
+ $migrations = array();
+ foreach ($this->listPendingMigrations() as $version) {
+ $migrations[] = new Migration(
+ $version,
+ $this->loadMigrationFile($version)
+ );
+ }
+
+ return $migrations;
+ }
+
+ /**
+ * @return $this
+ */
+ public function applyPendingMigrations()
+ {
+ // Ensure we have enough time to migrate
+ ini_set('max_execution_time', 0);
+
+ foreach ($this->getPendingMigrations() as $migration) {
+ $migration->apply($this->connection);
+ }
+
+ return $this;
+ }
+
+ public function listPendingMigrations()
+ {
+ $lastMigration = $this->getLastMigrationNumber();
+ if ($lastMigration === 0) {
+ return array(0);
+ }
+
+ return $this->listMigrationsAfter($this->getLastMigrationNumber());
+ }
+
+ public function listAllMigrations()
+ {
+ $dir = $this->getMigrationsDir();
+ if (! is_readable($dir)) {
+ return array();
+ }
+
+ $versions = array();
+
+ foreach (new DirectoryIterator($this->getMigrationsDir()) as $file) {
+ if ($file->isDot()) {
+ continue;
+ }
+
+ $filename = $file->getFilename();
+ if (preg_match('/^upgrade_(\d+)\.sql$/', $filename, $match)) {
+ $versions[] = $match[1];
+ }
+ }
+
+ sort($versions);
+
+ return $versions;
+ }
+
+ public function loadMigrationFile($version)
+ {
+ if ($version === 0) {
+ $filename = $this->getFullSchemaFile();
+ } else {
+ $filename = $this->getMigrationFileName($version);
+ }
+
+ return file_get_contents($filename);
+ }
+
+ public function hasBeenDowngraded()
+ {
+ return ! $this->hasMigrationFile($this->getLastMigrationNumber());
+ }
+
+ public function hasMigrationFile($version)
+ {
+ return \file_exists($this->getMigrationFileName($version));
+ }
+
+ protected function getMigrationFileName($version)
+ {
+ return sprintf(
+ '%s/upgrade_%d.sql',
+ $this->getMigrationsDir(),
+ $version
+ );
+ }
+
+ protected function listMigrationsAfter($version)
+ {
+ $filtered = array();
+ foreach ($this->listAllMigrations() as $available) {
+ if ($available > $version) {
+ $filtered[] = $available;
+ }
+ }
+
+ return $filtered;
+ }
+
+ protected function getMigrationsDir()
+ {
+ if ($this->migrationsDir === null) {
+ $this->migrationsDir = $this->getSchemaDir(
+ $this->connection->getDbType() . '-migrations'
+ );
+ }
+
+ return $this->migrationsDir;
+ }
+
+ protected function getFullSchemaFile()
+ {
+ return $this->getSchemaDir(
+ $this->connection->getDbType() . '.sql'
+ );
+ }
+
+ protected function getSchemaDir($sub = null)
+ {
+ try {
+ $dir = $this->getModuleDir('/schema');
+ } catch (ProgrammingError $e) {
+ throw new RuntimeException(
+ 'Unable to detect the schema directory for this module',
+ 0,
+ $e
+ );
+ }
+ if ($sub === null) {
+ return $dir;
+ } else {
+ return $dir . '/' . ltrim($sub, '/');
+ }
+ }
+
+ /**
+ * @param string $sub
+ * @return string
+ * @throws ProgrammingError
+ */
+ protected function getModuleDir($sub = '')
+ {
+ return Icinga::app()->getModuleManager()->getModuleDir(
+ $this->getModuleName(),
+ $sub
+ );
+ }
+
+ protected function getModuleName()
+ {
+ return $this->getModuleNameForObject($this);
+ }
+
+ protected function getModuleNameForObject($object)
+ {
+ $class = get_class($object);
+ // Hint: Icinga\Module\ -> 14 chars
+ return lcfirst(substr($class, 14, strpos($class, '\\', 15) - 14));
+ }
+}
diff --git a/library/Director/Deployment/ConditionalConfigRenderer.php b/library/Director/Deployment/ConditionalConfigRenderer.php
new file mode 100644
index 0000000..0b24418
--- /dev/null
+++ b/library/Director/Deployment/ConditionalConfigRenderer.php
@@ -0,0 +1,64 @@
+<?php
+
+namespace Icinga\Module\Director\Deployment;
+
+use Icinga\Exception\NotFoundError;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\IcingaConfig\IcingaConfig;
+use Icinga\Module\Director\Objects\DirectorActivityLog;
+
+class ConditionalConfigRenderer
+{
+ /** @var Db */
+ protected $db;
+
+ protected $forceRendering = false;
+
+ public function __construct(Db $connection)
+ {
+ $this->db = $connection;
+ }
+
+ public function forceRendering($force = true)
+ {
+ $this->forceRendering = $force;
+
+ return $this;
+ }
+
+ public function getConfig()
+ {
+ if ($this->shouldGenerate()) {
+ return IcingaConfig::generate($this->db);
+ }
+
+ return $this->loadLatestActivityConfig();
+ }
+
+ protected function loadLatestActivityConfig()
+ {
+ $db = $this->db;
+
+ return IcingaConfig::loadByActivityChecksum($db->getLastActivityChecksum(), $db);
+ }
+
+ protected function shouldGenerate()
+ {
+ return $this->forceRendering || !$this->configForLatestActivityExists();
+ }
+
+ protected function configForLatestActivityExists()
+ {
+ $db = $this->db;
+ try {
+ $latestActivity = DirectorActivityLog::loadLatest($db);
+ } catch (NotFoundError $e) {
+ return false;
+ }
+
+ return IcingaConfig::existsForActivityChecksum(
+ bin2hex($latestActivity->get('checksum')),
+ $db
+ );
+ }
+}
diff --git a/library/Director/Deployment/ConditionalDeployment.php b/library/Director/Deployment/ConditionalDeployment.php
new file mode 100644
index 0000000..0f64028
--- /dev/null
+++ b/library/Director/Deployment/ConditionalDeployment.php
@@ -0,0 +1,190 @@
+<?php
+
+namespace Icinga\Module\Director\Deployment;
+
+use Icinga\Exception\IcingaException;
+use Icinga\Module\Director\Core\CoreApi;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\IcingaConfig\IcingaConfig;
+use Icinga\Module\Director\Objects\DirectorDeploymentLog;
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerAwareTrait;
+use Psr\Log\NullLogger;
+
+class ConditionalDeployment implements LoggerAwareInterface
+{
+ use LoggerAwareTrait;
+
+ /** @var Db */
+ protected $db;
+
+ /** @var CoreApi */
+ protected $api;
+
+ /** @var ?DeploymentGracePeriod */
+ protected $gracePeriod = null;
+
+ protected $force = false;
+
+ protected $hasBeenForced = false;
+
+ /** @var ?string */
+ protected $noDeploymentReason = null;
+
+ public function __construct(Db $connection, CoreApi $api = null)
+ {
+ $this->setLogger(new NullLogger());
+ $this->db = $connection;
+ if ($api === null) {
+ $this->api = $connection->getDeploymentEndpoint()->api();
+ } else {
+ $this->api = $api;
+ }
+ $this->refresh();
+ }
+
+ /**
+ * @param IcingaConfig $config
+ * @return ?DirectorDeploymentLog
+ */
+ public function deploy(IcingaConfig $config)
+ {
+ $this->hasBeenForced = false;
+ if ($this->shouldDeploy($config)) {
+ return $this->reallyDeploy($config);
+ } elseif ($this->force) {
+ $deployment = $this->reallyDeploy($config);
+ $this->hasBeenForced = true;
+
+ return $deployment;
+ }
+
+ return null;
+ }
+
+ /**
+ * @param bool $force
+ * @return $this
+ */
+ public function force($force = true)
+ {
+ $this->force = $force;
+ return $this;
+ }
+
+ public function setGracePeriod(DeploymentGracePeriod $gracePeriod)
+ {
+ $this->gracePeriod = $gracePeriod;
+ return $this;
+ }
+
+ public function refresh()
+ {
+ $this->api->collectLogFiles($this->db);
+ $this->api->wipeInactiveStages($this->db);
+ }
+
+ public function waitForStartupAfterDeploy(DirectorDeploymentLog $deploymentLog, $timeout)
+ {
+ $startTime = time();
+ while ((time() - $startTime) <= $timeout) {
+ $deploymentFromDB = DirectorDeploymentLog::load($deploymentLog->getId(), $this->db);
+ $stageCollected = $deploymentFromDB->get('stage_collected');
+ if ($stageCollected === null) {
+ usleep(500000);
+ continue;
+ }
+ if ($stageCollected === 'n') {
+ return 'stage has not been collected (Icinga "lost" the deployment)';
+ }
+ if ($deploymentFromDB->get('startup_succeeded') === 'y') {
+ return true;
+ }
+ return 'deployment failed during startup (usually a Configuration Error)';
+ }
+ return 'deployment timed out (while waiting for an Icinga restart)';
+ }
+
+ /**
+ * @return string|null
+ */
+ public function getNoDeploymentReason()
+ {
+ return $this->noDeploymentReason;
+ }
+
+ public function hasBeenForced()
+ {
+ return $this->hasBeenForced;
+ }
+
+ protected function shouldDeploy(IcingaConfig $config)
+ {
+ $this->noDeploymentReason = null;
+ if ($this->hasNeverDeployed()) {
+ return true;
+ }
+
+ if ($this->isWithinGracePeriod()) {
+ $this->noDeploymentReason = 'Grace period is active';
+ return false;
+ }
+
+ if ($this->deployedConfigMatches($config)) {
+ $this->noDeploymentReason = 'Config matches last deployed one';
+ return false;
+ }
+
+ if ($this->getActiveChecksum() === $config->getHexChecksum()) {
+ $this->noDeploymentReason = 'Config matches active stage';
+ return false;
+ }
+
+ return true;
+ }
+
+ protected function hasNeverDeployed()
+ {
+ return !DirectorDeploymentLog::hasDeployments($this->db);
+ }
+
+ protected function isWithinGracePeriod()
+ {
+ return $this->gracePeriod && $this->gracePeriod->isActive();
+ }
+
+ protected function deployedConfigMatches(IcingaConfig $config)
+ {
+ if ($deployment = DirectorDeploymentLog::optionalLatest($this->db)) {
+ return $deployment->getConfigHexChecksum() === $config->getHexChecksum();
+ }
+
+ return false;
+ }
+
+ protected function getActiveChecksum()
+ {
+ return DirectorDeploymentLog::getConfigChecksumForStageName(
+ $this->db,
+ $this->api->getActiveStageName()
+ );
+ }
+
+ /**
+ * @param IcingaConfig $config
+ * @return bool|DirectorDeploymentLog
+ * @throws IcingaException
+ * @throws \Icinga\Module\Director\Exception\DuplicateKeyException
+ */
+ protected function reallyDeploy(IcingaConfig $config)
+ {
+ $checksum = $config->getHexChecksum();
+ $this->logger->info(sprintf('Director ConfigJob ready to deploy "%s"', $checksum));
+ if ($deployment = $this->api->dumpConfig($config, $this->db)) {
+ $this->logger->notice(sprintf('Director ConfigJob deployed config "%s"', $checksum));
+ return $deployment;
+ } else {
+ throw new IcingaException('Failed to deploy config "%s"', $checksum);
+ }
+ }
+}
diff --git a/library/Director/Deployment/DeploymentGracePeriod.php b/library/Director/Deployment/DeploymentGracePeriod.php
new file mode 100644
index 0000000..6cde25a
--- /dev/null
+++ b/library/Director/Deployment/DeploymentGracePeriod.php
@@ -0,0 +1,61 @@
+<?php
+
+namespace Icinga\Module\Director\Deployment;
+
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Objects\DirectorDeploymentLog;
+
+class DeploymentGracePeriod
+{
+ /** @var int */
+ protected $graceTimeSeconds;
+
+ /** @var Db */
+ protected $db;
+
+ /**
+ * @param int $graceTimeSeconds
+ * @param Db $db
+ */
+ public function __construct($graceTimeSeconds, Db $db)
+ {
+ $this->graceTimeSeconds = $graceTimeSeconds;
+ $this->db = $db;
+ }
+
+ /**
+ * Whether we're still within a grace period
+ * @return bool
+ */
+ public function isActive()
+ {
+ if ($deployment = $this->lastDeployment()) {
+ return $deployment->getDeploymentTimestamp() > $this->getGracePeriodStart();
+ }
+
+ return false;
+ }
+
+ protected function getGracePeriodStart()
+ {
+ return time() - $this->graceTimeSeconds;
+ }
+
+ public function getRemainingGraceTime()
+ {
+ if ($this->isActive()) {
+ if ($deployment = $this->lastDeployment()) {
+ return $deployment->getDeploymentTimestamp() - $this->getGracePeriodStart();
+ } else {
+ return null;
+ }
+ }
+
+ return 0;
+ }
+
+ protected function lastDeployment()
+ {
+ return DirectorDeploymentLog::optionalLatest($this->db);
+ }
+}
diff --git a/library/Director/Deployment/DeploymentInfo.php b/library/Director/Deployment/DeploymentInfo.php
new file mode 100644
index 0000000..77d52de
--- /dev/null
+++ b/library/Director/Deployment/DeploymentInfo.php
@@ -0,0 +1,59 @@
+<?php
+
+namespace Icinga\Module\Director\Deployment;
+
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Objects\IcingaObject;
+
+class DeploymentInfo
+{
+ /** @var IcingaObject */
+ protected $object;
+
+ protected $db;
+
+ /** @var int */
+ protected $totalChanges;
+
+ /** @var int */
+ protected $objectChanges;
+
+ public function __construct(Db $db)
+ {
+ $this->db = $db;
+ }
+
+ public function setObject(IcingaObject $object)
+ {
+ $this->object = $object;
+ return $this;
+ }
+
+ public function getTotalChanges()
+ {
+ if ($this->totalChanges === null) {
+ $this->totalChanges = $this->db->countActivitiesSinceLastDeployedConfig();
+ }
+
+ return $this->totalChanges;
+ }
+
+ public function getSingleObjectChanges()
+ {
+ if ($this->objectChanges === null) {
+ if ($this->object === null) {
+ $this->objectChanges = 0;
+ } else {
+ $this->objectChanges = $this->db
+ ->countActivitiesSinceLastDeployedConfig($this->object);
+ }
+ }
+
+ return $this->objectChanges;
+ }
+
+ public function hasUndeployedChanges()
+ {
+ return $this->getSingleObjectChanges() > 0 && $this->getTotalChanges() > 0;
+ }
+}
diff --git a/library/Director/Deployment/DeploymentStatus.php b/library/Director/Deployment/DeploymentStatus.php
new file mode 100644
index 0000000..ae850c6
--- /dev/null
+++ b/library/Director/Deployment/DeploymentStatus.php
@@ -0,0 +1,164 @@
+<?php
+
+namespace Icinga\Module\Director\Deployment;
+
+use Exception;
+use Icinga\Module\Director\Core\CoreApi;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Objects\DirectorDeploymentLog;
+
+class DeploymentStatus
+{
+ protected $db;
+
+ protected $api;
+
+ public function __construct(Db $db, CoreApi $api)
+ {
+ $this->db = $db;
+ $this->api = $api;
+ }
+
+ public function getDeploymentStatus($configs = null, $activities = null)
+ {
+ try {
+ if (DirectorDeploymentLog::hasUncollected($this->db)) {
+ $this->api->collectLogFiles($this->db);
+ }
+ } catch (Exception $e) {
+ // Ignore eventual issues while talking to Icinga
+ }
+
+ $activeConfiguration = null;
+ $lastActivityLogChecksum = null;
+ $configChecksum = null;
+ if ($stageName = $this->api->getActiveStageName()) {
+ $activityLogChecksum = DirectorDeploymentLog::getRelatedToActiveStage($this->api, $this->db);
+ if ($activityLogChecksum === null) {
+ $activeConfiguration = [
+ 'stage_name' => $stageName,
+ 'config' => null,
+ 'activity' => null
+ ];
+ } else {
+ $lastActivityLogChecksum = bin2hex($activityLogChecksum->get('last_activity_checksum'));
+ $configChecksum = $this->getConfigChecksumForStageName($stageName);
+ $activeConfiguration = [
+ 'stage_name' => $stageName,
+ 'config' => ($configChecksum) ? : null,
+ 'activity' => $lastActivityLogChecksum
+ ];
+ }
+ }
+ $result = [
+ 'active_configuration' => (object) $activeConfiguration,
+ ];
+
+ if ($configs) {
+ $result['configs'] = (object) $this->getDeploymentStatusForConfigChecksums(
+ explode(',', $configs),
+ $configChecksum
+ );
+ }
+
+ if ($activities) {
+ $result['activities'] = (object) $this->getDeploymentStatusForActivityLogChecksums(
+ explode(',', $activities),
+ $lastActivityLogChecksum
+ );
+ }
+ return (object) $result;
+ }
+
+ public function getConfigChecksumForStageName($stageName)
+ {
+ $db = $this->db->getDbAdapter();
+ $query = $db->select()->from(
+ ['l' => 'director_deployment_log'],
+ ['checksum' => $this->db->dbHexFunc('l.config_checksum')]
+ )->where('l.stage_name = ?', $stageName);
+
+ return $db->fetchOne($query);
+ }
+
+ public function getDeploymentStatusForConfigChecksums($configChecksums, $activeConfigChecksum)
+ {
+ $db = $this->db->getDbAdapter();
+ $results = array_combine($configChecksums, array_map(function () {
+ return 'unknown';
+ }, $configChecksums));
+ $binaryConfigChecksums = [];
+ foreach ($configChecksums as $singleConfigChecksum) {
+ $binaryConfigChecksums[$singleConfigChecksum] = $this->db->quoteBinary(hex2bin($singleConfigChecksum));
+ }
+ $deployedConfigs = $this->getDeployedConfigs(array_values($binaryConfigChecksums));
+
+ foreach ($results as $singleChecksum => &$status) {
+ // active if it's equal to the provided active
+ if ($singleChecksum === $activeConfigChecksum) {
+ $status = 'active';
+ } else {
+ if (isset($deployedConfigs[$singleChecksum])) {
+ $status = ($deployedConfigs[$singleChecksum] === 'y') ? 'deployed' : 'failed';
+ } else {
+ // check if it's in generated_config table it is undeployed
+ $generatedConfigQuery = $db->select()->from(
+ ['g' => 'director_generated_config'],
+ ['checksum' => 'g.checksum']
+ )->where('g.checksum = ?', $binaryConfigChecksums[$singleChecksum]);
+ if ($db->fetchOne($generatedConfigQuery)) {
+ $status = 'undeployed';
+ }
+ }
+ // otherwise leave unknown
+ }
+ }
+
+ return $results;
+ }
+
+ public function getDeploymentStatusForActivityLogChecksums($activityLogChecksums, $activeActivityLogChecksum)
+ {
+ $db = $this->db->getDbAdapter();
+ $results = array_combine($activityLogChecksums, array_map(function () {
+ return 'unknown';
+ }, $activityLogChecksums));
+
+ foreach ($results as $singleActivityLogChecksum => &$status) {
+ // active if it's equal to the provided active
+ if ($singleActivityLogChecksum === $activeActivityLogChecksum) {
+ $status = 'active';
+ } else {
+ // get last deployed activity id and check if it's less than the passed one
+ $generatedConfigQuery = $db->select()->from(
+ ['a' => 'director_activity_log'],
+ ['id' => 'a.id']
+ )->where('a.checksum = ?', $this->db->quoteBinary(hex2bin($singleActivityLogChecksum)));
+ if ($singleActivityLogData = $db->fetchOne($generatedConfigQuery)) {
+ if ($lastDeploymentActivityLogId = $this->db->getLastDeploymentActivityLogId()) {
+ if ((int) $singleActivityLogData > $lastDeploymentActivityLogId) {
+ $status = 'undeployed';
+ } else {
+ $status = 'deployed';
+ }
+ }
+ }
+ }
+ }
+ return $results;
+ }
+
+ /**
+ * @param array $binaryConfigChecksums
+ * @return array
+ */
+ public function getDeployedConfigs(array $binaryConfigChecksums)
+ {
+ $db = $this->db->getDbAdapter();
+ $deploymentLogQuery = $db->select()->from(['l' => 'director_deployment_log'], [
+ 'checksum' => $this->db->dbHexFunc('l.config_checksum'),
+ 'deployed' => 'l.startup_succeeded'
+ ])->where('l.config_checksum IN (?)', $binaryConfigChecksums);
+ return $db->fetchPairs($deploymentLogQuery);
+ }
+}
diff --git a/library/Director/DirectorObject/Automation/Basket.php b/library/Director/DirectorObject/Automation/Basket.php
new file mode 100644
index 0000000..f7eb8e5
--- /dev/null
+++ b/library/Director/DirectorObject/Automation/Basket.php
@@ -0,0 +1,232 @@
+<?php
+
+namespace Icinga\Module\Director\DirectorObject\Automation;
+
+use Icinga\Module\Director\Core\Json;
+use Icinga\Module\Director\Data\Db\DbObject;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Exception\DuplicateKeyException;
+
+/**
+ * Class Basket
+ *
+ * TODO
+ * - create a UUID like in RFC4122
+ */
+class Basket extends DbObject implements ExportInterface
+{
+ const SELECTION_ALL = true;
+ const SELECTION_NONE = false;
+
+ protected $table = 'director_basket';
+
+ protected $keyName = 'basket_name';
+
+ protected $chosenObjects = [];
+
+ protected $protectedFormerChosenObjects;
+
+ protected $defaultProperties = [
+ 'uuid' => null,
+ 'basket_name' => null,
+ 'objects' => null,
+ 'owner_type' => null,
+ 'owner_value' => null,
+ ];
+
+ protected $binaryProperties = [
+ 'uuid'
+ ];
+
+ public function getHexUuid()
+ {
+ return bin2hex($this->get('uuid'));
+ }
+
+ public function listObjectTypes()
+ {
+ return array_keys($this->objects);
+ }
+
+ public function getChosenObjects()
+ {
+ return $this->chosenObjects;
+ }
+
+ public function isEmpty()
+ {
+ return count($this->getChosenObjects()) === 0;
+ }
+
+ protected function onLoadFromDb()
+ {
+ $this->chosenObjects = (array) Json::decode($this->get('objects'));
+ unset($this->chosenObjects['Datafield']); // Might be in old baskets
+ }
+
+ public function getUniqueIdentifier()
+ {
+ return $this->get('basket_name');
+ }
+
+ public function export()
+ {
+ $result = $this->getProperties();
+ unset($result['uuid']);
+ $result['objects'] = Json::decode($result['objects']);
+ ksort($result);
+
+ return (object) $result;
+ }
+
+ /**
+ * @param $plain
+ * @param Db $db
+ * @param bool $replace
+ * @return static
+ * @throws DuplicateKeyException
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public static function import($plain, Db $db, $replace = false)
+ {
+ $properties = (array) $plain;
+ $name = $properties['basket_name'];
+
+ if ($replace && static::exists($name, $db)) {
+ $object = static::load($name, $db);
+ } elseif (static::exists($name, $db)) {
+ throw new DuplicateKeyException(
+ 'Basket "%s" already exists',
+ $name
+ );
+ } else {
+ $object = static::create([], $db);
+ }
+ $object->setProperties($properties);
+
+ return $object;
+ }
+
+ public function supportsCustomSelectionFor($type)
+ {
+ if (! array_key_exists($type, $this->chosenObjects)) {
+ return false;
+ }
+
+ return is_array($this->chosenObjects[$type]);
+ }
+
+ public function setObjects($objects)
+ {
+ if (empty($objects)) {
+ $this->chosenObjects = [];
+ } else {
+ $this->protectedFormerChosenObjects = $this->chosenObjects;
+ $this->chosenObjects = [];
+ foreach ((array) $objects as $type => $object) {
+ $this->addObjects($type, $object);
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * This is a weird method, as it is required to deal with raw form data
+ *
+ * @param $type
+ * @param ExportInterface[]|bool $objects
+ */
+ public function addObjects($type, $objects = true)
+ {
+ BasketSnapshot::assertValidType($type);
+ // '1' -> from Form!
+ if ($objects === 'ALL') {
+ $objects = true;
+ } elseif ($objects === null || $objects === 'IGNORE') {
+ return;
+ } elseif ($objects === '[]' || is_array($objects)) {
+ if (! isset($this->chosenObjects[$type]) || ! is_array($this->chosenObjects[$type])) {
+ $this->chosenObjects[$type] = [];
+ }
+ if (isset($this->protectedFormerChosenObjects[$type])) {
+ if (is_array($this->protectedFormerChosenObjects[$type])) {
+ $this->chosenObjects[$type] = $this->protectedFormerChosenObjects[$type];
+ } else {
+ $this->chosenObjects[$type] = [];
+ }
+ }
+
+ if ($objects === '[]') {
+ $objects = [];
+ }
+ }
+
+ if ($objects === true) {
+ $this->chosenObjects[$type] = true;
+ } elseif ($objects === '0') {
+ // nothing
+ } else {
+ foreach ($objects as $object) {
+ $this->addObject($type, $object);
+ }
+
+ if (array_key_exists($type, $this->chosenObjects)) {
+ ksort($this->chosenObjects[$type]);
+ }
+ }
+
+ $this->reallySet('objects', Json::encode($this->chosenObjects));
+ }
+
+ public function hasObject($type, $object)
+ {
+ if (! $this->hasType($type)) {
+ return false;
+ }
+
+ if ($this->chosenObjects[$type] === true) {
+ return true;
+ }
+
+ if ($object instanceof ExportInterface) {
+ $object = $object->getUniqueIdentifier();
+ }
+
+ if (is_array($this->chosenObjects[$type])) {
+ return in_array($object, $this->chosenObjects[$type]);
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * @param $type
+ * @param string $object
+ */
+ public function addObject($type, $object)
+ {
+ if (is_array($this->chosenObjects[$type])) {
+ $this->chosenObjects[$type][] = $object;
+ } else {
+ throw new \InvalidArgumentException(sprintf(
+ 'The Basket "%s" has not been configured for single objects of type "%s"',
+ $this->get('basket_name'),
+ $type
+ ));
+ }
+ }
+
+ public function hasType($type)
+ {
+ return isset($this->chosenObjects[$type]);
+ }
+
+ protected function beforeStore()
+ {
+ if (! $this->hasBeenLoadedFromDb()) {
+ // TODO: This is BS, use a real UUID
+ $this->set('uuid', hex2bin(substr(sha1(microtime(true) . rand(1, 100000)), 0, 32)));
+ }
+ }
+}
diff --git a/library/Director/DirectorObject/Automation/BasketContent.php b/library/Director/DirectorObject/Automation/BasketContent.php
new file mode 100644
index 0000000..e59c0ae
--- /dev/null
+++ b/library/Director/DirectorObject/Automation/BasketContent.php
@@ -0,0 +1,24 @@
+<?php
+
+namespace Icinga\Module\Director\DirectorObject\Automation;
+
+use Icinga\Module\Director\Data\Db\DbObject;
+
+class BasketContent extends DbObject
+{
+ protected $objects;
+
+ protected $table = 'director_basket_content';
+
+ protected $keyName = 'checksum';
+
+ protected $defaultProperties = [
+ 'checksum' => null,
+ 'summary' => null,
+ 'content' => null,
+ ];
+
+ protected $binaryProperties = [
+ 'checksum'
+ ];
+}
diff --git a/library/Director/DirectorObject/Automation/BasketSnapshot.php b/library/Director/DirectorObject/Automation/BasketSnapshot.php
new file mode 100644
index 0000000..4ddf2ce
--- /dev/null
+++ b/library/Director/DirectorObject/Automation/BasketSnapshot.php
@@ -0,0 +1,531 @@
+<?php
+
+namespace Icinga\Module\Director\DirectorObject\Automation;
+
+use gipfl\Json\JsonEncodeException;
+use gipfl\Json\JsonString;
+use Icinga\Module\Director\Core\Json;
+use Icinga\Module\Director\Data\Exporter;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Data\Db\DbObject;
+use Icinga\Module\Director\Objects\DirectorDatafield;
+use Icinga\Module\Director\Objects\DirectorDatafieldCategory;
+use Icinga\Module\Director\Objects\DirectorDatalist;
+use Icinga\Module\Director\Objects\DirectorJob;
+use Icinga\Module\Director\Objects\IcingaCommand;
+use Icinga\Module\Director\Objects\IcingaDependency;
+use Icinga\Module\Director\Objects\IcingaHost;
+use Icinga\Module\Director\Objects\IcingaHostGroup;
+use Icinga\Module\Director\Objects\IcingaNotification;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Icinga\Module\Director\Objects\IcingaService;
+use Icinga\Module\Director\Objects\IcingaServiceGroup;
+use Icinga\Module\Director\Objects\IcingaServiceSet;
+use Icinga\Module\Director\Objects\IcingaTemplateChoiceHost;
+use Icinga\Module\Director\Objects\IcingaTemplateChoiceService;
+use Icinga\Module\Director\Objects\IcingaTimePeriod;
+use Icinga\Module\Director\Objects\IcingaUser;
+use Icinga\Module\Director\Objects\IcingaUserGroup;
+use Icinga\Module\Director\Objects\ImportSource;
+use Icinga\Module\Director\Objects\SyncRule;
+use InvalidArgumentException;
+use RuntimeException;
+
+class BasketSnapshot extends DbObject
+{
+ protected static $typeClasses = [
+ 'DatafieldCategory' => DirectorDatafieldCategory::class,
+ 'Datafield' => DirectorDatafield::class,
+ 'TimePeriod' => IcingaTimePeriod::class,
+ 'CommandTemplate' => [IcingaCommand::class, ['object_type' => 'template']],
+ 'ExternalCommand' => [IcingaCommand::class, ['object_type' => 'external_object']],
+ 'Command' => [IcingaCommand::class, ['object_type' => 'object']],
+ 'HostGroup' => IcingaHostGroup::class,
+ 'IcingaTemplateChoiceHost' => IcingaTemplateChoiceHost::class,
+ 'HostTemplate' => IcingaHost::class,
+ 'ServiceGroup' => IcingaServiceGroup::class,
+ 'IcingaTemplateChoiceService' => IcingaTemplateChoiceService::class,
+ 'ServiceTemplate' => IcingaService::class,
+ 'ServiceSet' => IcingaServiceSet::class,
+ 'UserGroup' => IcingaUserGroup::class,
+ 'UserTemplate' => [IcingaUser::class, ['object_type' => 'template']],
+ 'User' => [IcingaUser::class, ['object_type' => 'object']],
+ 'NotificationTemplate' => IcingaNotification::class,
+ 'Notification' => [IcingaNotification::class, ['object_type' => 'apply']],
+ 'DataList' => DirectorDatalist::class,
+ 'Dependency' => IcingaDependency::class,
+ 'ImportSource' => ImportSource::class,
+ 'SyncRule' => SyncRule::class,
+ 'DirectorJob' => DirectorJob::class,
+ 'Basket' => Basket::class,
+ ];
+
+ protected $objects = [];
+
+ protected $content;
+
+ protected $table = 'director_basket_snapshot';
+
+ protected $keyName = [
+ 'basket_uuid',
+ 'ts_create',
+ ];
+
+ protected $restoreOrder = [
+ 'CommandTemplate',
+ 'ExternalCommand',
+ 'Command',
+ 'TimePeriod',
+ 'HostGroup',
+ 'IcingaTemplateChoiceHost',
+ 'HostTemplate',
+ 'ServiceGroup',
+ 'IcingaTemplateChoiceService',
+ 'ServiceTemplate',
+ 'ServiceSet',
+ 'UserGroup',
+ 'UserTemplate',
+ 'User',
+ 'NotificationTemplate',
+ 'Notification',
+ 'Dependency',
+ 'ImportSource',
+ 'SyncRule',
+ 'DirectorJob',
+ 'Basket',
+ ];
+
+ protected $defaultProperties = [
+ 'basket_uuid' => null,
+ 'content_checksum' => null,
+ 'ts_create' => null,
+ ];
+
+ protected $binaryProperties = [
+ 'basket_uuid',
+ 'content_checksum',
+ ];
+
+ public static function supports($type)
+ {
+ return isset(self::$typeClasses[$type]);
+ }
+
+ public static function assertValidType($type)
+ {
+ if (! static::supports($type)) {
+ throw new InvalidArgumentException("Basket does not support '$type'");
+ }
+ }
+
+ public static function getClassForType($type)
+ {
+ static::assertValidType($type);
+
+ if (is_array(self::$typeClasses[$type])) {
+ return self::$typeClasses[$type][0];
+ }
+
+ return self::$typeClasses[$type];
+ }
+
+ public static function getClassAndObjectTypeForType($type)
+ {
+ if (is_array(self::$typeClasses[$type])) {
+ return self::$typeClasses[$type];
+ }
+
+ return [self::$typeClasses[$type], null];
+ }
+
+ /**
+ * @param Basket $basket
+ * @param Db $db
+ * @return BasketSnapshot
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public static function createForBasket(Basket $basket, Db $db)
+ {
+ $snapshot = static::create([
+ 'basket_uuid' => $basket->get('uuid')
+ ], $db);
+ $snapshot->addObjectsChosenByBasket($basket);
+ $snapshot->resolveRequiredFields();
+
+ return $snapshot;
+ }
+
+ /**
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ protected function resolveRequiredFields()
+ {
+ /** @var Db $db */
+ $db = $this->getConnection();
+ $fieldResolver = new BasketSnapshotFieldResolver($this->objects, $db);
+ /** @var DirectorDatafield[] $fields */
+ $fields = $fieldResolver->loadCurrentFields($db);
+ $categories = [];
+ if (! empty($fields)) {
+ $plain = [];
+ foreach ($fields as $id => $field) {
+ $plain[$id] = $field->export();
+ if ($category = $field->getCategory()) {
+ $categories[$category->get('category_name')] = $category->export();
+ }
+ }
+ $this->objects['Datafield'] = $plain;
+ }
+ if (! empty($categories)) {
+ $this->objects['DatafieldCategory'] = $categories;
+ }
+ }
+
+ protected function addObjectsChosenByBasket(Basket $basket)
+ {
+ foreach ($basket->getChosenObjects() as $typeName => $selection) {
+ if ($selection === true) {
+ $this->addAll($typeName);
+ } elseif (! empty($selection)) {
+ $this->addByIdentifiers($typeName, $selection);
+ }
+ }
+ }
+
+ /**
+ * @throws \Icinga\Module\Director\Exception\DuplicateKeyException
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ protected function beforeStore()
+ {
+ if ($this->hasBeenLoadedFromDb()) {
+ throw new RuntimeException('A basket snapshot cannot be modified');
+ }
+ $json = $this->getJsonDump();
+ $checksum = sha1($json, true);
+ if (! BasketContent::exists($checksum, $this->getConnection())) {
+ BasketContent::create([
+ 'checksum' => $checksum,
+ 'summary' => $this->getJsonSummary(),
+ 'content' => $json,
+ ], $this->getConnection())->store();
+ }
+
+ $this->set('content_checksum', $checksum);
+ $this->set('ts_create', round(microtime(true) * 1000));
+ }
+
+ /**
+ * @param Db $connection
+ * @param bool $replace
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function restoreTo(Db $connection, $replace = true)
+ {
+ static::restoreJson(
+ $this->getJsonDump(),
+ $connection,
+ $replace
+ );
+ }
+
+ /**
+ * @param Basket $basket
+ * @param $string
+ * @return BasketSnapshot
+ */
+ public static function forBasketFromJson(Basket $basket, $string)
+ {
+ $snapshot = static::create([
+ 'basket_uuid' => $basket->get('uuid')
+ ]);
+ $snapshot->objects = [];
+ foreach ((array) Json::decode($string) as $type => $objects) {
+ $snapshot->objects[$type] = (array) $objects;
+ }
+
+ return $snapshot;
+ }
+
+ public static function restoreJson($string, Db $connection, $replace = true)
+ {
+ $snapshot = new static();
+ $snapshot->restoreObjects(
+ Json::decode($string),
+ $connection,
+ $replace
+ );
+ }
+
+ /**
+ * @param $all
+ * @param Db $connection
+ * @param bool $replace
+ * @throws \Icinga\Module\Director\Exception\DuplicateKeyException
+ * @throws \Zend_Db_Adapter_Exception
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ protected function restoreObjects($all, Db $connection, $replace = true)
+ {
+ $db = $connection->getDbAdapter();
+ $db->beginTransaction();
+ $fieldResolver = new BasketSnapshotFieldResolver($all, $connection);
+ $this->restoreType($all, 'DataList', $fieldResolver, $connection, $replace);
+ $this->restoreType($all, 'DatafieldCategory', $fieldResolver, $connection, $replace);
+ $fieldResolver->storeNewFields();
+ foreach ($this->restoreOrder as $typeName) {
+ $this->restoreType($all, $typeName, $fieldResolver, $connection, $replace);
+ }
+ $db->commit();
+ }
+
+ /**
+ * @param $all
+ * @param $typeName
+ * @param BasketSnapshotFieldResolver $fieldResolver
+ * @param Db $connection
+ * @param $replace
+ * @throws \Icinga\Exception\NotFoundError
+ * @throws \Icinga\Module\Director\Exception\DuplicateKeyException
+ * @throws \Zend_Db_Adapter_Exception
+ */
+ public function restoreType(
+ &$all,
+ $typeName,
+ BasketSnapshotFieldResolver $fieldResolver,
+ Db $connection,
+ $replace
+ ) {
+ if (isset($all->$typeName)) {
+ $objects = (array) $all->$typeName;
+ } else {
+ return;
+ }
+ $class = static::getClassForType($typeName);
+
+ $changed = [];
+ foreach ($objects as $key => $object) {
+ /** @var DbObject $new */
+ $new = $class::import($object, $connection, $replace);
+ if ($new->hasBeenModified()) {
+ if ($new instanceof IcingaObject && $new->supportsImports()) {
+ /** @var ExportInterface $new */
+ $changed[$new->getUniqueIdentifier()] = $new;
+ } else {
+ $new->store();
+ // Linking fields right now, as we're not in $changed
+ if ($new instanceof IcingaObject) {
+ $fieldResolver->relinkObjectFields($new, $object);
+ }
+ }
+ } else {
+ // No modification on the object, still, fields might have
+ // been changed
+ if ($new instanceof IcingaObject) {
+ $fieldResolver->relinkObjectFields($new, $object);
+ }
+ }
+ $allObjects[spl_object_hash($new)] = $object;
+ }
+
+ /** @var IcingaObject $object */
+ foreach ($changed as $object) {
+ $this->recursivelyStore($object, $changed);
+ }
+ foreach ($changed as $key => $new) {
+ // Store related fields. As objects might have formerly been
+ // un-stored, let's to it right here
+ if ($new instanceof IcingaObject) {
+ $fieldResolver->relinkObjectFields($new, $objects[$key]);
+ }
+ }
+ }
+
+ /**
+ * @param IcingaObject $object
+ * @param $list
+ * @throws \Icinga\Module\Director\Exception\DuplicateKeyException
+ */
+ protected function recursivelyStore(IcingaObject $object, &$list)
+ {
+ foreach ($object->listImportNames() as $parent) {
+ if (array_key_exists($parent, $list)) {
+ $this->recursivelyStore($list[$parent], $list);
+ }
+ }
+
+ $object->store();
+ }
+
+ /**
+ * @return BasketContent
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ protected function getContent()
+ {
+ if ($this->content === null) {
+ $this->content = BasketContent::load($this->get('content_checksum'), $this->getConnection());
+ }
+
+ return $this->content;
+ }
+
+ protected function onDelete()
+ {
+ $db = $this->getDb();
+ $db->delete(
+ ['bc' => 'director_basket_content'],
+ 'NOT EXISTS (SELECT director_basket_checksum WHERE content_checksum = bc.checksum)'
+ );
+ }
+
+ /**
+ * @return string
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function getJsonSummary()
+ {
+ if ($this->hasBeenLoadedFromDb()) {
+ return $this->getContent()->get('summary');
+ }
+
+ return Json::encode($this->getSummary(), JSON_PRETTY_PRINT);
+ }
+
+ /**
+ * @return array|mixed
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function getSummary()
+ {
+ if ($this->hasBeenLoadedFromDb()) {
+ return Json::decode($this->getContent()->get('summary'));
+ }
+
+ $summary = [];
+ foreach (array_keys($this->objects) as $key) {
+ $summary[$key] = count($this->objects[$key]);
+ }
+
+ return $summary;
+ }
+
+ /**
+ * @return string
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function getJsonDump()
+ {
+ if ($this->hasBeenLoadedFromDb()) {
+ return $this->getContent()->get('content');
+ }
+
+ try {
+ return JsonString::encode($this->objects, JSON_PRETTY_PRINT);
+ } catch (JsonEncodeException $e) {
+ foreach ($this->objects as $type => $objects) {
+ foreach ($objects as $object) {
+ try {
+ JsonString::encode($object);
+ } catch (JsonEncodeException $singleError) {
+ $dump = var_export($object, 1);
+ if (function_exists('iconv')) {
+ $dump = iconv('UTF-8', 'UTF-8//IGNORE', $dump);
+ }
+ throw new JsonEncodeException(sprintf(
+ 'Failed to encode object ot type "%s": %s, %s',
+ $type,
+ $dump,
+ $singleError->getMessage()
+ ), $singleError->getCode());
+ }
+ }
+ }
+
+ throw $e;
+ }
+ }
+
+ protected function addAll($typeName)
+ {
+ list($class, $filter) = static::getClassAndObjectTypeForType($typeName);
+ $connection = $this->getConnection();
+ assert($connection instanceof Db);
+
+ /** @var IcingaObject $dummy */
+ $dummy = $class::create();
+ if ($dummy instanceof IcingaObject && $dummy->supportsImports()) {
+ $db = $this->getDb();
+ $select = $db->select()->from($dummy->getTableName());
+ if ($filter) {
+ foreach ($filter as $column => $value) {
+ $select->where("$column = ?", $value);
+ }
+ } elseif (! $dummy->isGroup()
+ // TODO: this is ugly.
+ && ! $dummy instanceof IcingaDependency
+ && ! $dummy instanceof IcingaTimePeriod
+ ) {
+ $select->where('object_type = ?', 'template');
+ }
+ $all = $class::loadAll($connection, $select);
+ } else {
+ $all = $class::loadAll($connection);
+ }
+ $exporter = new Exporter($connection);
+ foreach ($all as $object) {
+ $this->objects[$typeName][$object->getUniqueIdentifier()] = $exporter->export($object);
+ }
+ }
+
+ protected function addByIdentifiers($typeName, $identifiers)
+ {
+ foreach ($identifiers as $identifier) {
+ $this->addByIdentifier($typeName, $identifier);
+ }
+ }
+
+ /**
+ * @param $typeName
+ * @param $identifier
+ * @param Db $connection
+ * @return ExportInterface|DbObject|null
+ */
+ public static function instanceByIdentifier($typeName, $identifier, Db $connection)
+ {
+ $class = static::getClassForType($typeName);
+ if (substr($class, -13) === 'IcingaService') {
+ $identifier = [
+ 'object_type' => 'template',
+ 'object_name' => $identifier,
+ ];
+ }
+ /** @var ExportInterface $object */
+ if ($class::exists($identifier, $connection)) {
+ $object = $class::load($identifier, $connection);
+ } else {
+ $object = null;
+ }
+
+ return $object;
+ }
+
+ /**
+ * @param $typeName
+ * @param $identifier
+ */
+ protected function addByIdentifier($typeName, $identifier)
+ {
+ /** @var Db $connection */
+ $connection = $this->getConnection();
+ $exporter = new Exporter($connection);
+ $object = static::instanceByIdentifier(
+ $typeName,
+ $identifier,
+ $connection
+ );
+ if ($object !== null) {
+ $this->objects[$typeName][$identifier] = $exporter->export($object);
+ }
+ }
+}
diff --git a/library/Director/DirectorObject/Automation/BasketSnapshotFieldResolver.php b/library/Director/DirectorObject/Automation/BasketSnapshotFieldResolver.php
new file mode 100644
index 0000000..4653255
--- /dev/null
+++ b/library/Director/DirectorObject/Automation/BasketSnapshotFieldResolver.php
@@ -0,0 +1,226 @@
+<?php
+
+namespace Icinga\Module\Director\DirectorObject\Automation;
+
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Objects\DirectorDatafield;
+use Icinga\Module\Director\Objects\IcingaObject;
+
+class BasketSnapshotFieldResolver
+{
+ /** @var BasketSnapshot */
+ protected $snapshot;
+
+ /** @var \Icinga\Module\Director\Data\Db\DbConnection */
+ protected $targetDb;
+
+ /** @var array|null */
+ protected $requiredIds;
+
+ protected $objects;
+
+ /** @var int */
+ protected $nextNewId = 1;
+
+ /** @var array|null */
+ protected $idMap;
+
+ /** @var DirectorDatafield[]|null */
+ protected $targetFields;
+
+ public function __construct($objects, Db $targetDb)
+ {
+ $this->objects = $objects;
+ $this->targetDb = $targetDb;
+ }
+
+ /**
+ * @param Db $db
+ * @return DirectorDatafield[]
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function loadCurrentFields(Db $db)
+ {
+ $fields = [];
+ foreach ($this->getRequiredIds() as $id) {
+ $fields[$id] = DirectorDatafield::loadWithAutoIncId((int) $id, $db);
+ }
+
+ return $fields;
+ }
+
+ /**
+ * @throws \Icinga\Exception\NotFoundError
+ * @throws \Icinga\Module\Director\Exception\DuplicateKeyException
+ */
+ public function storeNewFields()
+ {
+ $this->targetFields = null; // Clear Cache
+ foreach ($this->getTargetFields() as $id => $field) {
+ if ($field->hasBeenModified()) {
+ $field->store();
+ $this->idMap[$id] = $field->get('id');
+ }
+ }
+ }
+
+ /**
+ * @param IcingaObject $new
+ * @param $object
+ * @throws \Icinga\Exception\NotFoundError
+ * @throws \Zend_Db_Adapter_Exception
+ */
+ public function relinkObjectFields(IcingaObject $new, $object)
+ {
+ if (! $new->supportsFields() || ! isset($object->fields)) {
+ return;
+ }
+ $fieldMap = $this->getIdMap();
+
+ $objectId = (int) $new->get('id');
+ $table = $new->getTableName() . '_field';
+ $objectKey = $new->getShortTableName() . '_id';
+ $existingFields = [];
+
+ $db = $this->targetDb->getDbAdapter();
+
+ foreach ($db->fetchAll(
+ $db->select()->from($table)->where("$objectKey = ?", $objectId)
+ ) as $mapping) {
+ $existingFields[(int) $mapping->datafield_id] = $mapping;
+ }
+ foreach ($object->fields as $field) {
+ $id = $fieldMap[(int) $field->datafield_id];
+ if (isset($existingFields[$id])) {
+ unset($existingFields[$id]);
+ } else {
+ $db->insert($table, [
+ $objectKey => $objectId,
+ 'datafield_id' => $id,
+ 'is_required' => $field->is_required,
+ 'var_filter' => $field->var_filter,
+ ]);
+ }
+ }
+ if (! empty($existingFields)) {
+ $db->delete(
+ $table,
+ $db->quoteInto(
+ "$objectKey = $objectId AND datafield_id IN (?)",
+ array_keys($existingFields)
+ )
+ );
+ }
+ }
+
+ /**
+ * @param object $object
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function tweakTargetIds($object)
+ {
+ $forward = $this->getIdMap();
+ $map = array_flip($forward);
+ if (isset($object->fields)) {
+ foreach ($object->fields as $field) {
+ $id = $field->datafield_id;
+ if (isset($map[$id])) {
+ $field->datafield_id = $map[$id];
+ } else {
+ $field->datafield_id = "(NEW)";
+ }
+ }
+ }
+ }
+
+ /**
+ * @return int
+ */
+ protected function getNextNewId()
+ {
+ return $this->nextNewId++;
+ }
+
+ protected function getRequiredIds()
+ {
+ if ($this->requiredIds === null) {
+ if (isset($this->objects['Datafield'])) {
+ $this->requiredIds = array_keys($this->objects['Datafield']);
+ } else {
+ $ids = [];
+ foreach ($this->objects as $typeName => $objects) {
+ foreach ($objects as $key => $object) {
+ if (isset($object->fields)) {
+ foreach ($object->fields as $field) {
+ $ids[$field->datafield_id] = true;
+ }
+ }
+ }
+ }
+
+ $this->requiredIds = array_keys($ids);
+ }
+ }
+
+ return $this->requiredIds;
+ }
+
+ /**
+ * @param $type
+ * @return object[]
+ */
+ protected function getObjectsByType($type)
+ {
+ if (isset($this->objects->$type)) {
+ return (array) $this->objects->$type;
+ } else {
+ return [];
+ }
+ }
+
+ /**
+ * @return DirectorDatafield[]
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ protected function getTargetFields()
+ {
+ if ($this->targetFields === null) {
+ $this->calculateIdMap();
+ }
+
+ return $this->targetFields;
+ }
+
+ /**
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ protected function getIdMap()
+ {
+ if ($this->idMap === null) {
+ $this->calculateIdMap();
+ }
+
+ return $this->idMap;
+ }
+
+ /**
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ protected function calculateIdMap()
+ {
+ $this->idMap = [];
+ $this->targetFields = [];
+ foreach ($this->getObjectsByType('Datafield') as $id => $object) {
+ unset($object->category_id); // Fix old baskets
+ // Hint: import() doesn't store!
+ $new = DirectorDatafield::import($object, $this->targetDb);
+ if ($new->hasBeenLoadedFromDb()) {
+ $newId = (int) $new->get('id');
+ } else {
+ $newId = sprintf('NEW(%s)', $this->getNextNewId());
+ }
+ $this->idMap[$id] = $newId;
+ $this->targetFields[$id] = $new;
+ }
+ }
+}
diff --git a/library/Director/DirectorObject/Automation/CompareBasketObject.php b/library/Director/DirectorObject/Automation/CompareBasketObject.php
new file mode 100644
index 0000000..ef2e9e2
--- /dev/null
+++ b/library/Director/DirectorObject/Automation/CompareBasketObject.php
@@ -0,0 +1,146 @@
+<?php
+
+namespace Icinga\Module\Director\DirectorObject\Automation;
+
+use Icinga\Module\Director\Core\Json;
+use ipl\Html\Error;
+use RuntimeException;
+use function array_key_exists;
+use function is_array;
+use function is_object;
+use function is_scalar;
+
+class CompareBasketObject
+{
+ public static function normalize(&$value)
+ {
+ if (is_scalar($value)) {
+ return;
+ }
+ if (is_array($value)) {
+ foreach ($value as $k => &$v) {
+ static::normalize($v);
+ }
+ unset($v);
+ }
+ if (is_object($value)) {
+ $sorted = (array) $value;
+ // foreign baskets might not sort as we do:
+ ksort($sorted);
+ foreach ($sorted as $k => &$v) {
+ static::normalize($v);
+ }
+ unset($v);
+ $value = $sorted;
+
+ // foreign baskets might not sort those lists correctly:
+ if (isset($value->list_name) && isset($value->entries)) {
+ static::sortListBy('entry_name', $value->entries);
+ }
+ if (isset($value->fields)) {
+ static::sortListBy('datafield_id', $value->fields);
+ }
+ }
+ }
+
+ protected static function sortListBy($key, &$list)
+ {
+ usort($list, function ($a, $b) use ($key) {
+ return $a->$key > $b->$key ? -1 : 1;
+ });
+ }
+
+ public static function equals($a, $b)
+ {
+ if (is_scalar($a)) {
+ return $a === $b;
+ }
+
+ if ($a === null) {
+ return $b === null;
+ }
+
+ // Well... this is annoying :-/
+ $a = Json::decode(Json::encode($a, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
+ $b = Json::decode(Json::encode($b, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
+ if (is_array($a)) {
+ // Empty arrays VS empty objects :-( This is a fallback, not needed unless en/decode takes place
+ if (empty($a) && is_object($b) && (array) $b === []) {
+ return true;
+ }
+ if (! is_array($b)) {
+ return false;
+ }
+ if (array_keys($a) !== array_keys($b)) {
+ return false;
+ }
+ foreach ($a as $k => $v) {
+ if (array_key_exists($k, $b) && static::equals($b[$k], $v)) {
+ continue;
+ }
+ return false;
+ }
+
+ return true;
+ }
+
+ if (is_object($a)) {
+ // Well... empty arrays VS empty objects :-(
+ if ($b === [] && (array) $a === []) {
+ return true;
+ }
+ if (! is_object($b)) {
+ return false;
+ }
+
+ // Workaround, same as above
+ if (isset($a->list_name) && isset($a->entries)) {
+ if (! isset($b->entries)) {
+ return false;
+ }
+ static::sortListBy('entry_name', $a->entries);
+ static::sortListBy('entry_name', $b->entries);
+ }
+ if (isset($a->fields) && isset($b->fields)) {
+ static::sortListBy('datafield_id', $a->fields);
+ static::sortListBy('datafield_id', $b->fields);
+ }
+ foreach ((array) $a as $k => $v) {
+ if (property_exists($b, $k) && static::equals($v, $b->$k)) {
+ continue;
+ }
+ if (! property_exists($b, $k)) {
+ if ($v === null) {
+ continue;
+ }
+ // Deal with two special defaults:
+ if ($k === 'set_if_format' && $v === 'string') {
+ continue;
+ }
+ if ($k === 'disabled' && $v === false) {
+ continue;
+ }
+ }
+ return false;
+ }
+ foreach ((array) $b as $k => $v) {
+ if (! property_exists($a, $k)) {
+ if ($v === null) {
+ continue;
+ }
+ // Once again:
+ if ($k === 'set_if_format' && $v === 'string') {
+ continue;
+ }
+ if ($k === 'disabled' && $v === false) {
+ continue;
+ }
+ return false;
+ }
+ }
+ return true;
+ }
+
+ throw new RuntimeException("Cannot compare " . Error::getPhpTypeName($a));
+ }
+}
diff --git a/library/Director/DirectorObject/Automation/ExportInterface.php b/library/Director/DirectorObject/Automation/ExportInterface.php
new file mode 100644
index 0000000..275dfed
--- /dev/null
+++ b/library/Director/DirectorObject/Automation/ExportInterface.php
@@ -0,0 +1,20 @@
+<?php
+
+namespace Icinga\Module\Director\DirectorObject\Automation;
+
+use Icinga\Module\Director\Db;
+
+interface ExportInterface
+{
+ /**
+ * @deprecated
+ * @return \stdClass
+ */
+ public function export();
+
+ public static function import($plain, Db $db, $replace = false);
+
+ // TODO:
+ // public function getXyzChecksum();
+ public function getUniqueIdentifier();
+}
diff --git a/library/Director/DirectorObject/Automation/ImportExport.php b/library/Director/DirectorObject/Automation/ImportExport.php
new file mode 100644
index 0000000..a5e72fa
--- /dev/null
+++ b/library/Director/DirectorObject/Automation/ImportExport.php
@@ -0,0 +1,149 @@
+<?php
+
+namespace Icinga\Module\Director\DirectorObject\Automation;
+
+use Icinga\Module\Director\Data\Exporter;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Objects\DirectorDatafield;
+use Icinga\Module\Director\Objects\DirectorDatalist;
+use Icinga\Module\Director\Objects\DirectorJob;
+use Icinga\Module\Director\Objects\IcingaHostGroup;
+use Icinga\Module\Director\Objects\IcingaServiceGroup;
+use Icinga\Module\Director\Objects\IcingaServiceSet;
+use Icinga\Module\Director\Objects\IcingaTemplateChoiceHost;
+use Icinga\Module\Director\Objects\ImportSource;
+use Icinga\Module\Director\Objects\SyncRule;
+
+class ImportExport
+{
+ /** @var Db */
+ protected $connection;
+
+ /** @var Exporter */
+ protected $exporter;
+
+ public function __construct(Db $connection)
+ {
+ $this->connection = $connection;
+ $this->exporter = new Exporter($connection);
+ }
+
+ public function serializeAllServiceSets()
+ {
+ $res = [];
+ foreach (IcingaServiceSet::loadAll($this->connection) as $object) {
+ if ($object->get('host_id')) {
+ continue;
+ }
+ $res[] = $this->exporter->export($object);
+ }
+
+ return $res;
+ }
+
+ public function serializeAllHostTemplateChoices()
+ {
+ $res = [];
+ foreach (IcingaTemplateChoiceHost::loadAll($this->connection) as $object) {
+ $res[] = $this->exporter->export($object);
+ }
+
+ return $res;
+ }
+
+ public function serializeAllHostGroups()
+ {
+ $res = [];
+ foreach (IcingaHostGroup::loadAll($this->connection) as $object) {
+ $res[] = $object->toPlainObject();
+ }
+
+ return $res;
+ }
+
+ public function serializeAllServiceGroups()
+ {
+ $res = [];
+ foreach (IcingaServiceGroup::loadAll($this->connection) as $object) {
+ $res[] = $object->toPlainObject();
+ }
+
+ return $res;
+ }
+
+ public function serializeAllDataFields()
+ {
+ $res = [];
+ foreach (DirectorDatafield::loadAll($this->connection) as $object) {
+ $res[] = $this->exporter->export($object);
+ }
+
+ return $res;
+ }
+
+ public function serializeAllDataLists()
+ {
+ $res = [];
+ foreach (DirectorDatalist::loadAll($this->connection) as $object) {
+ $res[] = $this->exporter->export($object);
+ }
+
+ return $res;
+ }
+
+ public function serializeAllJobs()
+ {
+ $res = [];
+ foreach (DirectorJob::loadAll($this->connection) as $object) {
+ $res[] = $this->exporter->export($object);
+ }
+
+ return $res;
+ }
+
+ public function serializeAllImportSources()
+ {
+ $res = [];
+ foreach (ImportSource::loadAll($this->connection) as $object) {
+ $res[] = $this->exporter->export($object);
+ }
+
+ return $res;
+ }
+
+ public function serializeAllSyncRules()
+ {
+ $res = [];
+ foreach (SyncRule::loadAll($this->connection) as $object) {
+ $res[] = $this->exporter->export($object);
+ }
+
+ return $res;
+ }
+
+ public function unserializeImportSources($objects)
+ {
+ $count = 0;
+ $this->connection->runFailSafeTransaction(function () use ($objects, &$count) {
+ foreach ($objects as $object) {
+ ImportSource::import($object, $this->connection)->store();
+ $count++;
+ }
+ });
+
+ return $count;
+ }
+
+ public function unserializeSyncRules($objects)
+ {
+ $count = 0;
+ $this->connection->runFailSafeTransaction(function () use ($objects, &$count) {
+ foreach ($objects as $object) {
+ SyncRule::import($object, $this->connection)->store();
+ }
+ $count++;
+ });
+
+ return $count;
+ }
+}
diff --git a/library/Director/DirectorObject/Lookup/AppliedServiceInfo.php b/library/Director/DirectorObject/Lookup/AppliedServiceInfo.php
new file mode 100644
index 0000000..abda497
--- /dev/null
+++ b/library/Director/DirectorObject/Lookup/AppliedServiceInfo.php
@@ -0,0 +1,109 @@
+<?php
+
+namespace Icinga\Module\Director\DirectorObject\Lookup;
+
+use gipfl\IcingaWeb2\Url;
+use Icinga\Data\Filter\Filter;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Objects\HostApplyMatches;
+use Icinga\Module\Director\Objects\IcingaHost;
+use Ramsey\Uuid\Uuid;
+use Ramsey\Uuid\UuidInterface;
+
+/**
+ * A Service Apply Rule matching this Host, generating a Service with the given
+ * name
+ */
+class AppliedServiceInfo implements ServiceInfo
+{
+ /** @var string */
+ protected $hostName;
+
+ /** @var string */
+ protected $serviceName;
+
+ /** @var int */
+ protected $serviceApplyRuleId;
+
+ /** @var UuidInterface */
+ protected $uuid;
+
+ public function __construct($hostName, $serviceName, $serviceApplyRuleId, UuidInterface $uuid)
+ {
+ $this->hostName = $hostName;
+ $this->serviceName= $serviceName;
+ $this->serviceApplyRuleId = $serviceApplyRuleId;
+ $this->uuid = $uuid;
+ }
+
+ public static function find(IcingaHost $host, $serviceName)
+ {
+ $matcher = HostApplyMatches::prepare($host);
+ $connection = $host->getConnection();
+ foreach (static::fetchApplyRulesByServiceName($connection, $serviceName) as $rule) {
+ if ($matcher->matchesFilter($rule->filter)) {
+ return new static($host->getObjectName(), $serviceName, (int) $rule->id, $rule->uuid);
+ }
+ }
+
+ return null;
+ }
+
+ public function getHostName()
+ {
+ return $this->hostName;
+ }
+
+ /**
+ * @return int
+ */
+ public function getServiceApplyRuleId()
+ {
+ return $this->serviceApplyRuleId;
+ }
+
+ public function getName()
+ {
+ return $this->serviceName;
+ }
+
+ public function getUuid()
+ {
+ return $this->uuid;
+ }
+
+ public function getUrl()
+ {
+ return Url::fromPath('director/host/appliedservice', [
+ 'name' => $this->hostName,
+ 'service_id' => $this->serviceApplyRuleId,
+ ]);
+ }
+
+ public function requiresOverrides()
+ {
+ return true;
+ }
+
+ protected static function fetchApplyRulesByServiceName(Db $connection, $serviceName)
+ {
+ $db = $connection->getDbAdapter();
+ $query = $db->select()
+ ->from(['s' => 'icinga_service'], [
+ 'id' => 's.id',
+ 'uuid' => 's.uuid',
+ 'name' => 's.object_name',
+ 'assign_filter' => 's.assign_filter',
+ ])
+ ->where('object_name = ?', $serviceName)
+ ->where('object_type = ? AND assign_filter IS NOT NULL', 'apply');
+
+ $allRules = $db->fetchAll($query);
+ foreach ($allRules as $rule) {
+ $rule->uuid = Uuid::fromBytes(Db\DbUtil::binaryResult($rule->uuid));
+ $rule->filter = Filter::fromQueryString($rule->assign_filter);
+ }
+
+ return $allRules;
+ }
+}
diff --git a/library/Director/DirectorObject/Lookup/AppliedServiceSetServiceInfo.php b/library/Director/DirectorObject/Lookup/AppliedServiceSetServiceInfo.php
new file mode 100644
index 0000000..b5785d5
--- /dev/null
+++ b/library/Director/DirectorObject/Lookup/AppliedServiceSetServiceInfo.php
@@ -0,0 +1,127 @@
+<?php
+
+namespace Icinga\Module\Director\DirectorObject\Lookup;
+
+use gipfl\IcingaWeb2\Url;
+use Icinga\Data\Filter\Filter;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Objects\HostApplyMatches;
+use Icinga\Module\Director\Objects\IcingaHost;
+use Ramsey\Uuid\Uuid;
+use Ramsey\Uuid\UuidInterface;
+
+/**
+ * A Service that makes part of a Service Set Apply Rule matching this Host,
+ * generating a Service with the given name
+ */
+class AppliedServiceSetServiceInfo implements ServiceInfo
+{
+ /** @var string */
+ protected $hostName;
+
+ /** @var string */
+ protected $serviceName;
+
+ /** @var string */
+ protected $serviceSetName;
+
+ /** @var UuidInterface */
+ protected $uuid;
+
+ public function __construct($hostName, $serviceName, $serviceSetName, UuidInterface $uuid)
+ {
+ $this->hostName = $hostName;
+ $this->serviceName = $serviceName;
+ $this->serviceSetName = $serviceSetName;
+ $this->uuid = $uuid;
+ }
+
+ public static function find(IcingaHost $host, $serviceName)
+ {
+ $matcher = HostApplyMatches::prepare($host);
+ $connection = $host->getConnection();
+ foreach (static::fetchServiceSetApplyRulesByServiceName($connection, $host->get('id'), $serviceName) as $rule) {
+ if ($matcher->matchesFilter($rule->filter)) {
+ return new static(
+ $host->getObjectName(),
+ $serviceName,
+ $rule->service_set_name,
+ $rule->uuid
+ );
+ }
+ }
+
+ return null;
+ }
+
+ public function getHostName()
+ {
+ return $this->hostName;
+ }
+
+ public function getUuid()
+ {
+ return $this->uuid;
+ }
+
+ /**
+ * @return string
+ */
+ public function getServiceSetName()
+ {
+ return $this->serviceSetName;
+ }
+
+ public function getName()
+ {
+ return $this->serviceName;
+ }
+
+ public function getUrl()
+ {
+ return Url::fromPath('director/host/servicesetservice', [
+ 'name' => $this->hostName,
+ 'service' => $this->serviceName,
+ 'set' => $this->serviceSetName,
+ ]);
+ }
+
+ public function requiresOverrides()
+ {
+ return true;
+ }
+
+ protected static function fetchServiceSetApplyRulesByServiceName(Db $connection, $hostId, $serviceName)
+ {
+ $db = $connection->getDbAdapter();
+ $query = $db->select()
+ ->from(['s' => 'icinga_service'], [
+ 'id' => 's.id',
+ 'uuid' => 'ss.uuid',
+ 'name' => 's.object_name',
+ 'assign_filter' => 'ss.assign_filter',
+ 'service_set_name' => 'ss.object_name',
+ ])
+ ->join(
+ ['ss' => 'icinga_service_set'],
+ 's.service_set_id = ss.id',
+ []
+ )
+ ->where('s.object_name = ?', $serviceName)
+ ->where('ss.assign_filter IS NOT NULL')
+ ->where( // Ignore deactivated Services:
+ 'NOT EXISTS (SELECT 1 FROM icinga_host_service_blacklist hsb'
+ . ' WHERE hsb.host_id = ? AND hsb.service_id = s.id)',
+ (int) $hostId
+ );
+ ;
+
+ $allRules = $db->fetchAll($query);
+ foreach ($allRules as $rule) {
+ $rule->uuid = Uuid::fromBytes(Db\DbUtil::binaryResult($rule->uuid));
+ $rule->filter = Filter::fromQueryString($rule->assign_filter);
+ }
+
+ return $allRules;
+ }
+}
diff --git a/library/Director/DirectorObject/Lookup/InheritedServiceInfo.php b/library/Director/DirectorObject/Lookup/InheritedServiceInfo.php
new file mode 100644
index 0000000..875d5fb
--- /dev/null
+++ b/library/Director/DirectorObject/Lookup/InheritedServiceInfo.php
@@ -0,0 +1,94 @@
+<?php
+
+namespace Icinga\Module\Director\DirectorObject\Lookup;
+
+use gipfl\IcingaWeb2\Url;
+use Icinga\Module\Director\Objects\IcingaHost;
+use Icinga\Module\Director\Objects\IcingaService;
+use Icinga\Module\Director\Repository\IcingaTemplateRepository;
+use Ramsey\Uuid\UuidInterface;
+
+/**
+ * A Service attached to a parent Service Template. This is a shortcut for
+ * 'assign where "Template Name" in templates'
+ */
+class InheritedServiceInfo implements ServiceInfo
+{
+ /** @var string */
+ protected $hostName;
+
+ /** @var string */
+ protected $hostTemplateName;
+
+ /** @var string */
+ protected $serviceName;
+
+ /** @var UuidInterface */
+ protected $uuid;
+
+ public function __construct($hostName, $hostTemplateName, $serviceName, UuidInterface $uuid)
+ {
+ $this->hostName = $hostName;
+ $this->hostTemplateName = $hostTemplateName;
+ $this->serviceName= $serviceName;
+ $this->uuid = $uuid;
+ }
+
+ public static function find(IcingaHost $host, $serviceName)
+ {
+ $db = $host->getConnection();
+ foreach (IcingaTemplateRepository::instanceByObject($host)->getTemplatesFor($host, true) as $parent) {
+ $key = [
+ 'host_id' => $parent->get('id'),
+ 'object_name' => $serviceName
+ ];
+ if (IcingaService::exists($key, $db)) {
+ return new static(
+ $host->getObjectName(),
+ $parent->getObjectName(),
+ $serviceName,
+ IcingaService::load($key, $db)->getUniqueId()
+ );
+ }
+ }
+
+ return false;
+ }
+
+ public function getHostName()
+ {
+ return $this->hostName;
+ }
+
+ public function getUuid()
+ {
+ return $this->uuid;
+ }
+
+ /**
+ * @return string
+ */
+ public function getHostTemplateName()
+ {
+ return $this->hostTemplateName;
+ }
+
+ public function getName()
+ {
+ return $this->serviceName;
+ }
+
+ public function getUrl()
+ {
+ return Url::fromPath('director/host/inheritedservice', [
+ 'name' => $this->hostName,
+ 'service' => $this->serviceName,
+ 'inheritedFrom' => $this->hostTemplateName
+ ]);
+ }
+
+ public function requiresOverrides()
+ {
+ return true;
+ }
+}
diff --git a/library/Director/DirectorObject/Lookup/ServiceFinder.php b/library/Director/DirectorObject/Lookup/ServiceFinder.php
new file mode 100644
index 0000000..fb8d74c
--- /dev/null
+++ b/library/Director/DirectorObject/Lookup/ServiceFinder.php
@@ -0,0 +1,79 @@
+<?php
+
+namespace Icinga\Module\Director\DirectorObject\Lookup;
+
+use gipfl\IcingaWeb2\Url;
+use Icinga\Authentication\Auth;
+use Icinga\Module\Director\Objects\HostApplyMatches;
+use Icinga\Module\Director\Objects\IcingaHost;
+use RuntimeException;
+
+class ServiceFinder
+{
+ /** @var IcingaHost */
+ protected $host;
+
+ /** @var ?Auth */
+ protected $auth;
+
+ /** @var IcingaHost[] */
+ protected $parents;
+
+ /** @var HostApplyMatches */
+ protected $applyMatcher;
+
+ /** @var \Icinga\Module\Director\Db */
+ protected $db;
+
+ public function __construct(IcingaHost $host, Auth $auth = null)
+ {
+ $this->host = $host;
+ $this->auth = $auth;
+ $this->db = $host->getConnection();
+ }
+
+ public static function find(IcingaHost $host, $serviceName)
+ {
+ foreach ([
+ SingleServiceInfo::class,
+ InheritedServiceInfo::class,
+ ServiceSetServiceInfo::class,
+ AppliedServiceInfo::class,
+ AppliedServiceSetServiceInfo::class,
+ ] as $class) {
+ /** @var ServiceInfo $class */
+ if ($info = $class::find($host, $serviceName)) {
+ return $info;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * @param $serviceName
+ * @return Url
+ */
+ public function getRedirectionUrl($serviceName)
+ {
+ if ($this->auth === null) {
+ throw new RuntimeException('Auth is required for ServiceFinder when dealing when asking for URLs');
+ }
+ if ($this->auth->hasPermission('director/host')) {
+ if ($info = $this::find($this->host, $serviceName)) {
+ return $info->getUrl();
+ }
+ }
+ if ($this->auth->hasPermission('director/monitoring/services-ro')) {
+ return Url::fromPath('director/host/servicesro', [
+ 'name' => $this->host->getObjectName(),
+ 'service' => $serviceName
+ ]);
+ }
+
+ return Url::fromPath('director/host/invalidservice', [
+ 'name' => $this->host->getObjectName(),
+ 'service' => $serviceName,
+ ]);
+ }
+}
diff --git a/library/Director/DirectorObject/Lookup/ServiceInfo.php b/library/Director/DirectorObject/Lookup/ServiceInfo.php
new file mode 100644
index 0000000..3c8c51b
--- /dev/null
+++ b/library/Director/DirectorObject/Lookup/ServiceInfo.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace Icinga\Module\Director\DirectorObject\Lookup;
+
+use gipfl\IcingaWeb2\Url;
+use Icinga\Module\Director\Objects\IcingaHost;
+use Ramsey\Uuid\UuidInterface;
+
+interface ServiceInfo
+{
+ /**
+ * The final Service name
+ *
+ * @return string
+ */
+ public function getName();
+
+ /**
+ * The host the final (rendered, processed) Service belongs to
+ *
+ * @return string
+ */
+ public function getHostName();
+
+ /**
+ * @return Url
+ */
+ public function getUrl();
+
+ /**
+ * @return UuidInterface
+ */
+ public function getUuid();
+
+ /**
+ * @return bool
+ */
+ public function requiresOverrides();
+
+ /**
+ * @param IcingaHost $host
+ * @param $serviceName
+ * @return ServiceInfo|false
+ */
+ public static function find(IcingaHost $host, $serviceName);
+}
diff --git a/library/Director/DirectorObject/Lookup/ServiceSetServiceInfo.php b/library/Director/DirectorObject/Lookup/ServiceSetServiceInfo.php
new file mode 100644
index 0000000..a980da8
--- /dev/null
+++ b/library/Director/DirectorObject/Lookup/ServiceSetServiceInfo.php
@@ -0,0 +1,121 @@
+<?php
+
+namespace Icinga\Module\Director\DirectorObject\Lookup;
+
+use gipfl\IcingaWeb2\Url;
+use Icinga\Module\Director\Objects\IcingaHost;
+use Icinga\Module\Director\Repository\IcingaTemplateRepository;
+use Ramsey\Uuid\Uuid;
+use Ramsey\Uuid\UuidInterface;
+
+/**
+ * A service belonging to a Service Set, attached either directly to the given
+ * Host or to one of it's inherited Host Templates
+ */
+class ServiceSetServiceInfo implements ServiceInfo
+{
+ /** @var string */
+ protected $hostName;
+
+ /** @var string */
+ protected $serviceName;
+
+ /** @var string */
+ protected $serviceSetName;
+
+ /** @var UuidInterface */
+ protected $uuid;
+
+ public function __construct($hostName, $serviceName, $serviceSetName, UuidInterface $uuid)
+ {
+ $this->hostName = $hostName;
+ $this->serviceName = $serviceName;
+ $this->serviceSetName = $serviceSetName;
+ $this->uuid = $uuid;
+ }
+
+ public static function find(IcingaHost $host, $serviceName)
+ {
+ $ids = [$host->get('id')];
+
+ foreach (IcingaTemplateRepository::instanceByObject($host)->getTemplatesFor($host, true) as $parent) {
+ $ids[] = $parent->get('id');
+ }
+
+ $db = $host->getConnection()->getDbAdapter();
+ $query = $db->select()
+ ->from(
+ ['s' => 'icinga_service'],
+ [
+ 'service_set_name' => 'ss.object_name',
+ 'uuid' => 's.uuid',
+ ]
+ )->join(
+ ['ss' => 'icinga_service_set'],
+ 's.service_set_id = ss.id',
+ []
+ )->join(
+ ['hsi' => 'icinga_service_set_inheritance'],
+ 'hsi.parent_service_set_id = ss.id',
+ []
+ )->join(
+ ['hs' => 'icinga_service_set'],
+ 'hs.id = hsi.service_set_id',
+ []
+ )->where('hs.host_id IN (?)', $ids)
+ ->where('s.object_name = ?', $serviceName)
+ ->where( // Ignore deactivated Services:
+ 'NOT EXISTS (SELECT 1 FROM icinga_host_service_blacklist hsb'
+ . ' WHERE hsb.host_id = ? AND hsb.service_id = s.id)',
+ (int) $host->get('id')
+ );
+
+ if ($row = $db->fetchRow($query)) {
+ return new static(
+ $host->getObjectName(),
+ $serviceName,
+ $row->service_set_name,
+ Uuid::fromBytes($row->uuid)
+ );
+ }
+
+ return null;
+ }
+
+ public function getHostName()
+ {
+ return $this->hostName;
+ }
+
+ public function getName()
+ {
+ return $this->serviceName;
+ }
+
+ public function getUuid()
+ {
+ return $this->uuid;
+ }
+
+ /**
+ * @return string
+ */
+ public function getServiceSetName()
+ {
+ return $this->serviceSetName;
+ }
+
+ public function getUrl()
+ {
+ return Url::fromPath('director/host/servicesetservice', [
+ 'name' => $this->hostName,
+ 'service' => $this->serviceName,
+ 'set' => $this->serviceSetName,
+ ]);
+ }
+
+ public function requiresOverrides()
+ {
+ return true;
+ }
+}
diff --git a/library/Director/DirectorObject/Lookup/SingleServiceInfo.php b/library/Director/DirectorObject/Lookup/SingleServiceInfo.php
new file mode 100644
index 0000000..af54fc7
--- /dev/null
+++ b/library/Director/DirectorObject/Lookup/SingleServiceInfo.php
@@ -0,0 +1,83 @@
+<?php
+
+namespace Icinga\Module\Director\DirectorObject\Lookup;
+
+use gipfl\IcingaWeb2\Url;
+use Icinga\Module\Director\Objects\IcingaHost;
+use Icinga\Module\Director\Objects\IcingaService;
+use Ramsey\Uuid\UuidInterface;
+
+/**
+ * A single service, directly attached to a Host Object. Overrides might
+ * still be used when use_var_overrides is true.
+ */
+class SingleServiceInfo implements ServiceInfo
+{
+ /** @var string */
+ protected $hostName;
+
+ /** @var string */
+ protected $serviceName;
+
+ /** @var bool */
+ protected $useOverrides;
+
+ /** @var UuidInterface */
+ protected $uuid;
+
+ public function __construct($hostName, $serviceName, UuidInterface $uuid, $useOverrides)
+ {
+ $this->hostName = $hostName;
+ $this->serviceName = $serviceName;
+ $this->useOverrides = $useOverrides;
+ $this->uuid = $uuid;
+ }
+
+ public static function find(IcingaHost $host, $serviceName)
+ {
+ $keyParams = [
+ 'host_id' => $host->get('id'),
+ 'object_name' => $serviceName
+ ];
+ $connection = $host->getConnection();
+ if (IcingaService::exists($keyParams, $connection)) {
+ $service = IcingaService::load($keyParams, $connection);
+ $useOverrides = $service->getResolvedVar('use_var_overrides') === 'y';
+
+ return new static($host->getObjectName(), $serviceName, $service->getUniqueId(), $useOverrides);
+ }
+
+ return false;
+ }
+
+ public function getHostName()
+ {
+ return $this->hostName;
+ }
+
+ public function getName()
+ {
+ return $this->serviceName;
+ }
+
+ /**
+ * @return UuidInterface
+ */
+ public function getUuid()
+ {
+ return $this->uuid;
+ }
+
+ public function getUrl()
+ {
+ return Url::fromPath('director/service/edit', [
+ 'host' => $this->hostName,
+ 'name' => $this->serviceName,
+ ]);
+ }
+
+ public function requiresOverrides()
+ {
+ return $this->useOverrides;
+ }
+}
diff --git a/library/Director/DirectorObject/ObjectPurgeHelper.php b/library/Director/DirectorObject/ObjectPurgeHelper.php
new file mode 100644
index 0000000..a043965
--- /dev/null
+++ b/library/Director/DirectorObject/ObjectPurgeHelper.php
@@ -0,0 +1,149 @@
+<?php
+
+namespace Icinga\Module\Director\DirectorObject;
+
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Objects\IcingaObject;
+use InvalidArgumentException;
+
+class ObjectPurgeHelper
+{
+ protected $db;
+
+ protected $force = false;
+
+ public function __construct(Db $db)
+ {
+ $this->db = $db;
+ }
+
+ public function force($force = true)
+ {
+ $this->force = $force;
+ return $this;
+ }
+
+ public function purge(array $keep, $class, $objectType = null)
+ {
+ if (empty($keep) && ! $this->force) {
+ throw new InvalidArgumentException('I will NOT purge all object unless being forced to do so');
+ }
+ $db = $this->db->getDbAdapter();
+ /** @var IcingaObject $class cheating, it's a class name, not an object */
+ $dummy = $class::create();
+ assert($dummy instanceof IcingaObject);
+ $keyCols = (array) $dummy->getKeyName();
+ if ($objectType !== null) {
+ $keyCols[] = 'object_type';
+ }
+
+ $keepKeys = [];
+ foreach ($keep as $object) {
+ if ($object instanceof \stdClass) {
+ $properties = (array) $object;
+ // TODO: this is object-specific and to be found in the ::import() function!
+ unset($properties['fields']);
+ $object = $class::fromPlainObject($properties);
+ } elseif (\get_class($object) !== $class) {
+ throw new InvalidArgumentException(
+ 'Can keep only matching objects, expected "%s", got "%s',
+ $class,
+ \get_class($keep)
+ );
+ }
+ $key = [];
+ foreach ($keyCols as $col) {
+ $key[$col] = $object->get($col);
+ }
+ $keepKeys[$this->makeRowKey($key)] = true;
+ }
+
+ $query = $db->select()->from(['o' => $dummy->getTableName()], $keyCols);
+ if ($objectType !== null) {
+ $query->where('object_type = ?', $objectType);
+ }
+ $allExisting = [];
+ foreach ($db->fetchAll($query) as $row) {
+ $allExisting[$this->makeRowKey($row)] = $row;
+ }
+ $remove = [];
+ foreach ($allExisting as $key => $keyProperties) {
+ if (! isset($keepKeys[$key])) {
+ $remove[] = $keyProperties;
+ }
+ }
+ $db->beginTransaction();
+ foreach ($remove as $keyProperties) {
+ $keyColumn = $class::getKeyColumnName();
+ if (is_array($keyColumn)) {
+ $object = $class::load((array) $keyProperties, $this->db);
+ } else {
+ $object = $class::load($keyProperties->$keyColumn, $this->db);
+ }
+ $object->delete();
+ }
+ $db->commit();
+ }
+
+ public static function listObjectTypesAvailableForPurge()
+ {
+ return [
+ 'Basket',
+ 'Command',
+ 'CommandTemplate',
+ 'Dependency',
+ 'DirectorJob',
+ 'ExternalCommand',
+ 'HostGroup',
+ 'HostTemplate',
+ 'IcingaTemplateChoiceHost',
+ 'IcingaTemplateChoiceService',
+ 'ImportSource',
+ 'Notification',
+ 'NotificationTemplate',
+ 'ServiceGroup',
+ 'ServiceSet',
+ 'ServiceTemplate',
+ 'SyncRule',
+ 'TimePeriod',
+ ];
+ }
+
+ public static function objectTypeIsEligibleForPurge($type)
+ {
+ return in_array($type, static::listObjectTypesAvailableForPurge(), true);
+ }
+
+ public static function assertObjectTypesAreEligibleForPurge($types)
+ {
+ $invalid = [];
+ foreach ($types as $type) {
+ if (! static::objectTypeIsEligibleForPurge($type)) {
+ $invalid[] = $type;
+ }
+ }
+
+ if (empty($invalid)) {
+ return;
+ }
+
+ if (count($invalid) === 1) {
+ $message = sprintf('"%s" is not eligible for purge', $invalid[0]);
+ } else {
+ $message = 'The following types are not eligible for purge: '
+ . implode(', ', $invalid);
+ }
+
+ throw new InvalidArgumentException(
+ "$message. Valid types: "
+ . implode(', ', static::listObjectTypesAvailableForPurge())
+ );
+ }
+
+ protected function makeRowKey($row)
+ {
+ $row = (array) $row;
+ ksort($row);
+ return json_encode($row, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+ }
+}
diff --git a/library/Director/Exception/DuplicateKeyException.php b/library/Director/Exception/DuplicateKeyException.php
new file mode 100644
index 0000000..a9cba65
--- /dev/null
+++ b/library/Director/Exception/DuplicateKeyException.php
@@ -0,0 +1,9 @@
+<?php
+
+namespace Icinga\Module\Director\Exception;
+
+use Icinga\Exception\IcingaException;
+
+class DuplicateKeyException extends IcingaException
+{
+}
diff --git a/library/Director/Exception/JsonEncodeException.php b/library/Director/Exception/JsonEncodeException.php
new file mode 100644
index 0000000..7db2f77
--- /dev/null
+++ b/library/Director/Exception/JsonEncodeException.php
@@ -0,0 +1,7 @@
+<?php
+
+namespace Icinga\Module\Director\Exception;
+
+class JsonEncodeException extends JsonException
+{
+}
diff --git a/library/Director/Exception/JsonException.php b/library/Director/Exception/JsonException.php
new file mode 100644
index 0000000..dad848d
--- /dev/null
+++ b/library/Director/Exception/JsonException.php
@@ -0,0 +1,55 @@
+<?php
+
+namespace Icinga\Module\Director\Exception;
+
+use Icinga\Exception\IcingaException;
+
+class JsonException extends IcingaException
+{
+ public static function forLastJsonError($msg = null)
+ {
+ if ($msg === null) {
+ return new static(static::getJsonErrorMessage(\json_last_error()));
+ } else {
+ return new static($msg . ': ' . static::getJsonErrorMessage(\json_last_error()));
+ }
+ }
+
+ public static function getJsonErrorMessage($code)
+ {
+ $map = [
+ JSON_ERROR_DEPTH => 'The maximum stack depth has been exceeded',
+ JSON_ERROR_CTRL_CHAR => 'Control character error, possibly incorrectly encoded',
+ JSON_ERROR_STATE_MISMATCH => 'Invalid or malformed JSON',
+ JSON_ERROR_SYNTAX => 'JSON Syntax error',
+ JSON_ERROR_UTF8 => 'Malformed UTF-8 characters, possibly incorrectly encoded'
+ ];
+ if (\array_key_exists($code, $map)) {
+ return $map[$code];
+ }
+
+ if (PHP_VERSION_ID >= 50500) {
+ $map = [
+ JSON_ERROR_RECURSION => 'One or more recursive references in the value to be encoded',
+ JSON_ERROR_INF_OR_NAN => 'One or more NAN or INF values in the value to be encoded',
+ JSON_ERROR_UNSUPPORTED_TYPE => 'A value of a type that cannot be encoded was given',
+ ];
+ if (\array_key_exists($code, $map)) {
+ return $map[$code];
+ }
+ }
+
+ if (PHP_VERSION_ID >= 70000) {
+ $map = [
+ JSON_ERROR_INVALID_PROPERTY_NAME => 'A property name that cannot be encoded was given',
+ JSON_ERROR_UTF16 => 'Malformed UTF-16 characters, possibly incorrectly encoded',
+ ];
+
+ if (\array_key_exists($code, $map)) {
+ return $map[$code];
+ }
+ }
+
+ return 'An error occured when parsing a JSON string';
+ }
+}
diff --git a/library/Director/Exception/NestingError.php b/library/Director/Exception/NestingError.php
new file mode 100644
index 0000000..bc191aa
--- /dev/null
+++ b/library/Director/Exception/NestingError.php
@@ -0,0 +1,9 @@
+<?php
+
+namespace Icinga\Module\Director\Exception;
+
+use Icinga\Exception\IcingaException;
+
+class NestingError extends IcingaException
+{
+}
diff --git a/library/Director/Field/FieldSpec.php b/library/Director/Field/FieldSpec.php
new file mode 100644
index 0000000..29baa0b
--- /dev/null
+++ b/library/Director/Field/FieldSpec.php
@@ -0,0 +1,206 @@
+<?php
+
+
+namespace Icinga\Module\Director\Field;
+
+use Icinga\Module\Director\Objects\DirectorDatafield;
+use Icinga\Module\Director\Objects\IcingaObject;
+
+class FieldSpec
+{
+ /** @var string */
+ protected $varName;
+
+ /** @var string */
+ protected $category;
+
+ /** @var string */
+ protected $caption;
+
+ /** @var boolean */
+ protected $isRequired = false;
+
+ /** @var string */
+ protected $description;
+
+ /** @var string */
+ protected $dataType;
+
+ /** @var string */
+ protected $varFilter;
+
+ /** @var string */
+ protected $format = "string";
+
+ /**
+ * FieldSpec constructor.
+ * @param $dataType
+ * @param $varName
+ * @param $caption
+ */
+ public function __construct($dataType, $varName, $caption)
+ {
+ $this->dataType = $dataType;
+ $this->varName = $varName;
+ $this->caption = $caption;
+ }
+
+ public function toDataField(IcingaObject $object)
+ {
+ return DirectorDatafield::create([
+ 'varname' => $this->getVarName(),
+ 'category' => $this->getCategory(),
+ 'caption' => $this->getCaption(),
+ 'description' => $this->getDescription(),
+ 'datatype' => $this->getDataType(),
+ 'format' => $this->getFormat(),
+ 'var_filter' => $this->getVarFilter(),
+ 'icinga_type' => $object->getShortTableName(),
+ 'object_id' => $object->get('id'),
+ ]);
+ }
+
+ /**
+ * @return string
+ */
+ public function getVarName()
+ {
+ return $this->varName;
+ }
+
+ /**
+ * @param string $varName
+ * @return FieldSpec
+ */
+ public function setVarName($varName)
+ {
+ $this->varName = $varName;
+ return $this;
+ }
+
+ /**
+ * @return string
+ */
+ public function getCaption()
+ {
+ return $this->caption;
+ }
+
+ /**
+ * @param string $caption
+ * @return FieldSpec
+ */
+ public function setCaption($caption)
+ {
+ $this->caption = $caption;
+ return $this;
+ }
+
+ /**
+ * @return bool
+ */
+ public function isRequired()
+ {
+ return $this->isRequired;
+ }
+
+ /**
+ * @param bool $isRequired
+ * @return FieldSpec
+ */
+ public function setIsRequired($isRequired)
+ {
+ $this->isRequired = $isRequired;
+ return $this;
+ }
+
+ /**
+ * @return string
+ */
+ public function getDescription()
+ {
+ return $this->description;
+ }
+
+ /**
+ * @param string $description
+ * @return FieldSpec
+ */
+ public function setDescription($description)
+ {
+ $this->description = $description;
+ return $this;
+ }
+
+ /**
+ * @return string
+ */
+ public function getDataType()
+ {
+ return $this->dataType;
+ }
+
+ /**
+ * @param string $dataType
+ * @return FieldSpec
+ */
+ public function setDataType($dataType)
+ {
+ $this->dataType = $dataType;
+ return $this;
+ }
+
+ /**
+ * @return string
+ */
+ public function getVarFilter()
+ {
+ return $this->varFilter;
+ }
+
+ /**
+ * @param string $varFilter
+ * @return FieldSpec
+ */
+ public function setVarFilter($varFilter)
+ {
+ $this->varFilter = $varFilter;
+ return $this;
+ }
+
+ /**
+ * @return string
+ */
+ public function getFormat()
+ {
+ return $this->format;
+ }
+
+ /**
+ * @param string $format
+ * @return FieldSpec
+ */
+ public function setFormat($format)
+ {
+ $this->format = $format;
+ return $this;
+ }
+
+ /**
+ * @return string
+ */
+ public function getCategory()
+ {
+ return $this->category;
+ }
+
+ /**
+ * @param string $category
+ * @return FieldSpec
+ */
+ public function setCategory($category)
+ {
+ $this->category = $category;
+ return $this;
+ }
+}
diff --git a/library/Director/Health.php b/library/Director/Health.php
new file mode 100644
index 0000000..0d85d18
--- /dev/null
+++ b/library/Director/Health.php
@@ -0,0 +1,285 @@
+<?php
+
+namespace Icinga\Module\Director;
+
+use Icinga\Application\Config;
+use Icinga\Date\DateFormatter;
+use Icinga\Module\Director\CheckPlugin\Check;
+use Icinga\Module\Director\CheckPlugin\CheckResults;
+use Icinga\Module\Director\Db\Migrations;
+use Icinga\Module\Director\Objects\DirectorDeploymentLog;
+use Icinga\Module\Director\Objects\DirectorJob;
+use Icinga\Module\Director\Objects\ImportSource;
+use Icinga\Module\Director\Objects\SyncRule;
+use Exception;
+
+class Health
+{
+ /** @var Db */
+ protected $connection;
+
+ /** @var string */
+ protected $dbResourceName;
+
+ protected $checks = [
+ 'config' => 'checkConfig',
+ 'sync' => 'checkSyncRules',
+ 'import' => 'checkImportSources',
+ 'jobs' => 'checkDirectorJobs',
+ 'deployment' => 'checkDeployments',
+ ];
+
+ public function setDbResourceName($name)
+ {
+ $this->dbResourceName = $name;
+
+ return $this;
+ }
+
+ public function getCheck($name)
+ {
+ if (array_key_exists($name, $this->checks)) {
+ $func = $this->checks[$name];
+ $check = $this->$func();
+ } else {
+ $check = new CheckResults('Invalid Parameter');
+ $check->fail("There is no check named '$name'");
+ }
+
+ return $check;
+ }
+
+ public function getAllChecks()
+ {
+ /** @var CheckResults[] $checks */
+ $checks = [$this->checkConfig()];
+
+ if ($checks[0]->hasErrors()) {
+ return $checks;
+ }
+
+ $checks[] = $this->checkDeployments();
+ $checks[] = $this->checkImportSources();
+ $checks[] = $this->checkSyncRules();
+ $checks[] = $this->checkDirectorJobs();
+
+ return $checks;
+ }
+
+ protected function hasDeploymentEndpoint()
+ {
+ try {
+ return $this->connection->hasDeploymentEndpoint();
+ } catch (Exception $e) {
+ return false;
+ }
+ }
+
+ public function hasResourceConfig()
+ {
+ return $this->getDbResourceName() !== null;
+ }
+
+ protected function getDbResourceName()
+ {
+ if ($this->dbResourceName === null) {
+ $this->dbResourceName = Config::module('director')->get('db', 'resource');
+ }
+
+ return $this->dbResourceName;
+ }
+
+ protected function getConnection()
+ {
+ if ($this->connection === null) {
+ $this->connection = Db::fromResourceName($this->getDbResourceName());
+ }
+
+ return $this->connection;
+ }
+
+ public function checkConfig()
+ {
+ $check = new Check('Director configuration');
+ $name = $this->getDbResourceName();
+ if ($name) {
+ $check->succeed("Database resource '$name' has been specified");
+ } else {
+ return $check->fail('No database resource has been specified');
+ }
+
+ try {
+ $db = $this->getConnection();
+ } catch (Exception $e) {
+ return $check->fail($e);
+ }
+
+ $migrations = new Migrations($db);
+ $check->assertTrue(
+ [$migrations, 'hasSchema'],
+ 'Make sure the DB schema exists'
+ );
+
+ if ($check->hasProblems()) {
+ return $check;
+ }
+
+ $check->call(function () use ($check, $migrations) {
+ $count = $migrations->countPendingMigrations();
+
+ if ($count === 0) {
+ $check->succeed('There are no pending schema migrations');
+ } elseif ($count === 1) {
+ $check->warn('There is a pending schema migration');
+ } else {
+ $check->warn(sprintf(
+ 'There are %s pending schema migrations',
+ $count
+ ));
+ }
+ });
+
+ return $check;
+ }
+
+ public function checkSyncRules()
+ {
+ $check = new CheckResults('Sync Rules');
+ $rules = SyncRule::loadAll($this->getConnection(), null, 'rule_name');
+ if (empty($rules)) {
+ $check->succeed('No Sync Rules have been defined');
+ return $check;
+ }
+ ksort($rules);
+
+ foreach ($rules as $rule) {
+ $state = $rule->get('sync_state');
+ $name = $rule->get('rule_name');
+ if ($state === 'failing') {
+ $message = $rule->get('last_error_message');
+ $check->fail("'$name' is failing: $message");
+ } elseif ($state === 'pending-changes') {
+ $check->succeed("'$name' is fine, but there are pending changes");
+ } elseif ($state === 'in-sync') {
+ $check->succeed("'$name' is in sync");
+ } else {
+ $check->fail("'$name' has never been checked", 'UNKNOWN');
+ }
+ }
+
+ return $check;
+ }
+
+ public function checkImportSources()
+ {
+ $check = new CheckResults('Import Sources');
+ $sources = ImportSource::loadAll($this->getConnection(), null, 'source_name');
+ if (empty($sources)) {
+ $check->succeed('No Import Sources have been defined');
+ return $check;
+ }
+
+ ksort($sources);
+ foreach ($sources as $src) {
+ $state = $src->get('import_state');
+ $name = $src->get('source_name');
+ if ($state === 'failing') {
+ $message = $src->get('last_error_message');
+ $check->fail("'$name' is failing: $message");
+ } elseif ($state === 'pending-changes') {
+ $check->succeed("'$name' is fine, but there are pending changes");
+ } elseif ($state === 'in-sync') {
+ $check->succeed("'$name' is in sync");
+ } else {
+ $check->fail("'$name' has never been checked", 'UNKNOWN');
+ }
+ }
+
+ return $check;
+ }
+
+ public function checkDirectorJobs()
+ {
+ $check = new CheckResults('Director Jobs');
+ $jobs = DirectorJob::loadAll($this->getConnection(), null, 'job_name');
+ if (empty($jobs)) {
+ $check->succeed('No Jobs have been defined');
+ return $check;
+ }
+ ksort($jobs);
+
+ foreach ($jobs as $job) {
+ $name = $job->get('job_name');
+ if ($job->hasBeenDisabled()) {
+ $check->succeed("'$name' has been disabled");
+ } elseif ($job->lastAttemptFailed()) {
+ $message = $job->get('last_error_message');
+ $check->fail("Last attempt for '$name' failed: $message");
+ } elseif ($job->isOverdue()) {
+ $check->fail("'$name' is overdue");
+ } elseif ($job->shouldRun()) {
+ $check->succeed("'$name' is fine, but should run now");
+ } else {
+ $check->succeed("'$name' is fine");
+ }
+ }
+
+ return $check;
+ }
+
+ public function checkDeployments()
+ {
+ $check = new Check('Director Deployments');
+
+ $db = $this->getConnection();
+
+ $check->call(function () use ($check, $db) {
+ $check->succeed(sprintf(
+ "Deployment endpoint is '%s'",
+ $db->getDeploymentEndpointName()
+ ));
+ })->call(function () use ($check, $db) {
+ $count = $db->countActivitiesSinceLastDeployedConfig();
+
+ if ($count === 1) {
+ $check->succeed('There is a single un-deployed change');
+ } else {
+ $check->succeed(sprintf(
+ 'There are %d un-deployed changes',
+ $count
+ ));
+ }
+ });
+
+ if (! DirectorDeploymentLog::hasDeployments($db)) {
+ $check->warn('Configuration has never been deployed');
+ return $check;
+ }
+
+ $latest = DirectorDeploymentLog::loadLatest($db);
+
+ $ts = $latest->getDeploymentTimestamp();
+ $time = DateFormatter::timeAgo($ts);
+ if ($latest->succeeded()) {
+ $check->succeed("The last Deployment was successful $time");
+ } elseif ($latest->isPending()) {
+ if ($ts + 180 < time()) {
+ $check->warn("The last Deployment started $time and is still pending");
+ } else {
+ $check->succeed("The last Deployment started $time and is still pending");
+ }
+ } else {
+ $check->fail("The last Deployment failed $time");
+ }
+
+ return $check;
+ }
+
+ public function __destruct()
+ {
+ if ($this->connection !== null) {
+ // We created our own connection, so let's tear it down
+ $this->connection->getDbAdapter()->closeConnection();
+ }
+ }
+}
diff --git a/library/Director/Hook/BranchSupportHook.php b/library/Director/Hook/BranchSupportHook.php
new file mode 100644
index 0000000..6615cbe
--- /dev/null
+++ b/library/Director/Hook/BranchSupportHook.php
@@ -0,0 +1,39 @@
+<?php
+
+namespace Icinga\Module\Director\Hook;
+
+use Icinga\Authentication\Auth;
+use Icinga\Module\Director\Dashboard\Dashlet\Dashlet;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Db\Branch\Branch;
+use Icinga\Module\Director\Db\Branch\BranchSTore;
+use Icinga\Web\Request;
+use ipl\Html\ValidHtml;
+
+abstract class BranchSupportHook
+{
+ /**
+ * @param Request $request
+ * @param BranchSTore $store
+ * @param Auth $auth
+ * @return Branch
+ */
+ abstract public function getBranchForRequest(Request $request, BranchStore $store, Auth $auth);
+
+ /**
+ * @param Branch $branch
+ * @param Auth $auth
+ * @param ?string $label
+ * @return ?ValidHtml
+ */
+ abstract public function linkToBranch(Branch $branch, Auth $auth, $label = null);
+
+ /**
+ * @param Db $db
+ * @return Dashlet[]
+ */
+ public function loadDashlets(Db $db)
+ {
+ return [];
+ }
+}
diff --git a/library/Director/Hook/DataTypeHook.php b/library/Director/Hook/DataTypeHook.php
new file mode 100644
index 0000000..1eb58d6
--- /dev/null
+++ b/library/Director/Hook/DataTypeHook.php
@@ -0,0 +1,59 @@
+<?php
+
+namespace Icinga\Module\Director\Hook;
+
+use Icinga\Module\Director\Web\Form\DirectorObjectForm;
+use Icinga\Module\Director\Web\Form\QuickForm;
+
+abstract class DataTypeHook
+{
+ protected $settings = array();
+
+ public function getName()
+ {
+ $parts = explode('\\', get_class($this));
+ $class = preg_replace('/DataType/', '', array_pop($parts));
+
+ if (array_shift($parts) === 'Icinga' && array_shift($parts) === 'Module') {
+ $module = array_shift($parts);
+ if ($module !== 'Director') {
+ return sprintf('%s (%s)', $class, $module);
+ }
+ }
+
+ return $class;
+ }
+
+ public static function getFormat()
+ {
+ return 'string';
+ }
+
+ /**
+ * @param $name
+ * @param QuickForm|DirectorObjectForm $form
+ *
+ * @return \Zend_Form_Element
+ */
+ abstract public function getFormElement($name, QuickForm $form);
+
+ public static function addSettingsFormFields(QuickForm $form)
+ {
+ return $form;
+ }
+
+ public function setSettings($settings)
+ {
+ $this->settings = $settings;
+ return $this;
+ }
+
+ public function getSetting($name, $default = null)
+ {
+ if (array_key_exists($name, $this->settings)) {
+ return $this->settings[$name];
+ } else {
+ return $default;
+ }
+ }
+}
diff --git a/library/Director/Hook/DeploymentHook.php b/library/Director/Hook/DeploymentHook.php
new file mode 100644
index 0000000..c8a834b
--- /dev/null
+++ b/library/Director/Hook/DeploymentHook.php
@@ -0,0 +1,65 @@
+<?php
+
+namespace Icinga\Module\Director\Hook;
+
+use Icinga\Module\Director\Objects\DirectorDeploymentLog;
+
+abstract class DeploymentHook
+{
+ /**
+ * Please override this method if you want to change the behaviour
+ * of the deploy (stop it by throwing an exception for some reason)
+ *
+ * @param DirectorDeploymentLog $deployment
+ */
+ public function beforeDeploy(DirectorDeploymentLog $deployment)
+ {
+ }
+
+ /**
+ * Please override this method if you want to trigger custom actions
+ * on a successful dump of the Icinga configuration
+ *
+ * @param DirectorDeploymentLog $deployment
+ */
+ public function onSuccessfulDump(DirectorDeploymentLog $deployment)
+ {
+ }
+
+ /**
+ * There is a typo in this method name, do not use this.
+ *
+ * @deprecated Please use onSuccessfulDump
+ * @param DirectorDeploymentLog $deployment
+ */
+ public function onSuccessfullDump(DirectorDeploymentLog $deployment)
+ {
+ }
+
+ /**
+ * Compatibility helper
+ *
+ * The initial version of this hook had a typo in the onSuccessfulDump method
+ * That's why we call this hook, which then calls both the correct and the
+ * erroneous method to make sure that we do not break existing implementations.
+ *
+ * @param DirectorDeploymentLog $deploymentLog
+ */
+ final public function triggerSuccessfulDump(DirectorDeploymentLog $deploymentLog)
+ {
+ $this->onSuccessfulDump($deploymentLog);
+ $this->onSuccessfullDump($deploymentLog);
+ }
+
+ /**
+ * Please override this method if you want to trigger custom actions
+ * once success (or failure) information have been collected for a deployed
+ * stage. startup_succeeded will then be filled, and startup_log might be
+ * available
+ *
+ * @param DirectorDeploymentLog $deployment
+ */
+ public function onCollect(DirectorDeploymentLog $deployment)
+ {
+ }
+}
diff --git a/library/Director/Hook/HostFieldHook.php b/library/Director/Hook/HostFieldHook.php
new file mode 100644
index 0000000..c0199d0
--- /dev/null
+++ b/library/Director/Hook/HostFieldHook.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace Icinga\Module\Director\Hook;
+
+use Icinga\Module\Director\Field\FieldSpec;
+use Icinga\Module\Director\Objects\IcingaHost;
+
+abstract class HostFieldHook
+{
+ public function wants(IcingaHost $host)
+ {
+ return true;
+ }
+
+ /**
+ * @return FieldSpec
+ */
+ abstract public function getFieldSpec(IcingaHost $host);
+}
diff --git a/library/Director/Hook/IcingaObjectFormHook.php b/library/Director/Hook/IcingaObjectFormHook.php
new file mode 100644
index 0000000..1d20ee1
--- /dev/null
+++ b/library/Director/Hook/IcingaObjectFormHook.php
@@ -0,0 +1,22 @@
+<?php
+
+namespace Icinga\Module\Director\Hook;
+
+use Icinga\Module\Director\Web\Form\DirectorObjectForm;
+use Icinga\Web\Hook;
+
+abstract class IcingaObjectFormHook
+{
+ protected $settings = [];
+
+ abstract public function onSetup(DirectorObjectForm $form);
+
+ public static function callOnSetup(DirectorObjectForm $form)
+ {
+ /** @var static[] $implementations */
+ $implementations = Hook::all('director/IcingaObjectForm');
+ foreach ($implementations as $implementation) {
+ $implementation->onSetup($form);
+ }
+ }
+}
diff --git a/library/Director/Hook/ImportSourceHook.php b/library/Director/Hook/ImportSourceHook.php
new file mode 100644
index 0000000..dba1c87
--- /dev/null
+++ b/library/Director/Hook/ImportSourceHook.php
@@ -0,0 +1,136 @@
+<?php
+
+namespace Icinga\Module\Director\Hook;
+
+use Icinga\Module\Director\Objects\ImportSource;
+use Icinga\Module\Director\Web\Form\QuickForm;
+use Icinga\Module\Director\Db;
+use Icinga\Exception\ConfigurationError;
+
+abstract class ImportSourceHook
+{
+ protected $settings = [];
+
+ public function getName()
+ {
+ $parts = explode('\\', get_class($this));
+ $class = preg_replace('/ImportSource/', '', array_pop($parts));
+
+ if (array_shift($parts) === 'Icinga' && array_shift($parts) === 'Module') {
+ $module = array_shift($parts);
+ if ($module !== 'Director') {
+ if ($class === '') {
+ return sprintf('%s module', $module);
+ }
+ return sprintf('%s (%s)', $class, $module);
+ }
+ }
+
+ return $class;
+ }
+
+ public static function forImportSource(ImportSource $source)
+ {
+ $db = $source->getDb();
+ $settings = $db->fetchPairs(
+ $db->select()->from(
+ 'import_source_setting',
+ ['setting_name', 'setting_value']
+ )->where('source_id = ?', $source->get('id'))
+ );
+
+ $className = $source->get('provider_class');
+ if (! class_exists($className)) {
+ throw new ConfigurationError(
+ 'Cannot load import provider class %s',
+ $className
+ );
+ }
+
+ /** @var ImportSourceHook $obj */
+ $obj = new $className;
+ $obj->setSettings($settings);
+ return $obj;
+ }
+
+ public static function loadByName($name, Db $db)
+ {
+ $db = $db->getDbAdapter();
+ $source = $db->fetchRow(
+ $db->select()->from(
+ 'import_source',
+ array('id', 'provider_class')
+ )->where('source_name = ?', $name)
+ );
+
+ $settings = $db->fetchPairs(
+ $db->select()->from(
+ 'import_source_setting',
+ array('setting_name', 'setting_value')
+ )->where('source_id = ?', $source->id)
+ );
+
+ if (! class_exists($source->provider_class)) {
+ throw new ConfigurationError(
+ 'Cannot load import provider class %s',
+ $source->provider_class
+ );
+ }
+ /** @var ImportSourceHook $obj */
+ $obj = new $source->provider_class;
+ $obj->setSettings($settings);
+
+ return $obj;
+ }
+
+ public function setSettings($settings)
+ {
+ $this->settings = $settings;
+ return $this;
+ }
+
+ public function getSetting($name, $default = null)
+ {
+ if (array_key_exists($name, $this->settings)) {
+ return $this->settings[$name];
+ } else {
+ return $default;
+ }
+ }
+
+ /**
+ * Returns an array containing importable objects
+ *
+ * @return array
+ */
+ abstract public function fetchData();
+
+ /**
+ * Returns a list of all available columns
+ *
+ * @return array
+ */
+ abstract public function listColumns();
+
+ /**
+ * Override this method in case you want to suggest a default
+ * key column
+ *
+ * @return string|null Default key column
+ */
+ public static function getDefaultKeyColumnName()
+ {
+ return null;
+ }
+
+ /**
+ * Override this method if you want to extend the settings form
+ *
+ * @param QuickForm $form QuickForm that should be extended
+ * @return QuickForm
+ */
+ public static function addSettingsFormFields(QuickForm $form)
+ {
+ return $form;
+ }
+}
diff --git a/library/Director/Hook/JobHook.php b/library/Director/Hook/JobHook.php
new file mode 100644
index 0000000..d9a81a9
--- /dev/null
+++ b/library/Director/Hook/JobHook.php
@@ -0,0 +1,86 @@
+<?php
+
+namespace Icinga\Module\Director\Hook;
+
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Objects\DirectorJob;
+use Icinga\Module\Director\Web\Form\QuickForm;
+
+abstract class JobHook
+{
+ /** @var Db */
+ private $db;
+
+ /** @var DirectorJob */
+ private $jobDefinition;
+
+ public static function getDescription(QuickForm $form)
+ {
+ return false;
+ }
+
+ abstract public function run();
+
+ public function isPending()
+ {
+ // TODO: Can be overridden, double-check whether this is necessary
+ }
+
+ public function setDefinition(DirectorJob $definition)
+ {
+ $this->jobDefinition = $definition;
+ return $this;
+ }
+
+ protected function getSetting($key, $default = null)
+ {
+ return $this->jobDefinition->getSetting($key, $default);
+ }
+
+ public function getName()
+ {
+ $parts = explode('\\', get_class($this));
+ $class = preg_replace('/Job$/', '', array_pop($parts));
+
+ if (array_shift($parts) === 'Icinga' && array_shift($parts) === 'Module') {
+ $module = array_shift($parts);
+ if ($module !== 'Director') {
+ return sprintf('%s (%s)', $class, $module);
+ }
+ }
+
+ return $class;
+ }
+
+ public function exportSettings()
+ {
+ return $this->jobDefinition->getSettings();
+ }
+
+ public static function getSuggestedRunInterval(QuickForm $form)
+ {
+ return 900;
+ }
+
+ /**
+ * Override this method if you want to extend the settings form
+ *
+ * @param QuickForm $form QuickForm that should be extended
+ * @return QuickForm
+ */
+ public static function addSettingsFormFields(QuickForm $form)
+ {
+ return $form;
+ }
+
+ public function setDb(Db $db)
+ {
+ $this->db = $db;
+ return $this;
+ }
+
+ protected function db()
+ {
+ return $this->db;
+ }
+}
diff --git a/library/Director/Hook/PropertyModifierHook.php b/library/Director/Hook/PropertyModifierHook.php
new file mode 100644
index 0000000..5d8736d
--- /dev/null
+++ b/library/Director/Hook/PropertyModifierHook.php
@@ -0,0 +1,258 @@
+<?php
+
+namespace Icinga\Module\Director\Hook;
+
+use Icinga\Module\Director\Web\Form\QuickForm;
+use Icinga\Module\Director\Db;
+
+abstract class PropertyModifierHook
+{
+ /** @var array */
+ protected $settings = [];
+
+ /** @var string */
+ private $targetProperty;
+
+ /** @var string */
+ private $propertyName;
+
+ /** @var Db */
+ private $db;
+
+ /** @var bool */
+ private $rejected = false;
+
+ /** @var \stdClass */
+ private $row;
+
+ /**
+ * Methode to transform the given value
+ *
+ * Your custom property modifier needs to implement this method.
+ *
+ * @return mixed $value
+ */
+ abstract public function transform($value);
+
+ public function getName()
+ {
+ $parts = explode('\\', get_class($this));
+ $class = preg_replace('/^PropertyModifier/', '', array_pop($parts)); // right?
+
+ if (array_shift($parts) === 'Icinga' && array_shift($parts) === 'Module') {
+ $module = array_shift($parts);
+ if ($module !== 'Director') {
+ return sprintf('%s (%s)', $class, $module);
+ }
+ }
+
+ return $class;
+ }
+
+ /**
+ * Whether this PropertyModifier wants to deal with array on it's own
+ *
+ * When true, the whole array value will be passed to transform(), otherwise
+ * transform() will be called for every single array member
+ *
+ * @return bool
+ */
+ public function hasArraySupport()
+ {
+ return false;
+ }
+
+ /**
+ * This creates one cloned row for every entry of the result array
+ *
+ * When set to true and given that the property modifier returns an Array,
+ * the current row will be cloned for every entry of that array. The modified
+ * property will then be replace each time accordingly. An empty Array
+ * completely removes the corrent row.
+ *
+ * @return bool
+ */
+ public function expandsRows()
+ {
+ return false;
+ }
+
+ /**
+ * Reject this whole row
+ *
+ * Allows your property modifier to reject specific rows
+ *
+ * @param bool $reject
+ * @return $this
+ */
+ public function rejectRow($reject = true)
+ {
+ $this->rejected = (bool) $reject;
+
+ return $this;
+ }
+
+ /**
+ * Whether this PropertyModifier wants access to the current row
+ *
+ * When true, the your modifier can access the current row via $this->getRow()
+ *
+ * @return bool
+ */
+ public function requiresRow()
+ {
+ return false;
+ }
+
+ /**
+ * Whether this modifier wants to reject the current row
+ *
+ * @return bool
+ */
+ public function rejectsRow()
+ {
+ return $this->rejected;
+ }
+
+ /**
+ * Get the current row
+ *
+ * Will be null when requiresRow was not null. Please do not modify the
+ * row. It might work right now, as we pass in an object reference for
+ * performance reasons. However, modifying row properties is not supported,
+ * and the outcome of such operation might change without pre-announcement
+ * in any future version.
+ *
+ * @return \stdClass|null
+ */
+ public function getRow()
+ {
+ return $this->row;
+ }
+
+ /**
+ * Sets the current row
+ *
+ * Please see requiresRow/getRow for related details. This method is called
+ * by the Import implementation, you should never need to call this on your
+ * own - apart from writing tests of course.
+ *
+ * @param \stdClass $row
+ * @return $this
+ */
+ public function setRow($row)
+ {
+ $this->row = $row;
+ return $this;
+ }
+
+ /**
+ * @return string
+ */
+ public function getPropertyName()
+ {
+ return $this->propertyName;
+ }
+
+ /**
+ * @param string $propertyName
+ * @return $this
+ */
+ public function setPropertyName($propertyName)
+ {
+ $this->propertyName = $propertyName;
+ return $this;
+ }
+
+ /**
+ * The desired target property. Modifiers might want to have their outcome
+ * written to another property of the current row.
+ *
+ * @param $property
+ * @return $this
+ */
+ public function setTargetProperty($property)
+ {
+ $this->targetProperty = $property;
+ return $this;
+ }
+
+ /**
+ * Whether the result of transform() should be written to a new property
+ *
+ * The Import implementation deals with this
+ *
+ * @return bool
+ */
+ public function hasTargetProperty()
+ {
+ return $this->targetProperty !== null;
+ }
+
+ /**
+ * Get the configured target property
+ *
+ * @return string
+ */
+ public function getTargetProperty($default = null)
+ {
+ if ($this->targetProperty === null) {
+ return $default;
+ }
+
+ return $this->targetProperty;
+ }
+
+ public function setDb(Db $db)
+ {
+ $this->db = $db;
+ return $this;
+ }
+
+ public function getDb()
+ {
+ return $this->db;
+ }
+
+ public function setSettings(array $settings)
+ {
+ $this->settings = $settings;
+ return $this;
+ }
+
+ public function getSetting($name, $default = null)
+ {
+ if (array_key_exists($name, $this->settings)) {
+ return $this->settings[$name];
+ } else {
+ return $default;
+ }
+ }
+
+ public function setSetting($name, $value)
+ {
+ $this->settings[$name] = $value;
+ return $this;
+ }
+
+ public function exportSettings()
+ {
+ return (object) $this->settings;
+ }
+
+ public function getSettings()
+ {
+ return $this->settings;
+ }
+
+ /**
+ * Override this method if you want to extend the settings form
+ *
+ * @param QuickForm $form QuickForm that should be extended
+ * @return QuickForm
+ */
+ public static function addSettingsFormFields(QuickForm $form)
+ {
+ return $form;
+ }
+}
diff --git a/library/Director/Hook/ServiceFieldHook.php b/library/Director/Hook/ServiceFieldHook.php
new file mode 100644
index 0000000..bca0836
--- /dev/null
+++ b/library/Director/Hook/ServiceFieldHook.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace Icinga\Module\Director\Hook;
+
+use Icinga\Module\Director\Field\FieldSpec;
+use Icinga\Module\Director\Objects\IcingaService;
+
+abstract class ServiceFieldHook
+{
+ public function wants(IcingaService $service)
+ {
+ return true;
+ }
+
+ /**
+ * @return FieldSpec
+ */
+ abstract public function getFieldSpec(IcingaService $service);
+}
diff --git a/library/Director/Hook/ShipConfigFilesHook.php b/library/Director/Hook/ShipConfigFilesHook.php
new file mode 100644
index 0000000..0026c59
--- /dev/null
+++ b/library/Director/Hook/ShipConfigFilesHook.php
@@ -0,0 +1,11 @@
+<?php
+
+namespace Icinga\Module\Director\Hook;
+
+abstract class ShipConfigFilesHook
+{
+ public function fetchFiles()
+ {
+ return array();
+ }
+}
diff --git a/library/Director/IcingaConfig/AgentWizard.php b/library/Director/IcingaConfig/AgentWizard.php
new file mode 100644
index 0000000..aceddb1
--- /dev/null
+++ b/library/Director/IcingaConfig/AgentWizard.php
@@ -0,0 +1,337 @@
+<?php
+
+namespace Icinga\Module\Director\IcingaConfig;
+
+use Icinga\Application\Icinga;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Module\Director\Objects\IcingaEndpoint;
+use Icinga\Module\Director\Objects\IcingaHost;
+use Icinga\Module\Director\Objects\IcingaZone;
+use Icinga\Module\Director\Util;
+use LogicException;
+
+class AgentWizard
+{
+ protected $db;
+
+ protected $host;
+
+ protected $parentZone;
+
+ protected $parentEndpoints;
+
+ /** @var string PKI ticket */
+ protected $ticket;
+
+ public function __construct(IcingaHost $host)
+ {
+ $this->host = $host;
+ }
+
+ protected function assertAgent()
+ {
+ if ($this->host->getResolvedProperty('has_agent') !== 'y') {
+ throw new ProgrammingError(
+ 'The given host "%s" is not an Agent',
+ $this->host->getObjectName()
+ );
+ }
+ }
+
+ protected function getCaServer()
+ {
+ return $this->db()->getDeploymentEndpointName();
+
+ // TODO: This is a problem with Icinga 2. Should look like this:
+ // return current($this->getParentEndpoints())->object_name;
+ }
+
+ protected function shouldConnectToMaster()
+ {
+ return $this->host->getResolvedProperty('master_should_connect') !== 'y';
+ }
+
+ protected function getParentZone()
+ {
+ if ($this->parentZone === null) {
+ $this->parentZone = $this->loadParentZone();
+ }
+
+ return $this->parentZone;
+ }
+
+ protected function loadParentZone()
+ {
+ $db = $this->db();
+
+ if ($zoneId = $this->host->getResolvedProperty('zone_id')) {
+ return IcingaZone::loadWithAutoIncId($zoneId, $db);
+ } else {
+ return IcingaZone::load($db->getMasterZoneName(), $db);
+ }
+ }
+
+ protected function getParentEndpoints()
+ {
+ if ($this->parentEndpoints === null) {
+ $this->parentEndpoints = $this->loadParentEndpoints();
+ }
+
+ return $this->parentEndpoints;
+ }
+
+ protected function loadParentEndpoints()
+ {
+ $db = $this->db()->getDbAdapter();
+
+ $query = $db->select()
+ ->from('icinga_endpoint')
+ ->where(
+ 'zone_id = ?',
+ $this->getParentZone()->get('id')
+ );
+
+ return IcingaEndpoint::loadAll(
+ $this->db(),
+ $query,
+ 'object_name'
+ );
+ }
+
+ /**
+ * Get the PKI ticket
+ *
+ * @return string
+ *
+ * @throws LogicException If ticket has not been set
+ */
+ protected function getTicket()
+ {
+ if ($this->ticket === null) {
+ throw new LogicException('Ticket is null');
+ }
+
+ return $this->ticket;
+ }
+
+ /**
+ * Set the PKI ticket
+ *
+ * @param string $ticket
+ *
+ * @return $this
+ */
+ public function setTicket($ticket)
+ {
+ $this->ticket = $ticket;
+ }
+
+ protected function loadPowershellModule()
+ {
+ return $this->getContribFile('windows-agent-installer/Icinga2Agent.psm1');
+ }
+
+ public function renderWindowsInstaller()
+ {
+ return $this->loadPowershellModule()
+ . "\n\n"
+ . 'exit Icinga2AgentModule `' . "\n "
+ . $this->renderPowershellParameters([
+ 'AgentName' => $this->host->getEndpointName(),
+ 'Ticket' => $this->getTicket(),
+ 'ParentZone' => $this->getParentZone()->getObjectName(),
+ 'ParentEndpoints' => array_keys($this->getParentEndpoints()),
+ 'CAServer' => $this->getCaServer(),
+ 'RunInstaller'
+ ]);
+ }
+
+ public function renderIcinga4WindowsWizardCommand($token)
+ {
+ $script = "Use-Icinga;\n"
+ . 'Start-IcingaAgentInstallWizard `' . "\n "
+ . $this->renderPowershellParameters([
+ 'DirectorUrl' => $this->getDirectorUrl(),
+ 'SelfServiceAPIKey' => $token,
+ 'UseDirectorSelfService' => 1,
+ 'OverrideDirectorVars' => 0,
+ 'Reconfigure',
+ 'RunInstaller'
+ ]);
+
+ return $script;
+ }
+
+ public function renderPowershellModuleInstaller($token, $withModule = false)
+ {
+ if ($withModule) {
+ $script = $this->loadPowershellModule() . "\n\n";
+ } else {
+ $script = '';
+ }
+
+ $script .= 'exit Icinga2AgentModule `' . "\n "
+ . $this->renderPowershellParameters([
+ 'DirectorUrl' => $this->getDirectorUrl(),
+ 'DirectorAuthToken' => $token,
+ 'RunInstaller'
+ ]);
+
+ return $script;
+ }
+
+ protected function getDirectorUrl()
+ {
+ $r = Icinga::app()->getRequest();
+ $scheme = $r->getServer('HTTP_X_FORWARDED_PROTO', $r->getScheme());
+
+ return sprintf(
+ '%s://%s%s/director/',
+ $scheme,
+ $r->getHttpHost(),
+ $r->getBaseUrl()
+ );
+ }
+
+ protected function renderPowershellParameters($parameters)
+ {
+ $maxKeyLength = max(array_map('strlen', array_keys($parameters)));
+ foreach ($parameters as $key => $value) {
+ if (is_int($key)) {
+ $maxKeyLength = max($maxKeyLength, strlen($value));
+ }
+ }
+ $parts = array();
+
+ foreach ($parameters as $key => $value) {
+ if (is_int($key)) {
+ $parts[] = $this->renderPowershellParameter($value, null, $maxKeyLength);
+ } else {
+ $parts[] = $this->renderPowershellParameter($key, $value, $maxKeyLength);
+ }
+ }
+
+ return implode(' `' . "\n ", $parts);
+ }
+
+ protected function renderPowershellParameter($key, $value, $maxKeyLength = null)
+ {
+ $ret = '-' . $key;
+ if ($value === null) {
+ return $ret;
+ }
+
+ $ret .= ' ';
+
+ if ($maxKeyLength !== null) {
+ $ret .= str_repeat(' ', $maxKeyLength - strlen($key));
+ }
+
+ if (is_array($value)) {
+ $vals = array();
+ foreach ($value as $val) {
+ $vals[] = $this->renderPowershellString($val);
+ }
+ $ret .= implode(', ', $vals);
+ } elseif (is_int($value)) {
+ $ret .= $value;
+ } else {
+ $ret .= $this->renderPowershellString($value);
+ }
+
+ return $ret;
+ }
+
+ protected function renderPowershellString($string)
+ {
+ // TODO: Escaping
+ return "'" . $string . "'";
+ }
+
+ protected function db()
+ {
+ if ($this->db === null) {
+ $this->db = $this->host->getConnection();
+ }
+
+ return $this->db;
+ }
+
+ public function renderLinuxInstaller()
+ {
+ $script = $this->loadBashModule();
+
+ $endpoints = [];
+ foreach ($this->getParentEndpoints() as $endpoint) {
+ $endpoints[$endpoint->getObjectName()] = $endpoint->get('host');
+ }
+
+ return $this->replaceBashTemplate($script, [
+ 'ICINGA2_NODENAME' => $this->host->getEndpointName(),
+ 'ICINGA2_CA_TICKET' => $this->getTicket(),
+ 'ICINGA2_PARENT_ZONE' => $this->getParentZone()->getObjectName(),
+ 'ICINGA2_PARENT_ENDPOINTS' => $endpoints,
+ 'ICINGA2_CA_NODE' => $this->getCaServer(),
+ 'ICINGA2_GLOBAL_ZONES' => [$this->db()->getDefaultGlobalZoneName()],
+ ]);
+ }
+
+ protected function loadBashModule()
+ {
+ return $this->getContribFile('linux-agent-installer/Icinga2Agent.bash');
+ }
+
+ protected function replaceBashTemplate($script, $parameters)
+ {
+ foreach ($parameters as $key => $value) {
+ $quotedKey = preg_quote($key, '~');
+ if (is_array($value)) {
+ $list = [];
+ foreach ($value as $k => $v) {
+ if (!is_numeric($k)) {
+ $v = "$k,$v";
+ }
+ $list[] = escapeshellarg($v);
+ }
+ $value = '(' . join(' ', $list) . ')';
+ } else {
+ $value = escapeshellarg($value);
+ }
+ $script = preg_replace("~^#?$quotedKey='@$quotedKey@'$~m", "${key}=${value}", $script);
+ }
+
+ return $script;
+ }
+
+ protected function renderBashParameter($key, $value)
+ {
+ $ret = $key . '=';
+
+ // Cheating, this doesn't really help. We should ship the rendered config
+ if (is_array($value) && count($value) === 1) {
+ $value = array_shift($value);
+ }
+
+ if (is_array($value)) {
+ $vals = array();
+ foreach ($value as $val) {
+ $vals[] = $this->renderPowershellString($val);
+ }
+ $ret .= '(' . implode(' ', $vals) . ')';
+ } else {
+ $ret .= $this->renderPowershellString($value);
+ }
+
+ return $ret;
+ }
+
+ protected function getContribDir()
+ {
+ return dirname(dirname(dirname(__DIR__))) . '/contrib';
+ }
+
+ protected function getContribFile($path)
+ {
+ return file_get_contents($this->getContribDir() . '/' . $path);
+ }
+}
diff --git a/library/Director/IcingaConfig/AssignRenderer.php b/library/Director/IcingaConfig/AssignRenderer.php
new file mode 100644
index 0000000..6acbfee
--- /dev/null
+++ b/library/Director/IcingaConfig/AssignRenderer.php
@@ -0,0 +1,268 @@
+<?php
+
+namespace Icinga\Module\Director\IcingaConfig;
+
+use gipfl\Json\JsonString;
+use Icinga\Data\Filter\Filter;
+use Icinga\Data\Filter\FilterAnd;
+use Icinga\Data\Filter\FilterChain;
+use Icinga\Data\Filter\FilterExpression;
+use Icinga\Data\Filter\FilterOr;
+use Icinga\Data\Filter\FilterNot;
+use Icinga\Data\Filter\FilterEqualOrGreaterThan;
+use Icinga\Data\Filter\FilterEqualOrLessThan;
+use Icinga\Data\Filter\FilterEqual;
+use Icinga\Data\Filter\FilterGreaterThan;
+use Icinga\Data\Filter\FilterLessThan;
+use Icinga\Data\Filter\FilterMatch;
+use Icinga\Data\Filter\FilterMatchNot;
+use Icinga\Data\Filter\FilterNotEqual;
+use Icinga\Exception\QueryException;
+use Icinga\Module\Director\Data\Json;
+use InvalidArgumentException;
+
+class AssignRenderer
+{
+ protected $filter;
+
+ public function __construct(Filter $filter)
+ {
+ $this->filter = $filter;
+ }
+
+ public static function forFilter(Filter $filter)
+ {
+ return new static($filter);
+ }
+
+ public function renderAssign()
+ {
+ return $this->render('assign');
+ }
+
+ public function renderIgnore()
+ {
+ return $this->render('ignore');
+ }
+
+ public function render($type)
+ {
+ return $type . ' where ' . $this->renderFilter($this->filter);
+ }
+
+ protected function renderFilter(Filter $filter)
+ {
+ if ($filter instanceof FilterNot) {
+ $parts = [];
+ foreach ($filter->filters() as $sub) {
+ $parts[] = $this->renderFilter($sub);
+ }
+
+ return '!(' . implode(' && ', $parts) . ')';
+ }
+ if ($filter->isChain()) {
+ /** @var FilterChain $filter */
+ return $this->renderFilterChain($filter);
+ } else {
+ /** @var FilterExpression $filter */
+ return $this->renderFilterExpression($filter);
+ }
+ }
+
+ protected function renderEquals($column, $expression)
+ {
+ if (substr($column, -7) === '.groups') {
+ return sprintf(
+ '%s in %s',
+ $expression,
+ $column
+ );
+ } else {
+ return sprintf(
+ '%s == %s',
+ $column,
+ $expression
+ );
+ }
+ }
+
+ protected function renderNotEquals($column, $expression)
+ {
+ if (substr($column, -7) === '.groups') {
+ return sprintf(
+ '!(%s in %s)',
+ $expression,
+ $column
+ );
+ } else {
+ return sprintf(
+ '%s != %s',
+ $column,
+ $expression
+ );
+ }
+ }
+
+ protected function renderInArray($column, $expression)
+ {
+ return sprintf(
+ '%s in %s',
+ $column,
+ $expression
+ );
+ }
+
+ protected function renderContains(FilterExpression $filter)
+ {
+ return sprintf(
+ '%s in %s',
+ $this->renderExpressionValue(json_decode($filter->getColumn())),
+ $filter->getExpression()
+ );
+ }
+
+ protected function renderFilterExpression(FilterExpression $filter)
+ {
+ if ($this->columnIsJson($filter)) {
+ return $this->renderContains($filter);
+ }
+
+ $column = $filter->getColumn();
+ $rawExpression = Json::decode($filter->getExpression());
+ $expression = $this->renderExpressionValue($rawExpression);
+
+ if (is_array($rawExpression) && $filter instanceof FilterMatch) {
+ return $this->renderInArray($column, $expression);
+ }
+
+ if (is_string($rawExpression) && ctype_digit($rawExpression)) {
+ // TODO: doing this for compat reasons, should work for all filters
+ if ($filter instanceof FilterEqualOrGreaterThan
+ || $filter instanceof FilterGreaterThan
+ || $filter instanceof FilterEqualOrLessThan
+ || $filter instanceof FilterLessThan
+ ) {
+ $expression = $rawExpression;
+ }
+ }
+
+ if ($filter instanceof FilterEqual) {
+ if (is_array($rawExpression)) {
+ return sprintf(
+ '%s in %s',
+ $column,
+ $expression
+ );
+ } else {
+ return sprintf(
+ '%s == %s',
+ $column,
+ $expression
+ );
+ }
+ } elseif ($filter instanceof FilterMatch) {
+ if ($rawExpression === true) {
+ return $column;
+ }
+ if ($rawExpression === false) {
+ return sprintf(
+ '! %s',
+ $column
+ );
+ }
+ if (strpos($expression, '*') === false) {
+ return $this->renderEquals($column, $expression);
+ } else {
+ return sprintf(
+ 'match(%s, %s)',
+ $expression,
+ $column
+ );
+ }
+ } elseif ($filter instanceof FilterMatchNot) {
+ if (strpos($expression, '*') === false) {
+ return $this->renderNotEquals($column, $expression);
+ } else {
+ return sprintf(
+ '! match(%s, %s)',
+ $expression,
+ $column
+ );
+ }
+ } elseif ($filter instanceof FilterNotEqual) {
+ return sprintf(
+ '%s != %s',
+ $column,
+ $expression
+ );
+ } elseif ($filter instanceof FilterEqualOrGreaterThan) {
+ return sprintf(
+ '%s >= %s',
+ $column,
+ $expression
+ );
+ } elseif ($filter instanceof FilterEqualOrLessThan) {
+ return sprintf(
+ '%s <= %s',
+ $column,
+ $expression
+ );
+ } elseif ($filter instanceof FilterGreaterThan) {
+ return sprintf(
+ '%s > %s',
+ $column,
+ $expression
+ );
+ } elseif ($filter instanceof FilterLessThan) {
+ return sprintf(
+ '%s < %s',
+ $column,
+ $expression
+ );
+ } else {
+ throw new QueryException(
+ 'Filter expression of type "%s" is not supported',
+ get_class($filter)
+ );
+ }
+ }
+
+ protected function renderExpressionValue($value)
+ {
+ return IcingaConfigHelper::renderPhpValue($value);
+ }
+
+ protected function columnIsJson(FilterExpression $filter)
+ {
+ $col = $filter->getColumn();
+ return strlen($col) && $col[0] === '"';
+ }
+
+ protected function renderFilterChain(FilterChain $filter)
+ {
+ // TODO: brackets if deeper level?
+ if ($filter instanceof FilterAnd) {
+ $op = ' && ';
+ } elseif ($filter instanceof FilterOr) {
+ $op = ' || ';
+ } elseif ($filter instanceof FilterNot) {
+ throw new InvalidArgumentException('renderFilterChain should never get a FilterNot instance');
+ } else {
+ throw new InvalidArgumentException('Cannot render filter: %s', $filter);
+ }
+
+ $parts = array();
+ if (! $filter->isEmpty()) {
+ /** @var Filter $f */
+ foreach ($filter->filters() as $f) {
+ if ($f instanceof FilterChain && $f->count() > 1) {
+ $parts[] = '(' . $this->renderFilter($f) . ')';
+ } else {
+ $parts[] = $this->renderFilter($f);
+ }
+ }
+ }
+
+ return implode($op, $parts);
+ }
+}
diff --git a/library/Director/IcingaConfig/ExtensibleSet.php b/library/Director/IcingaConfig/ExtensibleSet.php
new file mode 100644
index 0000000..9120816
--- /dev/null
+++ b/library/Director/IcingaConfig/ExtensibleSet.php
@@ -0,0 +1,574 @@
+<?php
+
+namespace Icinga\Module\Director\IcingaConfig;
+
+use Icinga\Exception\InvalidPropertyException;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c;
+use Icinga\Module\Director\Objects\IcingaObject;
+
+class ExtensibleSet
+{
+ protected $ownValues;
+
+ protected $plusValues = [];
+
+ protected $minusValues = [];
+
+ protected $resolvedValues;
+
+ protected $allowedValues;
+
+ protected $inheritedValues = [];
+
+ protected $fromDb;
+
+ /**
+ * @var IcingaObject
+ */
+ protected $object;
+
+ /**
+ * Object property name pointing to this set
+ *
+ * This also implies set table called <object_table>_<propertyName>_set
+ *
+ * @var string
+ */
+ protected $propertyName;
+
+ public function __construct($values = null)
+ {
+ if (null !== $values) {
+ $this->override($values);
+ }
+ }
+
+ public static function forIcingaObject(IcingaObject $object, $propertyName)
+ {
+ $set = new static;
+ $set->object = $object;
+ $set->propertyName = $propertyName;
+
+ if ($object->hasBeenLoadedFromDb()) {
+ $set->loadFromDb();
+ }
+
+ return $set;
+ }
+
+ public function set($set)
+ {
+ if (null === $set) {
+ $this->reset();
+
+ return $this;
+ } elseif (is_array($set) || is_string($set)) {
+ $this->reset();
+ $this->override($set);
+ } elseif (is_object($set)) {
+ $this->reset();
+
+ foreach (['override', 'extend', 'blacklist'] as $method) {
+ if (property_exists($set, $method)) {
+ $this->$method($set->$method);
+ }
+ }
+ } else {
+ throw new ProgrammingError(
+ 'ExtensibleSet::set accepts only plain arrays or objects'
+ );
+ }
+
+ return $this;
+ }
+
+ public function isEmpty()
+ {
+ return $this->ownValues === null
+ && empty($this->plusValues)
+ && empty($this->minusValues);
+ }
+
+ public function toPlainObject()
+ {
+ if ($this->ownValues !== null) {
+ if (empty($this->minusValues) && empty($this->plusValues)) {
+ return $this->ownValues;
+ }
+ }
+
+ $plain = (object) [];
+
+ if ($this->ownValues !== null) {
+ $plain->override = $this->ownValues;
+ }
+ if (! empty($this->plusValues)) {
+ $plain->extend = $this->plusValues;
+ }
+ if (! empty($this->minusValues)) {
+ $plain->blacklist = $this->minusValues;
+ }
+
+ return $plain;
+ }
+
+ public function getPlainUnmodifiedObject()
+ {
+ if ($this->fromDb === null) {
+ return null;
+ }
+
+ $old = $this->fromDb;
+
+ if ($old['override'] !== null) {
+ if (empty($old['blacklist']) && empty($old['extend'])) {
+ return $old['override'];
+ }
+ }
+
+ $plain = (object) [];
+
+ if ($old['override'] !== null) {
+ $plain->override = $old['override'];
+ }
+ if (! empty($old['extend'])) {
+ $plain->extend = $old['extend'];
+ }
+ if (! empty($old['blacklist'])) {
+ $plain->blacklist = $old['blacklist'];
+ }
+
+ return $plain;
+ }
+
+ public function hasBeenLoadedFromDb()
+ {
+ return $this->fromDb !== null;
+ }
+
+ public function hasBeenModified()
+ {
+ if ($this->hasBeenLoadedFromDb()) {
+ if ($this->ownValues !== $this->fromDb['override']) {
+ return true;
+ }
+
+ if ($this->plusValues !== $this->fromDb['extend']) {
+ return true;
+ }
+
+ if ($this->minusValues !== $this->fromDb['blacklist']) {
+ return true;
+ }
+
+ return false;
+ } else {
+ if ($this->ownValues === null
+ && empty($this->plusValues)
+ && empty($this->minusValues)
+ ) {
+ return false;
+ } else {
+ return true;
+ }
+ }
+ }
+
+ protected function loadFromDb()
+ {
+ $db = $this->object->getDb();
+
+ $query = $db->select()->from($this->tableName(), [
+ 'property',
+ 'merge_behaviour'
+ ])->where($this->foreignKey() . ' = ?', $this->object->get('id'));
+
+ $byBehaviour = [
+ 'override' => [],
+ 'extend' => [],
+ 'blacklist' => [],
+ ];
+
+ foreach ($db->fetchAll($query) as $row) {
+ if (! array_key_exists($row->merge_behaviour, $byBehaviour)) {
+ throw new ProgrammingError(
+ 'Got unknown merge_behaviour "%s". Schema change?',
+ $row->merge_behaviour
+ );
+ }
+
+ $byBehaviour[$row->merge_behaviour][] = $row->property;
+ }
+
+ foreach ($byBehaviour as $method => &$values) {
+ if (empty($values)) {
+ continue;
+ }
+
+ sort($values);
+ $this->$method($values);
+ }
+
+ if (empty($byBehaviour['override'])) {
+ $byBehaviour['override'] = null;
+ }
+
+ $this->fromDb = $byBehaviour;
+
+ return $this;
+ }
+
+ protected function foreignKey()
+ {
+ return $this->object->getShortTableName() . '_id';
+ }
+
+ protected function tableName()
+ {
+ return implode('_', [
+ $this->object->getTableName(),
+ $this->propertyName,
+ 'set'
+ ]);
+ }
+
+ public function getObject()
+ {
+ return $this->object;
+ }
+
+ public function store()
+ {
+ if (null === $this->object) {
+ throw new ProgrammingError(
+ 'Cannot store ExtensibleSet with no assigned object'
+ );
+ }
+
+ if (! $this->hasBeenModified()) {
+ return false;
+ }
+
+ $this->storeToDb();
+ return true;
+ }
+
+ protected function storeToDb()
+ {
+ $db = $this->object->getDb();
+
+ if ($db === null) {
+ throw new ProgrammingError(
+ 'Cannot store a set for an unstored related object'
+ );
+ }
+
+ $table = $this->tableName();
+ $props = [
+ $this->foreignKey() => $this->object->get('id')
+ ];
+
+ $db->delete(
+ $this->tableName(),
+ $db->quoteInto(
+ $this->foreignKey() . ' = ?',
+ $this->object->get('id')
+ )
+ );
+
+ if ($this->ownValues !== null) {
+ $props['merge_behaviour'] = 'override';
+ foreach ($this->ownValues as $value) {
+ $db->insert(
+ $table,
+ array_merge($props, ['property' => $value])
+ );
+ }
+ }
+
+ if (! empty($this->plusValues)) {
+ $props['merge_behaviour'] = 'extend';
+ foreach ($this->plusValues as $value) {
+ $db->insert(
+ $table,
+ array_merge($props, ['property' => $value])
+ );
+ }
+ }
+
+ if (! empty($this->minusValues)) {
+ $props['merge_behaviour'] = 'blacklist';
+ foreach ($this->minusValues as $value) {
+ $db->insert(
+ $table,
+ array_merge($props, ['property' => $value])
+ );
+ }
+ }
+
+ $this->setBeingLoadedFromDb();
+ }
+
+ public function setBeingLoadedFromDb()
+ {
+ $this->fromDb = [
+ 'override' => $this->ownValues ?: [],
+ 'extend' => $this->plusValues ?: [],
+ 'blacklist' => $this->minusValues ?: [],
+ ];
+ }
+
+ public function override($values)
+ {
+ $this->ownValues = [];
+ $this->inheritedValues = [];
+
+ $this->addValuesTo($this->ownValues, $values);
+
+ return $this->addResolvedValues($values);
+ }
+
+ public function extend($values)
+ {
+ $this->addValuesTo($this->plusValues, $values);
+ return $this->addResolvedValues($values);
+ }
+
+ public function blacklist($values)
+ {
+ $this->addValuesTo($this->minusValues, $values);
+
+ if ($this->hasBeenResolved()) {
+ $this->removeValuesFrom($this->resolvedValues, $values);
+ }
+
+ return $this;
+ }
+
+ public function getResolvedValues()
+ {
+ if (! $this->hasBeenResolved()) {
+ $this->recalculate();
+ }
+
+ sort($this->resolvedValues);
+
+ return $this->resolvedValues;
+ }
+
+ public function inheritFrom(ExtensibleSet $parent)
+ {
+ if ($this->ownValues !== null) {
+ return $this;
+ }
+
+ if ($this->hasBeenResolved()) {
+ $this->resolvedValues = null;
+ }
+
+ $this->inheritedValues = [];
+
+ $this->addValuesTo(
+ $this->inheritedValues,
+ $this->stripBlacklistedValues($parent->getResolvedValues())
+ );
+
+ return $this->recalculate();
+ }
+
+ public function forgetInheritedValues()
+ {
+ $this->inheritedValues = [];
+ return $this;
+ }
+
+ protected function renderArray($array)
+ {
+ $safe = [];
+ foreach ($array as $value) {
+ $safe[] = c::alreadyRendered($value);
+ }
+
+ return c::renderArray($safe);
+ }
+
+ public function renderAs($key, $prefix = ' ')
+ {
+ $parts = [];
+
+ // TODO: It would be nice if we could use empty arrays to override
+ // inherited ones
+ // if ($this->ownValues !== null) {
+ if (!empty($this->ownValues)) {
+ $parts[] = c::renderKeyValue(
+ $key,
+ $this->renderArray($this->ownValues),
+ $prefix
+ );
+ }
+
+ if (!empty($this->plusValues)) {
+ $parts[] = c::renderKeyOperatorValue(
+ $key,
+ '+=',
+ $this->renderArray($this->plusValues),
+ $prefix
+ );
+ }
+
+ if (!empty($this->minusValues)) {
+ $parts[] = c::renderKeyOperatorValue(
+ $key,
+ '-=',
+ $this->renderArray($this->minusValues),
+ $prefix
+ );
+ }
+
+ return implode('', $parts);
+ }
+
+ public function isRestricted()
+ {
+ return $this->allowedValues === null;
+ }
+
+ public function enumAllowedValues()
+ {
+ if ($this->isRestricted()) {
+ throw new ProgrammingError(
+ 'No allowed value set available, this set is not restricted'
+ );
+ }
+
+ if (empty($this->allowedValues)) {
+ return [];
+ }
+
+ return array_combine($this->allowedValues, $this->allowedValues);
+ }
+
+ protected function hasBeenResolved()
+ {
+ return $this->resolvedValues !== null;
+ }
+
+ protected function stripBlacklistedValues($array)
+ {
+ $this->removeValuesFrom($array, $this->minusValues);
+
+ return $array;
+ }
+
+ protected function assertValidValue($value)
+ {
+ if (null === $this->allowedValues) {
+ return $this;
+ }
+
+ if (in_array($value, $this->allowedValues)) {
+ return $this;
+ }
+
+ throw new InvalidPropertyException(
+ 'Got invalid property "%s", allowed are: (%s)',
+ $value,
+ implode(', ', $this->allowedValues)
+ );
+ }
+
+ protected function addValuesTo(&$array, $values)
+ {
+ foreach ($this->wantArray($values) as $value) {
+ // silently ignore null or empty strings
+ if (strlen($value) === 0) {
+ continue;
+ }
+
+ $this->addTo($array, $value);
+ }
+
+ return $this;
+ }
+
+ protected function addResolvedValues($values)
+ {
+ if (! $this->hasBeenResolved()) {
+ $this->resolvedValues = [];
+ }
+
+ return $this->addValuesTo(
+ $this->resolvedValues,
+ $this->stripBlacklistedValues($this->wantArray($values))
+ );
+ }
+
+ protected function removeValuesFrom(&$array, $values)
+ {
+ foreach ($this->wantArray($values) as $value) {
+ $this->removeFrom($array, $value);
+ }
+
+ return $this;
+ }
+
+ protected function addTo(&$array, $value)
+ {
+ if (! in_array($value, $array)) {
+ $this->assertValidValue($value);
+ $array[] = $value;
+ }
+
+ return $this;
+ }
+
+ protected function removeFrom(&$array, $value)
+ {
+ if (false !== ($pos = array_search($value, $array))) {
+ unset($array[$pos]);
+ }
+
+ return $this;
+ }
+
+ protected function recalculate()
+ {
+ $this->resolvedValues = [];
+
+ if ($this->ownValues === null) {
+ $this->addValuesTo($this->resolvedValues, $this->inheritedValues);
+ } else {
+ $this->addValuesTo($this->resolvedValues, $this->ownValues);
+ }
+ $this->addValuesTo($this->resolvedValues, $this->plusValues);
+ $this->removeFrom($this->resolvedValues, $this->minusValues);
+
+ return $this;
+ }
+
+ protected function reset()
+ {
+ $this->ownValues = null;
+ $this->plusValues = [];
+ $this->minusValues = [];
+ $this->resolvedValues = null;
+ $this->inheritedValues = [];
+
+ return $this;
+ }
+
+ protected function translate($string)
+ {
+ return mt('director', $string);
+ }
+
+ protected function wantArray($values)
+ {
+ if (is_array($values)) {
+ return $values;
+ }
+
+ return [$values];
+ }
+}
diff --git a/library/Director/IcingaConfig/IcingaConfig.php b/library/Director/IcingaConfig/IcingaConfig.php
new file mode 100644
index 0000000..72edd7e
--- /dev/null
+++ b/library/Director/IcingaConfig/IcingaConfig.php
@@ -0,0 +1,781 @@
+<?php
+
+namespace Icinga\Module\Director\IcingaConfig;
+
+use Icinga\Application\Benchmark;
+use Icinga\Application\Hook;
+use Icinga\Application\Icinga;
+use Icinga\Exception\NotFoundError;
+use Icinga\Module\Director\Application\MemoryLimit;
+use Icinga\Module\Director\Db\Cache\PrefetchCache;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Hook\ShipConfigFilesHook;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Icinga\Module\Director\Objects\IcingaHost;
+use Icinga\Module\Director\Objects\IcingaZone;
+use InvalidArgumentException;
+use LogicException;
+use RuntimeException;
+
+class IcingaConfig
+{
+ protected $files = array();
+
+ protected $checksum;
+
+ protected $zoneMap = array();
+
+ protected $lastActivityChecksum;
+
+ /** @var \Zend_Db_Adapter_Abstract */
+ protected $db;
+
+ protected $connection;
+
+ protected $generationTime;
+
+ protected $configFormat;
+
+ protected $deploymentModeV1;
+
+ public static $table = 'director_generated_config';
+
+ public function __construct(Db $connection)
+ {
+ // Make sure module hooks are loaded:
+ Icinga::app()->getModuleManager()->loadEnabledModules();
+
+ $this->connection = $connection;
+ $this->db = $connection->getDbAdapter();
+ $this->configFormat = $this->connection->settings()->config_format;
+ $this->deploymentModeV1 = $this->connection->settings()->deployment_mode_v1;
+ }
+
+ public function getSize()
+ {
+ $size = 0;
+ foreach ($this->getFiles() as $file) {
+ $size += $file->getSize();
+ }
+ return $size;
+ }
+
+ public function getDuration()
+ {
+ return $this->generationTime;
+ }
+
+ public function getFileCount()
+ {
+ return count($this->files);
+ }
+
+ public function getConfigFormat()
+ {
+ return $this->configFormat;
+ }
+
+ public function getDeploymentMode()
+ {
+ if ($this->isLegacy()) {
+ return $this->deploymentModeV1;
+ } else {
+ throw new LogicException('There is no deployment mode for Icinga 2 config format!');
+ }
+ }
+
+ public function setConfigFormat($format)
+ {
+ if (! in_array($format, array('v1', 'v2'))) {
+ throw new InvalidArgumentException(sprintf(
+ 'Only Icinga v1 and v2 config format is supported, got "%s"',
+ $format
+ ));
+ }
+
+ $this->configFormat = $format;
+
+ return $this;
+ }
+
+ public function isLegacy()
+ {
+ return $this->configFormat === 'v1';
+ }
+
+ public function getObjectCount()
+ {
+ $cnt = 0;
+ foreach ($this->getFiles() as $file) {
+ $cnt += $file->getObjectCount();
+ }
+ return $cnt;
+ }
+
+ public function getTemplateCount()
+ {
+ $cnt = 0;
+ foreach ($this->getFiles() as $file) {
+ $cnt += $file->getTemplateCount();
+ }
+ return $cnt;
+ }
+
+ public function getApplyCount()
+ {
+ $cnt = 0;
+ foreach ($this->getFiles() as $file) {
+ $cnt += $file->getApplyCount();
+ }
+ return $cnt;
+ }
+
+ public function getChecksum()
+ {
+ return $this->checksum;
+ }
+
+ public function getHexChecksum()
+ {
+ return bin2hex($this->checksum);
+ }
+
+ /**
+ * @return IcingaConfigFile[]
+ */
+ public function getFiles()
+ {
+ return $this->files;
+ }
+
+ public function getFileContents()
+ {
+ $result = array();
+ foreach ($this->files as $name => $file) {
+ $result[$name] = $file->getContent();
+ }
+
+ return $result;
+ }
+
+ /**
+ * @return array
+ */
+ public function getFileNames()
+ {
+ return array_keys($this->files);
+ }
+
+ /**
+ * @param string $name
+ *
+ * @return IcingaConfigFile
+ */
+ public function getFile($name)
+ {
+ return $this->files[$name];
+ }
+
+ /**
+ * @param string $checksum
+ * @param Db $connection
+ *
+ * @return static
+ */
+ public static function load($checksum, Db $connection)
+ {
+ $config = new static($connection);
+ $config->loadFromDb($checksum);
+ return $config;
+ }
+
+ /**
+ * @param string $checksum
+ * @param Db $connection
+ *
+ * @return bool
+ */
+ public static function exists($checksum, Db $connection)
+ {
+ $db = $connection->getDbAdapter();
+ $query = $db->select()->from(
+ array('c' => self::$table),
+ array('checksum' => $connection->dbHexFunc('c.checksum'))
+ )->where(
+ 'checksum = ?',
+ $connection->quoteBinary(hex2bin($checksum))
+ );
+
+ return $db->fetchOne($query) === $checksum;
+ }
+
+ public static function loadByActivityChecksum($checksum, Db $connection)
+ {
+ $db = $connection->getDbAdapter();
+ $query = $db->select()->from(
+ array('c' => self::$table),
+ array('checksum' => 'c.checksum')
+ )->join(
+ array('l' => 'director_activity_log'),
+ 'l.checksum = c.last_activity_checksum',
+ array()
+ )->where(
+ 'last_activity_checksum = ?',
+ $connection->quoteBinary(hex2bin($checksum))
+ )->order('l.id DESC')->limit(1);
+
+ return self::load($db->fetchOne($query), $connection);
+ }
+
+ public static function existsForActivityChecksum($checksum, Db $connection)
+ {
+ $db = $connection->getDbAdapter();
+ $query = $db->select()->from(
+ array('c' => self::$table),
+ array('checksum' => $connection->dbHexFunc('c.checksum'))
+ )->join(
+ array('l' => 'director_activity_log'),
+ 'l.checksum = c.last_activity_checksum',
+ array()
+ )->where(
+ 'last_activity_checksum = ?',
+ $connection->quoteBinary(hex2bin($checksum))
+ )->order('l.id DESC')->limit(1);
+
+ return $db->fetchOne($query) === $checksum;
+ }
+
+ /**
+ * @param Db $connection
+ *
+ * @return mixed
+ */
+ public static function generate(Db $connection)
+ {
+ $config = new static($connection);
+ return $config->storeIfModified();
+ }
+
+ public static function wouldChange(Db $connection)
+ {
+ $config = new static($connection);
+ return $config->hasBeenModified();
+ }
+
+ public function hasBeenModified()
+ {
+ $this->generateFromDb();
+ $this->collectExtraFiles();
+ $checksum = $this->calculateChecksum();
+ $activity = $this->getLastActivityChecksum();
+
+ $lastActivity = $this->connection->binaryDbResult(
+ $this->db->fetchOne(
+ $this->db->select()->from(
+ self::$table,
+ 'last_activity_checksum'
+ )->where(
+ 'checksum = ?',
+ $this->dbBin($checksum)
+ )
+ )
+ );
+
+ if ($lastActivity === false || $lastActivity === null) {
+ return true;
+ }
+
+ if ($lastActivity !== $activity) {
+ $this->db->update(
+ self::$table,
+ array(
+ 'last_activity_checksum' => $this->dbBin($activity)
+ ),
+ $this->db->quoteInto('checksum = ?', $this->dbBin($checksum))
+ );
+ }
+
+ return false;
+ }
+
+ protected function storeIfModified()
+ {
+ if ($this->hasBeenModified()) {
+ $this->store();
+ }
+
+ return $this;
+ }
+
+ protected function dbBin($binary)
+ {
+ return $this->connection->quoteBinary($binary);
+ }
+
+ protected function calculateChecksum()
+ {
+ $files = array();
+ $sortedFiles = $this->files;
+ ksort($sortedFiles);
+ /** @var IcingaConfigFile $file */
+ foreach ($sortedFiles as $name => $file) {
+ $files[] = $name . '=' . $file->getHexChecksum();
+ }
+
+ $this->checksum = sha1(implode(';', $files), true);
+ return $this->checksum;
+ }
+
+ public function getFilesChecksums()
+ {
+ $checksums = array();
+
+ /** @var IcingaConfigFile $file */
+ foreach ($this->files as $name => $file) {
+ $checksums[] = $file->getChecksum();
+ }
+
+ return $checksums;
+ }
+
+ // TODO: prepare lookup cache if empty?
+ public function getZoneName($id)
+ {
+ if (! array_key_exists($id, $this->zoneMap)) {
+ $zone = IcingaZone::loadWithAutoIncId($id, $this->connection);
+ $this->zoneMap[$id] = $zone->get('object_name');
+ }
+
+ return $this->zoneMap[$id];
+ }
+
+ /**
+ * @return self
+ */
+ public function store()
+ {
+ $fileTable = IcingaConfigFile::$table;
+ $fileKey = IcingaConfigFile::$keyName;
+
+ $existingQuery = $this->db->select()
+ ->from($fileTable, 'checksum')
+ ->where('checksum IN (?)', array_map(array($this, 'dbBin'), $this->getFilesChecksums()));
+
+ $existing = $this->db->fetchCol($existingQuery);
+
+ foreach ($existing as $key => $val) {
+ if (is_resource($val)) {
+ $existing[$key] = stream_get_contents($val);
+ }
+ }
+
+ $missing = array_diff($this->getFilesChecksums(), $existing);
+ $stored = array();
+
+ /** @var IcingaConfigFile $file */
+ foreach ($this->files as $name => $file) {
+ $checksum = $file->getChecksum();
+ if (! in_array($checksum, $missing)) {
+ continue;
+ }
+
+ if (array_key_exists($checksum, $stored)) {
+ continue;
+ }
+
+ $stored[$checksum] = true;
+
+ $this->db->insert(
+ $fileTable,
+ array(
+ $fileKey => $this->dbBin($checksum),
+ 'content' => $file->getContent(),
+ 'cnt_object' => $file->getObjectCount(),
+ 'cnt_template' => $file->getTemplateCount()
+ )
+ );
+ }
+
+ $activity = $this->dbBin($this->getLastActivityChecksum());
+ $this->db->beginTransaction();
+ try {
+ $this->db->insert(self::$table, [
+ 'duration' => $this->generationTime,
+ 'first_activity_checksum' => $activity,
+ 'last_activity_checksum' => $activity,
+ 'checksum' => $this->dbBin($this->getChecksum()),
+ ]);
+ /** @var IcingaConfigFile $file */
+ foreach ($this->files as $name => $file) {
+ $this->db->insert('director_generated_config_file', [
+ 'config_checksum' => $this->dbBin($this->getChecksum()),
+ 'file_checksum' => $this->dbBin($file->getChecksum()),
+ 'file_path' => $name,
+ ]);
+ }
+ $this->db->commit();
+ } catch (\Exception $e) {
+ try {
+ $this->db->rollBack();
+ } catch (\Exception $ignored) {
+ // Well...
+ }
+
+ throw $e;
+ }
+
+ return $this;
+ }
+
+ /**
+ * @return self
+ */
+ protected function generateFromDb()
+ {
+ PrefetchCache::initialize($this->connection);
+ $start = microtime(true);
+
+ MemoryLimit::raiseTo('1024M');
+ ini_set('max_execution_time', 0);
+ // Workaround for https://bugs.php.net/bug.php?id=68606 or similar
+ ini_set('zend.enable_gc', 0);
+
+ if (! $this->connection->isPgsql() && $this->db->quote("1\0") !== '\'1\\0\'') {
+ throw new RuntimeException(
+ 'Refusing to render the configuration, your DB layer corrupts binary data.'
+ . ' You might be affected by Zend Framework bug #655'
+ );
+ }
+
+ $this
+ ->prepareGlobalBasics()
+ ->createFileFromDb('zone')
+ ->createFileFromDb('endpoint')
+ ->createFileFromDb('command')
+ ->createFileFromDb('timePeriod')
+ ->createFileFromDb('hostGroup')
+ ->createFileFromDb('host')
+ ->createFileFromDb('serviceGroup')
+ ->createFileFromDb('service')
+ ->createFileFromDb('serviceSet')
+ ->createFileFromDb('userGroup')
+ ->createFileFromDb('user')
+ ->createFileFromDb('notification')
+ ->createFileFromDb('dependency')
+ ->createFileFromDb('scheduledDowntime')
+ ;
+
+ PrefetchCache::forget();
+ IcingaHost::clearAllPrefetchCaches();
+
+ $this->generationTime = (int) ((microtime(true) - $start) * 1000);
+
+ return $this;
+ }
+
+ /**
+ * @return self
+ */
+ protected function prepareGlobalBasics()
+ {
+ if ($this->isLegacy()) {
+ $this->configFile(
+ sprintf(
+ 'director/%s/001-director-basics',
+ $this->connection->getDefaultGlobalZoneName()
+ ),
+ '.cfg'
+ )->prepend(
+ $this->renderLegacyDefaultNotification()
+ );
+
+ return $this;
+ }
+
+ $this->configFile(
+ sprintf(
+ 'zones.d/%s/001-director-basics',
+ $this->connection->getDefaultGlobalZoneName()
+ )
+ )->prepend(
+ "\nconst DirectorStageDir = dirname(dirname(current_filename))\n"
+ . $this->renderFlappingLogHelper()
+ . $this->renderHostOverridableVars()
+ );
+
+ return $this;
+ }
+
+ protected function renderFlappingLogHelper()
+ {
+ return '
+globals.directorWarnedOnceForThresholds = false;
+globals.directorWarnOnceForThresholds = function() {
+ if (globals.directorWarnedOnceForThresholds == false) {
+ globals.directorWarnedOnceForThresholds = true
+ log(LogWarning, "config", "Director: flapping_threshold_high/low is not supported in this Icinga 2 version!")
+ }
+}
+';
+ }
+
+ protected function renderHostOverridableVars()
+ {
+ $settings = $this->connection->settings();
+
+ return sprintf(
+ '
+const DirectorOverrideTemplate = "%s"
+if (! globals.contains(DirectorOverrideTemplate)) {
+ const DirectorOverrideVars = "%s"
+
+ globals.directorWarnedOnceForServiceWithoutHost = false;
+ globals.directorWarnOnceForServiceWithoutHost = function() {
+ if (globals.directorWarnedOnceForServiceWithoutHost == false) {
+ globals.directorWarnedOnceForServiceWithoutHost = true
+ log(
+ LogWarning,
+ "config",
+ "Director: Custom Variable Overrides will not work in this Icinga 2 version. See Director issue #1579"
+ )
+ }
+ }
+
+ template Service DirectorOverrideTemplate {
+ /**
+ * Seems that host is missing when used in a service object, works fine for
+ * apply rules
+ */
+ if (! host) {
+ var host = get_host(host_name)
+ }
+ if (! host) {
+ globals.directorWarnOnceForServiceWithoutHost()
+ }
+
+ if (vars) {
+ vars += host.vars[DirectorOverrideVars][name]
+ } else {
+ vars = host.vars[DirectorOverrideVars][name]
+ }
+ }
+}
+',
+ $settings->override_services_templatename,
+ $settings->override_services_varname
+ );
+ }
+
+ /**
+ * @param string $checksum
+ *
+ * @throws NotFoundError
+ *
+ * @return self
+ */
+ protected function loadFromDb($checksum)
+ {
+ $query = $this->db->select()->from(
+ self::$table,
+ array('checksum', 'last_activity_checksum', 'duration')
+ )->where('checksum = ?', $this->dbBin($checksum));
+ $result = $this->db->fetchRow($query);
+
+ if (empty($result)) {
+ throw new NotFoundError('Got no config for %s', bin2hex($checksum));
+ }
+
+ $this->checksum = $this->connection->binaryDbResult($result->checksum);
+ $this->generationTime = $result->duration;
+ $this->lastActivityChecksum = $this->connection->binaryDbResult($result->last_activity_checksum);
+
+ $query = $this->db->select()->from(
+ array('cf' => 'director_generated_config_file'),
+ array(
+ 'file_path' => 'cf.file_path',
+ 'checksum' => 'f.checksum',
+ 'content' => 'f.content',
+ 'cnt_object' => 'f.cnt_object',
+ 'cnt_template' => 'f.cnt_template',
+ 'cnt_apply' => 'f.cnt_apply',
+ )
+ )->join(
+ array('f' => 'director_generated_file'),
+ 'cf.file_checksum = f.checksum',
+ array()
+ )->where('cf.config_checksum = ?', $this->dbBin($checksum));
+
+ foreach ($this->db->fetchAll($query) as $row) {
+ $file = new IcingaConfigFile();
+ $this->files[$row->file_path] = $file
+ ->setContent($row->content)
+ ->setObjectCount($row->cnt_object)
+ ->setTemplateCount($row->cnt_template)
+ ->setApplyCount($row->cnt_apply);
+ }
+
+ return $this;
+ }
+
+ protected function createFileFromDb($type)
+ {
+ /** @var IcingaObject $class */
+ $class = 'Icinga\\Module\\Director\\Objects\\Icinga' . ucfirst($type);
+ Benchmark::measure(sprintf('Prefetching %s', $type));
+ $objects = $class::prefetchAll($this->connection);
+ return $this->createFileForObjects($type, $objects);
+ }
+
+ /**
+ * @param string $type Short object type, like 'service' or 'zone'
+ * @param IcingaObject[] $objects
+ *
+ * @return self
+ */
+ protected function createFileForObjects($type, $objects)
+ {
+ if (empty($objects)) {
+ return $this;
+ }
+
+ Benchmark::measure(sprintf('Generating %ss: %s', $type, count($objects)));
+ foreach ($objects as $object) {
+ if ($object->isExternal()) {
+ if ($type === 'zone') {
+ $this->zoneMap[$object->get('id')] = $object->getObjectName();
+ }
+ }
+ $object->renderToConfig($this);
+ }
+
+ Benchmark::measure(sprintf('%ss done', $type));
+ return $this;
+ }
+
+ protected function typeWantsGlobalZone($type)
+ {
+ $types = array(
+ 'command',
+ );
+
+ return in_array($type, $types);
+ }
+
+ protected function typeWantsMasterZone($type)
+ {
+ $types = array(
+ 'host',
+ 'hostGroup',
+ 'service',
+ 'serviceGroup',
+ 'endpoint',
+ 'user',
+ 'userGroup',
+ 'timePeriod',
+ 'notification',
+ 'dependency'
+ );
+
+ return in_array($type, $types);
+ }
+
+ /**
+ * @param string $name Relative config file name
+ * @param string $suffix Config file suffix, defaults to '.conf'
+ *
+ * @return IcingaConfigFile
+ */
+ public function configFile($name, $suffix = '.conf')
+ {
+ $filename = $name . $suffix;
+ if (! array_key_exists($filename, $this->files)) {
+ $this->files[$filename] = new IcingaConfigFile();
+ }
+
+ return $this->files[$filename];
+ }
+
+ protected function collectExtraFiles()
+ {
+ /** @var ShipConfigFilesHook $hook */
+ foreach (Hook::all('Director\\ShipConfigFiles') as $hook) {
+ foreach ($hook->fetchFiles() as $filename => $file) {
+ if (array_key_exists($filename, $this->files)) {
+ throw new LogicException(sprintf(
+ 'Cannot ship one file twice: %s',
+ $filename
+ ));
+ }
+ if ($file instanceof IcingaConfigFile) {
+ $this->files[$filename] = $file;
+ } else {
+ $this->configFile($filename, '')->setContent((string) $file);
+ }
+ }
+ }
+
+ return $this;
+ }
+
+ public function getLastActivityHexChecksum()
+ {
+ return bin2hex($this->getLastActivityChecksum());
+ }
+
+ /**
+ * @return mixed
+ */
+ public function getLastActivityChecksum()
+ {
+ if ($this->lastActivityChecksum === null) {
+ $query = $this->db->select()
+ ->from('director_activity_log', 'checksum')
+ ->order('id DESC')
+ ->limit(1);
+
+ $this->lastActivityChecksum = $this->db->fetchOne($query);
+
+ // PgSQL workaround:
+ if (is_resource($this->lastActivityChecksum)) {
+ $this->lastActivityChecksum = stream_get_contents($this->lastActivityChecksum);
+ }
+ }
+
+ return $this->lastActivityChecksum;
+ }
+
+ protected function renderLegacyDefaultNotification()
+ {
+ return preg_replace('~^ {12}~m', '', '
+ #
+ # Default objects to avoid warnings
+ #
+
+ define contact {
+ contact_name icingaadmin
+ alias Icinga Admin
+ host_notifications_enabled 0
+ host_notification_commands notify-never-default
+ host_notification_period notification_none
+ service_notifications_enabled 0
+ service_notification_commands notify-never-default
+ service_notification_period notification_none
+ }
+
+ define contactgroup {
+ contactgroup_name icingaadmins
+ members icingaadmin
+ }
+
+ define timeperiod {
+ timeperiod_name notification_none
+ alias No Notifications
+ }
+
+ define command {
+ command_name notify-never-default
+ command_line /bin/echo "NOOP"
+ }
+ ');
+ }
+}
diff --git a/library/Director/IcingaConfig/IcingaConfigFile.php b/library/Director/IcingaConfig/IcingaConfigFile.php
new file mode 100644
index 0000000..109eb8a
--- /dev/null
+++ b/library/Director/IcingaConfig/IcingaConfigFile.php
@@ -0,0 +1,168 @@
+<?php
+
+namespace Icinga\Module\Director\IcingaConfig;
+
+use Icinga\Module\Director\Objects\IcingaObject;
+use Icinga\Module\Director\Util;
+
+class IcingaConfigFile
+{
+ public static $table = 'director_generated_file';
+
+ public static $keyName = 'checksum';
+
+ protected $content;
+
+ protected $checksum;
+
+ protected $cntObject = 0;
+
+ protected $cntTemplate = 0;
+
+ protected $cntApply = 0;
+
+ /**
+ * @param $content
+ *
+ * @return self
+ */
+ public function prepend($content)
+ {
+ $this->content = $content . $this->content;
+ $this->checksum = null;
+ return $this;
+ }
+
+ public function getContent()
+ {
+ return $this->content;
+ }
+
+ public function setContent($content)
+ {
+ $this->content = $content;
+ $this->checksum = null;
+ return $this;
+ }
+
+ public function addContent($content)
+ {
+ if ($this->content === null) {
+ $this->content = $content;
+ } else {
+ $this->content .= $content;
+ }
+ $this->checksum = null;
+ return $this;
+ }
+
+ public function getObjectCount()
+ {
+ return $this->cntObject;
+ }
+
+ public function getTemplateCount()
+ {
+ return $this->cntTemplate;
+ }
+
+ public function getApplyCount()
+ {
+ return $this->cntApply;
+ }
+
+ public function getSize()
+ {
+ return strlen($this->content);
+ }
+
+ public function setObjectCount($cnt)
+ {
+ $this->cntObject = $cnt;
+ return $this;
+ }
+
+ public function setTemplateCount($cnt)
+ {
+ $this->cntTemplate = $cnt;
+ return $this;
+ }
+
+ public function setApplyCount($cnt)
+ {
+ $this->cntApply = $cnt;
+ return $this;
+ }
+
+ public function getHexChecksum()
+ {
+ return bin2hex($this->getChecksum());
+ }
+
+ public function getChecksum()
+ {
+ if ($this->checksum === null) {
+ $this->checksum = sha1($this->content, true);
+ }
+
+ return $this->checksum;
+ }
+
+ public function addLegacyObjects($objects)
+ {
+ foreach ($objects as $object) {
+ $this->addLegacyObject($object);
+ }
+
+ return $this;
+ }
+
+ public function addObjects($objects)
+ {
+ foreach ($objects as $object) {
+ $this->addObject($object);
+ }
+
+ return $this;
+ }
+
+ public function addObject(IcingaObject $object)
+ {
+ $this->content .= $object->toConfigString();
+ $this->checksum = null;
+ return $this->addObjectStats($object);
+ }
+
+ public function addLegacyObject(IcingaObject $object)
+ {
+ $this->content .= $object->toLegacyConfigString();
+ $this->checksum = null;
+ return $this->addObjectStats($object);
+ }
+
+ protected function addObjectStats(IcingaObject $object)
+ {
+ if ($object->hasProperty('object_type')) {
+ $type = $object->object_type;
+
+ switch ($type) {
+ case 'object':
+ $this->cntObject++;
+ break;
+ case 'template':
+ $this->cntTemplate++;
+ break;
+ case 'apply':
+ $this->cntApply++;
+ break;
+ }
+ }
+
+ return $this;
+ }
+
+ public function __toString()
+ {
+ return $this->getContent();
+ }
+}
diff --git a/library/Director/IcingaConfig/IcingaConfigHelper.php b/library/Director/IcingaConfig/IcingaConfigHelper.php
new file mode 100644
index 0000000..03c017e
--- /dev/null
+++ b/library/Director/IcingaConfig/IcingaConfigHelper.php
@@ -0,0 +1,430 @@
+<?php
+
+namespace Icinga\Module\Director\IcingaConfig;
+
+use InvalidArgumentException;
+use function ctype_digit;
+use function explode;
+use function floor;
+use function implode;
+use function preg_match;
+use function preg_split;
+use function sprintf;
+use function strlen;
+use function strpos;
+use function substr;
+
+class IcingaConfigHelper
+{
+ /**
+ * Reserved words according to
+ * https://icinga.com/docs/icinga2/latest/doc/17-language-reference/#reserved-keywords
+ */
+ protected static $reservedWords = [
+ 'object',
+ 'template',
+ 'include',
+ 'include_recursive',
+ 'include_zones',
+ 'library',
+ 'null',
+ 'true',
+ 'false',
+ 'const',
+ 'var',
+ 'this',
+ 'globals',
+ 'locals',
+ 'use',
+ 'default',
+ 'ignore_on_error',
+ 'current_filename',
+ 'current_line',
+ 'apply',
+ 'to',
+ 'where',
+ 'import',
+ 'assign',
+ 'ignore',
+ 'function',
+ 'return',
+ 'break',
+ 'continue',
+ 'for',
+ 'if',
+ 'else',
+ 'while',
+ 'throw',
+ 'try',
+ 'except',
+ 'in',
+ 'using',
+ 'namespace',
+ ];
+
+ public static function renderKeyValue($key, $value, $prefix = ' ')
+ {
+ return self::renderKeyOperatorValue($key, '=', $value, $prefix);
+ }
+
+ public static function renderKeyOperatorValue($key, $operator, $value, $prefix = ' ')
+ {
+ $string = sprintf(
+ "%s %s %s",
+ $key,
+ $operator,
+ $value
+ );
+
+ if ($prefix && strpos($string, "\n") !== false) {
+ return $prefix . implode("\n" . $prefix, explode("\n", $string)) . "\n";
+ }
+
+ return $prefix . $string . "\n";
+ }
+
+ public static function renderBoolean($value)
+ {
+ if ($value === 'y' || $value === true) {
+ return 'true';
+ }
+ if ($value === 'n' || $value === false) {
+ return 'false';
+ }
+
+ throw new InvalidArgumentException(sprintf(
+ '%s is not a valid boolean',
+ $value
+ ));
+ }
+
+ protected static function renderInteger($value)
+ {
+ return (string) $value;
+ }
+
+ public static function renderFloat($value)
+ {
+ // Render .0000 floats as integers, mainly because of some JSON
+ // implementations:
+ if ((string) (int) $value === (string) $value) {
+ return static::renderInteger((int) $value);
+ }
+
+ return sprintf('%F', $value);
+ }
+
+ protected static function renderNull()
+ {
+ return 'null';
+ }
+
+ // TODO: Find out how to allow multiline {{{...}}} strings.
+ // Parameter? Dedicated method? Always if \n is found?
+ public static function renderString($string)
+ {
+ $special = [
+ '/\\\/',
+ '/"/',
+ '/\$/',
+ '/\t/',
+ '/\r/',
+ '/\n/',
+ // '/\b/', -> doesn't work
+ '/\f/',
+ ];
+
+ $replace = [
+ '\\\\\\',
+ '\\"',
+ '\\$',
+ '\\t',
+ '\\r',
+ '\\n',
+ // '\\b',
+ '\\f',
+ ];
+
+ $string = preg_replace($special, $replace, $string);
+
+ return '"' . $string . '"';
+ }
+
+ public static function renderPhpValue($value)
+ {
+ if (is_null($value)) {
+ return static::renderNull();
+ }
+ if (is_bool($value)) {
+ return static::renderBoolean($value);
+ }
+ if (is_int($value)) {
+ return static::renderInteger($value);
+ }
+ if (is_float($value)) {
+ return static::renderFloat($value);
+ }
+ // TODO:
+ // if (is_object($value) || static::isAssocArray($value)) {
+ // return static::renderHash($value, $prefix)
+ // TODO: also check array
+ if (is_array($value)) {
+ return static::renderArray($value);
+ }
+ if (is_string($value)) {
+ return static::renderString($value);
+ }
+
+ throw new InvalidArgumentException(sprintf(
+ 'Unexpected type %s',
+ var_export($value, 1)
+ ));
+ }
+
+ public static function renderDictionaryKey($key)
+ {
+ if (preg_match('/^[a-z_]+[a-z0-9_]*$/i', $key)) {
+ return static::escapeIfReserved($key);
+ }
+
+ return static::renderString($key);
+ }
+
+ // Requires an array
+ public static function renderArray($array)
+ {
+ $data = [];
+ foreach ($array as $entry) {
+ if ($entry instanceof IcingaConfigRenderer) {
+ $data[] = $entry;
+ } else {
+ $data[] = self::renderString($entry);
+ }
+ }
+
+ return static::renderEscapedArray($data);
+ }
+
+ public static function renderEscapedArray($array)
+ {
+ $str = '[ ' . implode(', ', $array) . ' ]';
+
+ if (strlen($str) < 60) {
+ return $str;
+ }
+
+ // Prefix for toConfigString?
+ return "[\n " . implode(",\n ", $array) . "\n]";
+ }
+
+ public static function renderDictionary($dictionary)
+ {
+ $values = [];
+ foreach ($dictionary as $key => $value) {
+ $values[$key] = rtrim(
+ self::renderKeyValue(
+ self::renderDictionaryKey($key),
+ $value
+ )
+ );
+ }
+
+ if (empty($values)) {
+ return '{}';
+ }
+ ksort($values, SORT_STRING);
+
+ // Prefix for toConfigString?
+ return "{\n" . implode("\n", $values) . "\n}";
+ }
+
+ public static function renderExpression($string)
+ {
+ return "{{\n " . $string . "\n}}";
+ }
+
+ public static function alreadyRendered($string)
+ {
+ return new IcingaConfigRendered($string);
+ }
+
+ public static function isReserved($string)
+ {
+ return in_array($string, self::$reservedWords, true);
+ }
+
+ public static function escapeIfReserved($string)
+ {
+ if (self::isReserved($string)) {
+ return '@' . $string;
+ }
+
+ return $string;
+ }
+
+ public static function isValidInterval($interval)
+ {
+ if (ctype_digit($interval)) {
+ return true;
+ }
+
+ $parts = preg_split('/\s+/', $interval, -1, PREG_SPLIT_NO_EMPTY);
+ foreach ($parts as $part) {
+ if (! preg_match('/^(\d+)([dhms]?)$/', $part)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ public static function parseInterval($interval)
+ {
+ if ($interval === null || $interval === '') {
+ return null;
+ }
+
+ if (is_int($interval) || ctype_digit($interval)) {
+ return (int) $interval;
+ }
+
+ $parts = preg_split('/\s+/', $interval, -1, PREG_SPLIT_NO_EMPTY);
+ $value = 0;
+ foreach ($parts as $part) {
+ if (! preg_match('/^(\d+)([dhms]?)$/', $part, $m)) {
+ throw new InvalidArgumentException(sprintf(
+ '"%s" is not a valid time (duration) definition',
+ $interval
+ ));
+ }
+
+ switch ($m[2]) {
+ case 'd':
+ $value += $m[1] * 86400;
+ break;
+ case 'h':
+ $value += $m[1] * 3600;
+ break;
+ case 'm':
+ $value += $m[1] * 60;
+ break;
+ default:
+ $value += (int) $m[1];
+ }
+ }
+
+ return $value;
+ }
+
+ public static function renderInterval($interval)
+ {
+ // TODO: compat only, do this at munge time. All db fields should be int
+ $seconds = self::parseInterval($interval);
+ if ($seconds === 0) {
+ return '0s';
+ }
+
+ $steps = [
+ 'd' => 86400,
+ 'h' => 3600,
+ 'm' => 60,
+ ];
+
+ foreach ($steps as $unit => $duration) {
+ if ($seconds % $duration === 0) {
+ return (int) floor($seconds / $duration) . $unit;
+ }
+ }
+
+ return $seconds . 's';
+ }
+
+ public static function stringHasMacro($string, $macroName = null)
+ {
+ $len = strlen($string);
+ $start = false;
+ // TODO: robust UTF8 support. It works, but it is not 100% correct
+ for ($i = 0; $i < $len; $i++) {
+ if ($string[$i] === '$') {
+ if ($start === false) {
+ $start = $i;
+ } else {
+ // Escaping, $$
+ if ($start + 1 === $i) {
+ $start = false;
+ } else {
+ if ($macroName === null) {
+ return true;
+ }
+ if ($macroName === substr($string, $start + 1, $i - $start - 1)) {
+ return true;
+ }
+
+ $start = false;
+ }
+ }
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Hint: this isn't complete, but let's restrict ourselves right now
+ *
+ * @param $name
+ * @return bool
+ */
+ public static function isValidMacroName($name)
+ {
+ return preg_match('/^[A-z_][A-z_.\d]+$/', $name)
+ && ! preg_match('/\.$/', $name);
+ }
+
+ public static function renderStringWithVariables($string, array $whiteList = null)
+ {
+ $len = strlen($string);
+ $start = false;
+ $parts = [];
+ // TODO: UTF8...
+ $offset = 0;
+ for ($i = 0; $i < $len; $i++) {
+ if ($string[$i] === '$') {
+ if ($start === false) {
+ $start = $i;
+ } else {
+ // Ignore $$
+ if ($start + 1 === $i) {
+ $start = false;
+ } 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;
+ }
+ }
+
+ $start = false;
+ }
+ }
+ }
+ }
+
+ if ($offset < $i) {
+ $parts[] = static::renderString(substr($string, $offset, $i - $offset));
+ }
+
+ if (! empty($parts)) {
+ return implode(' + ', $parts);
+ }
+
+ return '""';
+ }
+}
diff --git a/library/Director/IcingaConfig/IcingaConfigRendered.php b/library/Director/IcingaConfig/IcingaConfigRendered.php
new file mode 100644
index 0000000..90b710e
--- /dev/null
+++ b/library/Director/IcingaConfig/IcingaConfigRendered.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Icinga\Module\Director\IcingaConfig;
+
+use InvalidArgumentException;
+
+class IcingaConfigRendered implements IcingaConfigRenderer
+{
+ protected $rendered;
+
+ public function __construct($string)
+ {
+ if (! is_string($string)) {
+ throw new InvalidArgumentException('IcingaConfigRendered accepts only strings');
+ }
+
+ $this->rendered = $string;
+ }
+
+ public function toConfigString()
+ {
+ return $this->rendered;
+ }
+
+ public function __toString()
+ {
+ return $this->toConfigString();
+ }
+
+ public function toLegacyConfigString()
+ {
+ return $this->rendered;
+ }
+}
diff --git a/library/Director/IcingaConfig/IcingaConfigRenderer.php b/library/Director/IcingaConfig/IcingaConfigRenderer.php
new file mode 100644
index 0000000..108956d
--- /dev/null
+++ b/library/Director/IcingaConfig/IcingaConfigRenderer.php
@@ -0,0 +1,10 @@
+<?php
+
+namespace Icinga\Module\Director\IcingaConfig;
+
+interface IcingaConfigRenderer
+{
+ public function toConfigString();
+ public function toLegacyConfigString();
+ public function __toString();
+}
diff --git a/library/Director/IcingaConfig/IcingaLegacyConfigHelper.php b/library/Director/IcingaConfig/IcingaLegacyConfigHelper.php
new file mode 100644
index 0000000..38d93ee
--- /dev/null
+++ b/library/Director/IcingaConfig/IcingaLegacyConfigHelper.php
@@ -0,0 +1,110 @@
+<?php
+
+namespace Icinga\Module\Director\IcingaConfig;
+
+use InvalidArgumentException;
+
+class IcingaLegacyConfigHelper
+{
+ public static function renderKeyValue($key, $value, $prefix = ' ')
+ {
+ return self::renderKeyOperatorValue($key, "\t", $value, $prefix);
+ }
+
+ public static function renderKeyOperatorValue($key, $operator, $value, $prefix = ' ')
+ {
+ $string = sprintf(
+ "%s%s%s",
+ $key,
+ $operator,
+ $value
+ );
+
+ if ($prefix && strpos($string, "\n") !== false) {
+ return $prefix . implode("\n" . $prefix, explode("\n", $string)) . "\n";
+ }
+
+ return $prefix . $string . "\n";
+ }
+
+ public static function renderBoolean($value)
+ {
+ if ($value === 'y') {
+ return '1';
+ } elseif ($value === 'n') {
+ return '0';
+ } else {
+ throw new InvalidArgumentException('%s is not a valid boolean', $value);
+ }
+ }
+
+ // TODO: Double-check legacy "encoding"
+ public static function renderString($string)
+ {
+ $special = [
+ '/\\\/',
+ '/\$/',
+ '/\t/',
+ '/\r/',
+ '/\n/',
+ // '/\b/', -> doesn't work
+ '/\f/',
+ ];
+
+ $replace = [
+ '\\\\\\',
+ '\\$',
+ '\\t',
+ '\\r',
+ '\\n',
+ // '\\b',
+ '\\f',
+ ];
+
+ $string = preg_replace($special, $replace, $string);
+
+ return $string;
+ }
+
+ /**
+ * @param array $array
+ * @return string
+ */
+ public static function renderArray($array)
+ {
+ $data = [];
+ foreach ($array as $entry) {
+ if ($entry instanceof IcingaConfigRenderer) {
+ // $data[] = $entry;
+ $data[] = 'INVALID_ARRAY_MEMBER';
+ } else {
+ $data[] = self::renderString($entry);
+ }
+ }
+
+ return implode(', ', $data);
+ }
+
+ public static function renderDictionary($dictionary)
+ {
+ return 'INVALID_DICTIONARY';
+ }
+
+ public static function renderExpression($string)
+ {
+ return 'INVALID_EXPRESSION';
+ }
+
+ public static function alreadyRendered($string)
+ {
+ return new IcingaConfigRendered($string);
+ }
+
+ public static function renderInterval($interval)
+ {
+ if ($interval < 60) {
+ $interval = 60;
+ }
+ return $interval / 60;
+ }
+}
diff --git a/library/Director/IcingaConfig/StateFilterSet.php b/library/Director/IcingaConfig/StateFilterSet.php
new file mode 100644
index 0000000..7a2daec
--- /dev/null
+++ b/library/Director/IcingaConfig/StateFilterSet.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Icinga\Module\Director\IcingaConfig;
+
+class StateFilterSet extends ExtensibleSet
+{
+ protected $allowedValues = array(
+ 'Up',
+ 'Down',
+ 'OK',
+ 'Warning',
+ 'Critical',
+ 'Unknown',
+ );
+
+ public function enumAllowedValues()
+ {
+ return array(
+ $this->translate('Hosts') => array(
+ 'Up' => $this->translate('Up'),
+ 'Down' => $this->translate('Down')
+ ),
+ $this->translate('Services') => array(
+ 'OK' => $this->translate('OK'),
+ 'Warning' => $this->translate('Warning'),
+ 'Critical' => $this->translate('Critical'),
+ 'Unknown' => $this->translate('Unknown'),
+ ),
+ );
+ }
+}
diff --git a/library/Director/IcingaConfig/TypeFilterSet.php b/library/Director/IcingaConfig/TypeFilterSet.php
new file mode 100644
index 0000000..dffd4cf
--- /dev/null
+++ b/library/Director/IcingaConfig/TypeFilterSet.php
@@ -0,0 +1,39 @@
+<?php
+
+namespace Icinga\Module\Director\IcingaConfig;
+
+class TypeFilterSet extends ExtensibleSet
+{
+ protected $allowedValues = array(
+ 'Problem',
+ 'Recovery',
+ 'Custom',
+ 'Acknowledgement',
+ 'DowntimeStart',
+ 'DowntimeEnd',
+ 'DowntimeRemoved',
+ 'FlappingStart',
+ 'FlappingEnd',
+ );
+
+ public function enumAllowedValues()
+ {
+ return array(
+ $this->translate('State changes') => array(
+ 'Problem' => $this->translate('Problem'),
+ 'Recovery' => $this->translate('Recovery'),
+ 'Custom' => $this->translate('Custom notification'),
+ ),
+ $this->translate('Problem handling') => array(
+ 'Acknowledgement' => $this->translate('Acknowledgement'),
+ 'DowntimeStart' => $this->translate('Downtime starts'),
+ 'DowntimeEnd' => $this->translate('Downtime ends'),
+ 'DowntimeRemoved' => $this->translate('Downtime removed'),
+ ),
+ $this->translate('Flapping') => array(
+ 'FlappingStart' => $this->translate('Flapping starts'),
+ 'FlappingEnd' => $this->translate('Flapping ends')
+ )
+ );
+ }
+}
diff --git a/library/Director/Import/Import.php b/library/Director/Import/Import.php
new file mode 100644
index 0000000..f82454d
--- /dev/null
+++ b/library/Director/Import/Import.php
@@ -0,0 +1,481 @@
+<?php
+
+namespace Icinga\Module\Director\Import;
+
+use Exception;
+use Icinga\Application\Benchmark;
+use Icinga\Exception\IcingaException;
+use Icinga\Module\Director\Data\RecursiveUtf8Validator;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Hook\ImportSourceHook;
+use Icinga\Module\Director\Objects\ImportSource;
+use Icinga\Module\Director\Util;
+use stdClass;
+
+class Import
+{
+ /**
+ * @var ImportSource
+ */
+ protected $source;
+
+ /**
+ * @var Db
+ */
+ protected $connection;
+
+ /**
+ * @var \Zend_Db_Adapter_Abstract
+ */
+ protected $db;
+
+ /**
+ * Raw data that should be imported, array of stdClass objects
+ *
+ * @var array
+ */
+ protected $data;
+
+ /**
+ * Checksum of the rowset that should be imported
+ *
+ * @var string
+ */
+ private $rowsetChecksum;
+
+ /**
+ * Checksum-indexed rows
+ *
+ * @var array
+ */
+ private $rows;
+
+ /**
+ * Checksum-indexed row -> property
+ *
+ * @var array
+ */
+ private $rowProperties;
+
+ /**
+ * Whether this rowset exists, for caching purposes
+ *
+ * @var boolean
+ */
+ private $rowsetExists;
+
+ protected $properties = array();
+
+ /**
+ * Checksums of all rows
+ */
+ private $rowChecksums;
+
+ public function __construct(ImportSource $source)
+ {
+ $this->source = $source;
+ $this->connection = $source->getConnection();
+ $this->db = $this->connection->getDbAdapter();
+ }
+
+ /**
+ * Whether this import provides modified data
+ *
+ * @return boolean
+ */
+ public function providesChanges()
+ {
+ return ! $this->rowsetExists()
+ || ! $this->lastRowsetIs($this->rowsetChecksum());
+ }
+
+ /**
+ * Trigger an import run
+ *
+ * @return int Last import run ID
+ */
+ public function run()
+ {
+ if ($this->providesChanges() && ! $this->rowsetExists()) {
+ $this->storeRowset();
+ }
+
+ $this->db->insert(
+ 'import_run',
+ array(
+ 'source_id' => $this->source->get('id'),
+ 'rowset_checksum' => $this->quoteBinary($this->rowsetChecksum()),
+ 'start_time' => date('Y-m-d H:i:s'),
+ 'succeeded' => 'y'
+ )
+ );
+ if ($this->connection->isPgsql()) {
+ return $this->db->lastInsertId('import_run', 'id');
+ } else {
+ return $this->db->lastInsertId();
+ }
+ }
+
+ /**
+ * Whether there are no rows to be fetched from import source
+ *
+ * @return boolean
+ */
+ public function isEmpty()
+ {
+ $rows = $this->checksummedRows();
+ return empty($rows);
+ }
+
+ /**
+ * Checksum of all available rows
+ *
+ * @return string
+ */
+ protected function & rowsetChecksum()
+ {
+ if ($this->rowsetChecksum === null) {
+ $this->prepareChecksummedRows();
+ }
+
+ return $this->rowsetChecksum;
+ }
+
+ /**
+ * All rows
+ *
+ * @return array
+ */
+ protected function & checksummedRows()
+ {
+ if ($this->rows === null) {
+ $this->prepareChecksummedRows();
+ }
+
+ return $this->rows;
+ }
+
+ /**
+ * Checksum of all available rows
+ *
+ * @return array
+ */
+ protected function & rawData()
+ {
+ if ($this->data === null) {
+ $this->data = ImportSourceHook::forImportSource(
+ $this->source
+ )->fetchData();
+ Benchmark::measure('Fetched all data from Import Source');
+ $this->source->applyModifiers($this->data);
+ Benchmark::measure('Applied Property Modifiers to imported data');
+ }
+
+ return $this->data;
+ }
+
+ /**
+ * Prepare and remember an ImportedProperty
+ *
+ * @param string $key
+ * @param mixed $rawValue
+ *
+ * @return array
+ */
+ protected function prepareImportedProperty($key, $rawValue)
+ {
+ if (is_array($rawValue) || is_bool($rawValue) || is_int($rawValue) || is_float($rawValue)) {
+ $value = json_encode($rawValue);
+ $format = 'json';
+ } elseif ($rawValue instanceof stdClass) {
+ $value = json_encode($this->sortObject($rawValue));
+ $format = 'json';
+ } else {
+ $value = $rawValue;
+ $format = 'string';
+ }
+
+ $checksum = sha1(sprintf('%s=(%s)%s', $key, $format, $value), true);
+
+ if (! array_key_exists($checksum, $this->properties)) {
+ $this->properties[$checksum] = array(
+ 'checksum' => $this->quoteBinary($checksum),
+ 'property_name' => $key,
+ 'property_value' => $value,
+ 'format' => $format
+ );
+ }
+
+ return $this->properties[$checksum];
+ }
+
+ /**
+ * Walk through each row, prepare properties and calculate checksums
+ */
+ protected function prepareChecksummedRows()
+ {
+ $keyColumn = $this->source->get('key_column');
+ $this->rows = array();
+ $this->rowProperties = array();
+ $objects = array();
+ $rowCount = 0;
+
+ foreach ($this->rawData() as $row) {
+ $rowCount++;
+
+ // Key column must be set
+ if (! isset($row->$keyColumn)) {
+ throw new IcingaException(
+ 'No key column "%s" in row %d',
+ $keyColumn,
+ $rowCount
+ );
+ }
+
+ $object_name = $row->$keyColumn;
+
+ // Check for name collision
+ if (array_key_exists($object_name, $objects)) {
+ throw new IcingaException(
+ 'Duplicate entry: %s',
+ $object_name
+ );
+ }
+
+ $rowChecksums = array();
+ $keys = array_keys((array) $row);
+ sort($keys);
+
+ foreach ($keys as $key) {
+ // TODO: Specify how to treat NULL values. Ignoring for now.
+ // One option might be to import null (checksum '(null)')
+ // and to provide a flag at sync time
+ if ($row->$key === null) {
+ continue;
+ }
+
+ $property = $this->prepareImportedProperty($key, $row->$key);
+ $rowChecksums[] = $property['checksum'];
+ }
+
+ $checksum = sha1($object_name . ';' . implode(';', $rowChecksums), true);
+ if (array_key_exists($checksum, $this->rows)) {
+ die('WTF, collision?');
+ }
+
+ $this->rows[$checksum] = array(
+ 'checksum' => $this->quoteBinary($checksum),
+ 'object_name' => $object_name
+ );
+
+ $this->rowProperties[$checksum] = $rowChecksums;
+
+ $objects[$object_name] = $checksum;
+ }
+
+ $this->rowChecksums = array_keys($this->rows);
+ $this->rowsetChecksum = sha1(implode(';', $this->rowChecksums), true);
+ return $this;
+ }
+
+ /**
+ * Store our new rowset
+ */
+ protected function storeRowset()
+ {
+ $db = $this->db;
+ $rowset = $this->rowsetChecksum();
+ $rows = $this->checksummedRows();
+
+ $db->beginTransaction();
+
+ try {
+ if ($this->isEmpty()) {
+ $newRows = array();
+ $newProperties = array();
+ } else {
+ $newRows = $this->newChecksums('imported_row', $this->rowChecksums);
+ $newProperties = $this->newChecksums('imported_property', array_keys($this->properties));
+ }
+
+ $db->insert('imported_rowset', array('checksum' => $this->quoteBinary($rowset)));
+
+ foreach ($newProperties as $checksum) {
+ $db->insert('imported_property', $this->properties[$checksum]);
+ }
+
+ foreach ($newRows as $row) {
+ try {
+ $db->insert('imported_row', $rows[$row]);
+ foreach ($this->rowProperties[$row] as $property) {
+ $db->insert('imported_row_property', array(
+ 'row_checksum' => $this->quoteBinary($row),
+ 'property_checksum' => $property
+ ));
+ }
+ } catch (Exception $e) {
+ throw new IcingaException(
+ "Error while storing a row for '%s' into database: %s",
+ $rows[$row]['object_name'],
+ $e->getMessage()
+ );
+ }
+ }
+
+ foreach (array_keys($rows) as $row) {
+ $db->insert(
+ 'imported_rowset_row',
+ array(
+ 'rowset_checksum' => $this->quoteBinary($rowset),
+ 'row_checksum' => $this->quoteBinary($row)
+ )
+ );
+ }
+
+ $db->commit();
+
+ $this->rowsetExists = true;
+ } catch (Exception $e) {
+ try {
+ $db->rollBack();
+ } catch (Exception $e) {
+ // Well...
+ }
+ // Eventually throws details for invalid UTF8 characters
+ RecursiveUtf8Validator::validateRows($this->data);
+ throw $e;
+ }
+ }
+
+ /**
+ * Whether the last run of this import matches the given checksum
+ *
+ * @param string $checksum Binary checksum
+ *
+ * @return bool
+ */
+ protected function lastRowsetIs($checksum)
+ {
+ return $this->connection->getLatestImportedChecksum($this->source->get('id'))
+ === bin2hex($checksum);
+ }
+
+ /**
+ * Whether our rowset already exists in the database
+ *
+ * @return boolean
+ */
+ protected function rowsetExists()
+ {
+ if (null === $this->rowsetExists) {
+ $this->rowsetExists = 0 === count(
+ $this->newChecksums(
+ 'imported_rowset',
+ array($this->rowsetChecksum())
+ )
+ );
+ }
+
+ return $this->rowsetExists;
+ }
+
+ /**
+ * Finde new checksums for a specific table
+ *
+ * Accepts an array of checksums and gives you an array with those checksums
+ * that are missing in the given table
+ *
+ * @param string $table Database table name
+ * @param array $checksums Array with the checksums that should be verified
+ *
+ * @return array
+ */
+ protected function newChecksums($table, $checksums)
+ {
+ $db = $this->db;
+
+ // TODO: The following is a quickfix for binary data corrpution reported
+ // in https://github.com/zendframework/zf1/issues/655 caused by
+ // https://github.com/zendframework/zf1/commit/2ac9c30f
+ //
+ // Should be reverted once fixed, eventually with a check continueing
+ // to use this workaround for specific ZF versions (1.12.16 and 1.12.17
+ // so far). Alternatively we could also use a custom quoteInto method.
+
+ // The former query looked as follows:
+ //
+ // $query = $db->select()->from($table, 'checksum')
+ // ->where('checksum IN (?)', $checksums)
+ // ...
+ // return array_diff($checksums, $existing);
+
+ $hexed = array_map('bin2hex', $checksums);
+
+ $conn = $this->connection;
+ $query = $db
+ ->select()
+ ->from(
+ array('c' => $table),
+ array('checksum' => $conn->dbHexFunc('c.checksum'))
+ )->where(
+ $conn->dbHexFunc('c.checksum') . ' IN (?)',
+ $hexed
+ );
+
+ $existing = $db->fetchCol($query);
+ $new = array_diff($hexed, $existing);
+
+ return array_map('hex2bin', $new);
+ }
+
+ /**
+ * Sort a given stdClass object by property name
+ *
+ * @param stdClass $object
+ *
+ * @return object
+ */
+ protected function sortObject($object)
+ {
+ $array = (array) $object;
+ foreach ($array as $key => $val) {
+ $this->sortElement($val);
+ }
+ ksort($array);
+ return (object) $array;
+ }
+
+ /**
+ * Walk through a given array and sort all children
+ *
+ * Please note that the array itself will NOT be sorted, as arrays must
+ * keep their ordering
+ *
+ * @param array $array
+ */
+ protected function sortArrayObject(&$array)
+ {
+ foreach ($array as $key => $val) {
+ $this->sortElement($val);
+ }
+ }
+
+ /**
+ * Recursively sort a given property
+ *
+ * @param mixed $el
+ */
+ protected function sortElement(&$el)
+ {
+ if (is_array($el)) {
+ $this->sortArrayObject($el);
+ } elseif ($el instanceof stdClass) {
+ $el = $this->sortObject($el);
+ }
+ }
+
+ protected function quoteBinary($bin)
+ {
+ return $this->connection->quoteBinary($bin);
+ }
+}
diff --git a/library/Director/Import/ImportSourceCoreApi.php b/library/Director/Import/ImportSourceCoreApi.php
new file mode 100644
index 0000000..6d590ec
--- /dev/null
+++ b/library/Director/Import/ImportSourceCoreApi.php
@@ -0,0 +1,92 @@
+<?php
+
+namespace Icinga\Module\Director\Import;
+
+use Icinga\Application\Config;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Hook\ImportSourceHook;
+use Icinga\Module\Director\Web\Form\QuickForm;
+
+class ImportSourceCoreApi extends ImportSourceHook
+{
+ protected $connection;
+
+ protected $db;
+
+ protected $api;
+
+ public function fetchData()
+ {
+ $func = 'get' . $this->getSetting('object_type') . 'Objects';
+ $objects = $this->api()->$func();
+ $result = array();
+ foreach ($objects as $object) {
+ $result[] = $object->toPlainObject();
+ }
+
+ return $result;
+ }
+
+ public function listColumns()
+ {
+ $res = $this->fetchData();
+ if (empty($data)) {
+ return array('object_name');
+ }
+
+ return array_keys((array) $res[0]);
+ }
+
+ public static function getDefaultKeyColumnName()
+ {
+ return 'object_name';
+ }
+
+ public static function addSettingsFormFields(QuickForm $form)
+ {
+ $form->addElement('select', 'object_type', array(
+ 'label' => 'Object type',
+ 'required' => true,
+ 'multiOptions' => $form->optionalEnum(self::enumObjectTypes($form))
+ ));
+ }
+
+ protected static function enumObjectTypes($form)
+ {
+ $types = array(
+ 'CheckCommand' => $form->translate('Check Commands'),
+ 'NotificationCommand' => $form->translate('Notification Commands'),
+ 'Endpoint' => $form->translate('Endpoints'),
+ 'Host' => $form->translate('Hosts'),
+ 'HostGroup' => $form->translate('Hostgroups'),
+ 'User' => $form->translate('Users'),
+ 'UserGroup' => $form->translate('Usergroups'),
+ 'Zone' => $form->translate('Zones'),
+ );
+
+ asort($types);
+ return $types;
+ }
+
+ protected function api()
+ {
+ if ($this->api === null) {
+ $endpoint = $this->db()->getDeploymentEndpoint();
+ $this->api = $endpoint->api()->setDb($this->db());
+ }
+
+ return $this->api;
+ }
+
+ protected function db()
+ {
+ if ($this->db === null) {
+ $resourceName = Config::module('director')->get('db', 'resource');
+ if ($resourceName) {
+ $this->db = Db::fromResourceName($resourceName);
+ }
+ }
+
+ return $this->db;
+ }
+}
diff --git a/library/Director/Import/ImportSourceDirectorObject.php b/library/Director/Import/ImportSourceDirectorObject.php
new file mode 100644
index 0000000..e3f56fc
--- /dev/null
+++ b/library/Director/Import/ImportSourceDirectorObject.php
@@ -0,0 +1,120 @@
+<?php
+
+namespace Icinga\Module\Director\Import;
+
+use Icinga\Application\Config;
+use Icinga\Module\Director\Data\Db\DbObjectTypeRegistry;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Forms\ImportSourceForm;
+use Icinga\Module\Director\Hook\ImportSourceHook;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Icinga\Module\Director\Util;
+use Icinga\Module\Director\Web\Form\QuickForm;
+
+class ImportSourceDirectorObject extends ImportSourceHook
+{
+ protected $db;
+
+ public function getName()
+ {
+ return 'Director Objects';
+ }
+
+ public static function getDefaultKeyColumnName()
+ {
+ return 'object_name';
+ }
+
+ public function fetchData()
+ {
+ $db = $this->db();
+ $objectClass = $this->getSetting('object_class');
+ $objectType = $this->getSetting('object_type');
+ /** @var IcingaObject $class fake type hint, it's a string */
+ $class = DbObjectTypeRegistry::classByType($objectClass);
+ if ($objectType) {
+ $dummy = $class::create();
+ $query = $db->getDbAdapter()->select()
+ ->from($dummy->getTableName())
+ ->where('object_type = ?', $objectType);
+ } else {
+ $query = null;
+ }
+ $result = [];
+ $resolved = $this->getSetting('resolved') === 'y';
+ foreach ($class::loadAllByType($objectClass, $db, $query) as $object) {
+ $result[] = $object->toPlainObject($resolved);
+ }
+ if ($objectClass === 'zone') {
+ $this->enrichZonesWithDeploymentZone($result);
+ }
+ return $result;
+ }
+
+ protected function enrichZonesWithDeploymentZone(&$zones)
+ {
+ $masterZone = $this->db()->getMasterZoneName();
+ foreach ($zones as $zone) {
+ $zone->is_master_zone = $zone->object_name === $masterZone;
+ }
+ }
+
+ public static function addSettingsFormFields(QuickForm $form)
+ {
+ /** @var ImportSourceForm $form */
+ Util::addDbResourceFormElement($form, 'resource');
+ $form->getElement('resource')
+ ->setValue(Config::module('director')->get('db', 'resource'));
+ $form->addElement('select', 'object_class', [
+ 'label' => $form->translate('Director Object'),
+ 'multiOptions' => [
+ 'host' => $form->translate('Host'),
+ 'endpoint' => $form->translate('Endpoint'),
+ 'zone' => $form->translate('Zone'),
+ ],
+ 'required' => true,
+ ]);
+ $form->addElement('select', 'object_type', [
+ 'label' => $form->translate('Object Type'),
+ 'multiOptions' => [
+ null => $form->translate('All Object Types'),
+ 'object' => $form->translate('Objects'),
+ 'template' => $form->translate('Templates'),
+ 'external_object' => $form->translate('External Objects'),
+ 'apply' => $form->translate('Apply Rules'),
+ ],
+ ]);
+
+ /** @var $form \Icinga\Module\Director\Web\Form\DirectorObjectForm */
+ $form->addBoolean('resolved', [
+ 'label' => $form->translate('Resolved'),
+ ], 'n');
+
+ return $form;
+ }
+
+ protected function db()
+ {
+ if ($this->db === null) {
+ $this->db = Db::fromResourceName($this->settings['resource']);
+ }
+
+ return $this->db;
+ }
+
+ public function listColumns()
+ {
+ $rows = $this->fetchData();
+ $columns = [];
+
+ foreach ($rows as $object) {
+ foreach (array_keys((array) $object) as $column) {
+ if (! isset($columns[$column])) {
+ $columns[] = $column;
+ }
+ }
+ }
+
+ return $columns;
+ }
+}
diff --git a/library/Director/Import/ImportSourceLdap.php b/library/Director/Import/ImportSourceLdap.php
new file mode 100644
index 0000000..4518565
--- /dev/null
+++ b/library/Director/Import/ImportSourceLdap.php
@@ -0,0 +1,90 @@
+<?php
+
+namespace Icinga\Module\Director\Import;
+
+use Icinga\Data\ResourceFactory;
+use Icinga\Module\Director\Hook\ImportSourceHook;
+use Icinga\Module\Director\Util;
+use Icinga\Module\Director\Web\Form\QuickForm;
+
+class ImportSourceLdap extends ImportSourceHook
+{
+ protected $connection;
+
+ public function fetchData()
+ {
+ $columns = $this->listColumns();
+ $query = $this->connection()
+ ->select()
+ ->setUsePagedResults()
+ ->from($this->settings['objectclass'], $columns);
+
+ if ($base = $this->settings['base']) {
+ $query->setBase($base);
+ }
+ if ($filter = $this->settings['filter']) {
+ $query->setNativeFilter($filter);
+ }
+
+ if (in_array('dn', $columns)) {
+ $result = $query->fetchAll();
+ foreach ($result as $dn => $row) {
+ $row->dn = $dn;
+ }
+
+ return $result;
+ } else {
+ return $query->fetchAll();
+ }
+ }
+
+ public function listColumns()
+ {
+ return preg_split('/,\s*/', $this->settings['query'], -1, PREG_SPLIT_NO_EMPTY);
+ }
+
+ public static function addSettingsFormFields(QuickForm $form)
+ {
+ Util::addLDAPResourceFormElement($form, 'resource');
+ $form->addElement('text', 'base', array(
+ 'label' => $form->translate('LDAP Search Base'),
+ 'description' => $form->translate(
+ 'Your LDAP search base. Often something like OU=Users,OU=HQ,DC=your,DC=company,DC=tld'
+ )
+ ));
+ $form->addElement('text', 'objectclass', array(
+ 'label' => $form->translate('Object class'),
+ 'description' => $form->translate(
+ 'An object class to search for. Might be "user", "group", "computer" or similar'
+ )
+ ));
+ $form->addElement('text', 'filter', array(
+ 'label' => 'LDAP filter',
+ 'description' => $form->translate(
+ 'A custom LDAP filter to use in addition to the object class. This allows'
+ . ' for a lot of flexibility but requires LDAP filter skills. Simple filters'
+ . ' might look as follows: operatingsystem=*server*'
+ )
+ ));
+ $form->addElement('textarea', 'query', array(
+ 'label' => $form->translate('Properties'),
+ 'description' => $form->translate(
+ 'The LDAP properties that should be fetched. This is required to be a'
+ . ' comma-separated list like: "cn, dnshostname, operatingsystem, sAMAccountName"'
+ ),
+ 'spellcheck' => 'false',
+ 'required' => true,
+ 'rows' => 5,
+ ));
+ return $form;
+ }
+
+ protected function connection()
+ {
+ if ($this->connection === null) {
+ $this->connection = ResourceFactory::create($this->settings['resource']);
+ }
+
+ return $this->connection;
+ }
+}
diff --git a/library/Director/Import/ImportSourceRestApi.php b/library/Director/Import/ImportSourceRestApi.php
new file mode 100644
index 0000000..dc772e1
--- /dev/null
+++ b/library/Director/Import/ImportSourceRestApi.php
@@ -0,0 +1,380 @@
+<?php
+
+namespace Icinga\Module\Director\Import;
+
+use Icinga\Exception\InvalidPropertyException;
+use Icinga\Module\Director\Hook\ImportSourceHook;
+use Icinga\Module\Director\RestApi\RestApiClient;
+use Icinga\Module\Director\Web\Form\QuickForm;
+use InvalidArgumentException;
+
+class ImportSourceRestApi extends ImportSourceHook
+{
+ public function getName()
+ {
+ return 'REST API';
+ }
+
+ public function fetchData()
+ {
+ $result = $this->getRestApi()->get(
+ $this->getUrl(),
+ null,
+ $this->buildHeaders()
+ );
+ $result = $this->extractProperty($result);
+
+ return (array) $result;
+ }
+
+ public function listColumns()
+ {
+ $rows = $this->fetchData();
+ $columns = [];
+
+ foreach ($rows as $object) {
+ foreach (array_keys((array) $object) as $column) {
+ if (! isset($columns[$column])) {
+ $columns[] = $column;
+ }
+ }
+ }
+
+ return $columns;
+ }
+
+ /**
+ * Extract result from a property specified
+ *
+ * A simple key, like "objects", will take the final result from key objects
+ *
+ * If you have a deeper key like "objects" under the key "results", specify this as "results.objects".
+ *
+ * When a key of the JSON object contains a literal ".", this can be escaped as
+ *
+ * @param $result
+ *
+ * @return mixed
+ */
+ protected function extractProperty($result)
+ {
+ $property = $this->getSetting('extract_property');
+ if (! $property) {
+ return $result;
+ }
+
+ $parts = preg_split('~(?<!\\\\)\.~', $property);
+
+ // iterate over parts of the attribute path
+ $data = $result;
+ foreach ($parts as $part) {
+ // un-escape any dots
+ $part = preg_replace('~\\\\.~', '.', $part);
+
+ if (property_exists($data, $part)) {
+ $data = $data->$part;
+ } else {
+ throw new \RuntimeException(sprintf(
+ 'Result has no "%s" property. Available keys: %s',
+ $part,
+ implode(', ', array_keys((array) $data))
+ ));
+ }
+ }
+
+ return $data;
+ }
+
+ protected function buildHeaders()
+ {
+ $headers = [];
+
+ $text = $this->getSetting('headers', '');
+ foreach (preg_split('~\r?\n~', $text, -1, PREG_SPLIT_NO_EMPTY) as $header) {
+ $header = trim($header);
+ $parts = preg_split('~\s*:\s*~', $header, 2);
+ if (count($parts) < 2) {
+ throw new InvalidPropertyException('Could not parse header: "%s"', $header);
+ }
+
+ $headers[$parts[0]] = $parts[1];
+ }
+
+ return $headers;
+ }
+
+ /**
+ * @param QuickForm $form
+ * @throws \Zend_Form_Exception
+ */
+ public static function addSettingsFormFields(QuickForm $form)
+ {
+ static::addScheme($form);
+ static::addSslOptions($form);
+ static::addUrl($form);
+ static::addResultProperty($form);
+ static::addAuthentication($form);
+ static::addHeader($form);
+ static::addProxy($form);
+ }
+
+ /**
+ * @param QuickForm $form
+ * @throws \Zend_Form_Exception
+ */
+ protected static function addScheme(QuickForm $form)
+ {
+ $form->addElement('select', 'scheme', [
+ 'label' => $form->translate('Protocol'),
+ 'description' => $form->translate(
+ 'Whether to use encryption when talking to the REST API'
+ ),
+ 'multiOptions' => [
+ 'HTTPS' => $form->translate('HTTPS (strongly recommended)'),
+ 'HTTP' => $form->translate('HTTP (this is plaintext!)'),
+ ],
+ 'class' => 'autosubmit',
+ 'value' => 'HTTPS',
+ 'required' => true,
+ ]);
+ }
+
+ /**
+ * @param QuickForm $form
+ * @throws \Zend_Form_Exception
+ */
+ protected static function addHeader(QuickForm $form)
+ {
+ $form->addElement('textarea', 'headers', [
+ 'label' => $form->translate('HTTP Header'),
+ 'description' => implode(' ', [
+ $form->translate('Additional headers for the HTTP request.'),
+ $form->translate('Specify headers in text format "Header: Value", each header on a new line.'),
+ ]),
+ 'class' => 'preformatted',
+ 'rows' => 4,
+ ]);
+ }
+
+ /**
+ * @param QuickForm $form
+ * @throws \Zend_Form_Exception
+ */
+ protected static function addSslOptions(QuickForm $form)
+ {
+ $ssl = ! ($form->getSentOrObjectSetting('scheme', 'HTTPS') === 'HTTP');
+
+ if ($ssl) {
+ static::addBoolean($form, 'ssl_verify_peer', [
+ 'label' => $form->translate('Verify Peer'),
+ 'description' => $form->translate(
+ 'Whether we should check that our peer\'s certificate has'
+ . ' been signed by a trusted CA. This is strongly recommended.'
+ )
+ ], 'y');
+ static::addBoolean($form, 'ssl_verify_host', [
+ 'label' => $form->translate('Verify Host'),
+ 'description' => $form->translate(
+ 'Whether we should check that the certificate matches the'
+ . 'configured host'
+ )
+ ], 'y');
+ }
+ }
+
+ /**
+ * @param QuickForm $form
+ * @throws \Zend_Form_Exception
+ */
+ protected static function addUrl(QuickForm $form)
+ {
+ $form->addElement('text', 'url', [
+ 'label' => 'REST API URL',
+ 'description' => $form->translate(
+ 'Something like https://api.example.com/rest/v2/objects'
+ ),
+ 'required' => true,
+ ]);
+ }
+
+ /**
+ * @param QuickForm $form
+ * @throws \Zend_Form_Exception
+ */
+ protected static function addResultProperty(QuickForm $form)
+ {
+ $form->addElement('text', 'extract_property', [
+ 'label' => 'Extract property',
+ 'description' => implode("\n", [
+ $form->translate('Often the expected result is provided in a property like "objects".'
+ . ' Please specify this if required.'),
+ $form->translate('Also deeper keys can be specific by a dot-notation:'),
+ '"result.objects", "key.deeper_key.very_deep"',
+ $form->translate('Literal dots in a key name can be written in the escape notation:'),
+ '"key\.with\.dots"',
+ ])
+ ]);
+ }
+
+ /**
+ * @param QuickForm $form
+ * @throws \Zend_Form_Exception
+ */
+ protected static function addAuthentication(QuickForm $form)
+ {
+ $form->addElement('text', 'username', [
+ 'label' => $form->translate('Username'),
+ 'description' => $form->translate(
+ 'Will be used to authenticate against your REST API'
+ ),
+ ]);
+
+ $form->addElement('storedPassword', 'password', [
+ 'label' => $form->translate('Password'),
+ ]);
+ }
+
+ /**
+ * @param QuickForm $form
+ * @throws \Zend_Form_Exception
+ */
+ protected static function addProxy(QuickForm $form)
+ {
+ $form->addElement('select', 'proxy_type', [
+ 'label' => $form->translate('Proxy'),
+ 'description' => $form->translate(
+ 'In case your API is only reachable through a proxy, please'
+ . ' choose it\'s protocol right here'
+ ),
+ 'multiOptions' => $form->optionalEnum([
+ 'HTTP' => $form->translate('HTTP proxy'),
+ 'SOCKS5' => $form->translate('SOCKS5 proxy'),
+ ]),
+ 'class' => 'autosubmit'
+ ]);
+
+ $proxyType = $form->getSentOrObjectSetting('proxy_type');
+
+ if ($proxyType) {
+ $form->addElement('text', 'proxy', [
+ 'label' => $form->translate('Proxy Address'),
+ 'description' => $form->translate(
+ 'Hostname, IP or <host>:<port>'
+ ),
+ 'required' => true,
+ ]);
+ if ($proxyType === 'HTTP') {
+ $form->addElement('text', 'proxy_user', [
+ 'label' => $form->translate('Proxy Username'),
+ 'description' => $form->translate(
+ 'In case your proxy requires authentication, please'
+ . ' configure this here'
+ ),
+ ]);
+
+ $passRequired = strlen($form->getSentOrObjectSetting('proxy_user')) > 0;
+
+ $form->addElement('storedPassword', 'proxy_pass', [
+ 'label' => $form->translate('Proxy Password'),
+ 'required' => $passRequired
+ ]);
+ }
+ }
+ }
+
+ protected function getUrl()
+ {
+ $url = $this->getSetting('url');
+ $parts = \parse_url($url);
+ if (isset($parts['path'])) {
+ $path = $parts['path'];
+ } else {
+ $path = '/';
+ }
+
+ if (isset($parts['query'])) {
+ $url = "$path?" . $parts['query'];
+ } else {
+ $url = $path;
+ }
+
+ return $url;
+ }
+
+ protected function getRestApi()
+ {
+ $url = $this->getSetting('url');
+ $parts = \parse_url($url);
+ if (isset($parts['host'])) {
+ $host = $parts['host'];
+ } else {
+ throw new InvalidArgumentException("URL '$url' has no host");
+ }
+
+ $api = new RestApiClient(
+ $host,
+ $this->getSetting('username'),
+ $this->getSetting('password')
+ );
+
+ $api->setScheme($this->getSetting('scheme'));
+ if (isset($parts['port'])) {
+ $api->setPort($parts['port']);
+ }
+
+ if ($api->getScheme() === 'HTTPS') {
+ if ($this->getSetting('ssl_verify_peer', 'y') === 'n') {
+ $api->disableSslPeerVerification();
+ }
+ if ($this->getSetting('ssl_verify_host', 'y') === 'n') {
+ $api->disableSslHostVerification();
+ }
+ }
+
+ if ($proxy = $this->getSetting('proxy')) {
+ if ($proxyType = $this->getSetting('proxy_type')) {
+ $api->setProxy($proxy, $proxyType);
+ } else {
+ $api->setProxy($proxy);
+ }
+
+ if ($user = $this->getSetting('proxy_user')) {
+ $api->setProxyAuth($user, $this->getSetting('proxy_pass'));
+ }
+ }
+
+ return $api;
+ }
+
+ /**
+ * @param QuickForm $form
+ * @param string $key
+ * @param array $options
+ * @param string|null $default
+ * @throws \Zend_Form_Exception
+ */
+ protected static function addBoolean(QuickForm $form, $key, $options, $default = null)
+ {
+ if ($default === null) {
+ $form->addElement('OptionalYesNo', $key, $options);
+ } else {
+ $form->addElement('YesNo', $key, $options);
+ $form->getElement($key)->setValue($default);
+ }
+ }
+
+ /**
+ * @param QuickForm $form
+ * @param string $key
+ * @param string $label
+ * @param string $description
+ * @throws \Zend_Form_Exception
+ */
+ protected static function optionalBoolean(QuickForm $form, $key, $label, $description)
+ {
+ static::addBoolean($form, $key, [
+ 'label' => $label,
+ 'description' => $description
+ ]);
+ }
+}
diff --git a/library/Director/Import/ImportSourceSql.php b/library/Director/Import/ImportSourceSql.php
new file mode 100644
index 0000000..b08a3f3
--- /dev/null
+++ b/library/Director/Import/ImportSourceSql.php
@@ -0,0 +1,70 @@
+<?php
+
+namespace Icinga\Module\Director\Import;
+
+use gipfl\Web\Widget\Hint;
+use Icinga\Data\Db\DbConnection;
+use Icinga\Module\Director\Forms\ImportSourceForm;
+use Icinga\Module\Director\Hook\ImportSourceHook;
+use Icinga\Module\Director\Objects\ImportSource;
+use Icinga\Module\Director\Util;
+use Icinga\Module\Director\Web\Form\Filter\QueryColumnsFromSql;
+use Icinga\Module\Director\Web\Form\QuickForm;
+use ipl\Html\Html;
+
+class ImportSourceSql extends ImportSourceHook
+{
+ protected $db;
+
+ public function fetchData()
+ {
+ return $this->db()->fetchAll($this->settings['query']);
+ }
+
+ public function listColumns()
+ {
+ if ($columns = $this->getSetting('column_cache')) {
+ return explode(', ', $columns);
+ } else {
+ return array_keys((array) current($this->fetchData()));
+ }
+ }
+
+ public static function addSettingsFormFields(QuickForm $form)
+ {
+ /** @var ImportSourceForm $form */
+ Util::addDbResourceFormElement($form, 'resource');
+ /** @var ImportSource $current */
+ $current = $form->getObject();
+
+ $form->addElement('textarea', 'query', [
+ 'label' => $form->translate('DB Query'),
+ 'required' => true,
+ 'rows' => 15,
+ ]);
+ $form->addElement('hidden', 'column_cache', [
+ 'value' => '',
+ 'filters' => [new QueryColumnsFromSql($form)],
+ 'required' => true
+ ]);
+ if ($current) {
+ if ($columns = $current->getSetting('column_cache')) {
+ $form->addHtmlHint('Columns: ' . $columns);
+ } else {
+ $form->addHtmlHint(Hint::warning($form->translate(
+ 'Please click "Store" once again to determine query columns'
+ )));
+ }
+ }
+ return $form;
+ }
+
+ protected function db()
+ {
+ if ($this->db === null) {
+ $this->db = DbConnection::fromResourceName($this->settings['resource'])->getDbAdapter();
+ }
+
+ return $this->db;
+ }
+}
diff --git a/library/Director/Import/PurgeStrategy/ImportRunBasedPurgeStrategy.php b/library/Director/Import/PurgeStrategy/ImportRunBasedPurgeStrategy.php
new file mode 100644
index 0000000..9f0e8ab
--- /dev/null
+++ b/library/Director/Import/PurgeStrategy/ImportRunBasedPurgeStrategy.php
@@ -0,0 +1,90 @@
+<?php
+
+namespace Icinga\Module\Director\Import\PurgeStrategy;
+
+use Icinga\Module\Director\Import\SyncUtils;
+use Icinga\Module\Director\Objects\ImportRun;
+use Icinga\Module\Director\Objects\ImportSource;
+
+class ImportRunBasedPurgeStrategy extends PurgeStrategy
+{
+ public function listObjectsToPurge()
+ {
+ $remove = array();
+
+ foreach ($this->getSyncRule()->fetchInvolvedImportSources() as $source) {
+ $remove += $this->checkImportSource($source);
+ }
+
+ return $remove;
+ }
+
+ protected function getLastSync()
+ {
+ return strtotime($this->getSyncRule()->getLastSyncTimestamp());
+ }
+
+ // TODO: NAMING!
+ protected function checkImportSource(ImportSource $source)
+ {
+ if (null === ($lastSync = $this->getLastSync())) {
+ // No last sync, nothing to purge
+ return array();
+ }
+
+ $runA = $source->fetchLastRunBefore($lastSync);
+ if ($runA === null) {
+ // Nothing to purge for this source
+ return array();
+ }
+
+ $runB = $source->fetchLastRun();
+ if ($runA->rowset_checksum === $runB->rowset_checksum) {
+ // Same source data, nothing to purge
+ return array();
+ }
+
+ return $this->listKeysRemovedBetween($runA, $runB);
+ }
+
+ public function listKeysRemovedBetween(ImportRun $runA, ImportRun $runB)
+ {
+ $rule = $this->getSyncRule();
+ $db = $rule->getDb();
+
+ $selectA = $runA->prepareImportedObjectQuery();
+ $selectB = $runB->prepareImportedObjectQuery();
+
+ $query = $db->select()->from(
+ array('a' => $selectA),
+ 'a.object_name'
+ )->where('a.object_name NOT IN (?)', $selectB);
+
+ $result = $db->fetchCol($query);
+
+ if (empty($result)) {
+ return array();
+ }
+
+ if ($rule->hasCombinedKey()) {
+ $pattern = $rule->getSourceKeyPattern();
+ $columns = SyncUtils::getRootVariables(
+ SyncUtils::extractVariableNames($pattern)
+ );
+ $resultForCombinedKey = array();
+ foreach (array_chunk($result, 1000) as $keys) {
+ $rows = $runA->fetchRows($columns, null, $keys);
+ foreach ($rows as $row) {
+ $resultForCombinedKey[] = SyncUtils::fillVariables($pattern, $row);
+ }
+ }
+ $result = $resultForCombinedKey;
+ }
+
+ if (empty($result)) {
+ return array();
+ }
+
+ return array_combine($result, $result);
+ }
+}
diff --git a/library/Director/Import/PurgeStrategy/PurgeNothingPurgeStrategy.php b/library/Director/Import/PurgeStrategy/PurgeNothingPurgeStrategy.php
new file mode 100644
index 0000000..3da8d4f
--- /dev/null
+++ b/library/Director/Import/PurgeStrategy/PurgeNothingPurgeStrategy.php
@@ -0,0 +1,11 @@
+<?php
+
+namespace Icinga\Module\Director\Import\PurgeStrategy;
+
+class PurgeNothingPurgeStrategy extends PurgeStrategy
+{
+ public function listObjectsToPurge()
+ {
+ return array();
+ }
+}
diff --git a/library/Director/Import/PurgeStrategy/PurgeStrategy.php b/library/Director/Import/PurgeStrategy/PurgeStrategy.php
new file mode 100644
index 0000000..ffbe14f
--- /dev/null
+++ b/library/Director/Import/PurgeStrategy/PurgeStrategy.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Icinga\Module\Director\Import\PurgeStrategy;
+
+use Icinga\Module\Director\Objects\SyncRule;
+
+abstract class PurgeStrategy
+{
+ private $rule;
+
+ public function __construct(SyncRule $rule)
+ {
+ $this->rule = $rule;
+ }
+
+ protected function getSyncRule()
+ {
+ return $this->rule;
+ }
+
+ abstract public function listObjectsToPurge();
+
+ /**
+ * @return PurgeStrategy
+ */
+ public static function load($name, SyncRule $rule)
+ {
+ $class = __NAMESPACE__ . '\\' . $name . 'PurgeStrategy';
+ return new $class($rule);
+ }
+}
diff --git a/library/Director/Import/Sync.php b/library/Director/Import/Sync.php
new file mode 100644
index 0000000..8fea46c
--- /dev/null
+++ b/library/Director/Import/Sync.php
@@ -0,0 +1,942 @@
+<?php
+
+namespace Icinga\Module\Director\Import;
+
+use Exception;
+use Icinga\Application\Benchmark;
+use Icinga\Data\Filter\Filter;
+use Icinga\Module\Director\Application\MemoryLimit;
+use Icinga\Module\Director\Data\Db\DbObject;
+use Icinga\Module\Director\Data\Db\DbObjectStore;
+use Icinga\Module\Director\Data\Db\DbObjectTypeRegistry;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Db\Branch\BranchSupport;
+use Icinga\Module\Director\Db\Cache\PrefetchCache;
+use Icinga\Module\Director\Objects\HostGroupMembershipResolver;
+use Icinga\Module\Director\Objects\IcingaHost;
+use Icinga\Module\Director\Objects\IcingaHostGroup;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Icinga\Module\Director\Objects\ImportSource;
+use Icinga\Module\Director\Objects\IcingaService;
+use Icinga\Module\Director\Objects\SyncProperty;
+use Icinga\Module\Director\Objects\SyncRule;
+use Icinga\Module\Director\Objects\SyncRun;
+use Icinga\Exception\IcingaException;
+use Icinga\Module\Director\Repository\IcingaTemplateRepository;
+use InvalidArgumentException;
+use RuntimeException;
+
+class Sync
+{
+ /** @var SyncRule */
+ protected $rule;
+
+ /** @var Db */
+ protected $db;
+
+ /** @var array Related ImportSource objects */
+ protected $sources;
+
+ /** @var array Source columns we want to fetch from our sources */
+ protected $sourceColumns;
+
+ /** @var array Imported data */
+ protected $imported;
+
+ /** @var IcingaObject[] Objects to work with */
+ protected $objects;
+
+ /** @var array<mixed, array<int, string>> key => [property, property]*/
+ protected $setNull = [];
+
+ /** @var bool Whether we already prepared your sync */
+ protected $isPrepared = false;
+
+ /** @var bool Whether we applied strtolower() to existing object keys */
+ protected $usedLowerCasedKeys = false;
+
+ protected $modify = [];
+
+ protected $remove = [];
+
+ protected $create = [];
+
+ protected $errors = [];
+
+ /** @var SyncProperty[] */
+ protected $syncProperties;
+
+ protected $replaceVars = false;
+
+ protected $hasPropertyDisabled = false;
+
+ protected $serviceOverrideKeyName;
+
+ /**
+ * @var SyncRun
+ */
+ protected $run;
+
+ protected $runStartTime;
+
+ /** @var Filter[] */
+ protected $columnFilters = [];
+
+ /** @var HostGroupMembershipResolver|bool */
+ protected $hostGroupMembershipResolver;
+
+ /** @var ?DbObjectStore */
+ protected $store;
+
+ /**
+ * @param SyncRule $rule
+ * @param ?DbObjectStore $store
+ */
+ public function __construct(SyncRule $rule, DbObjectStore $store = null)
+ {
+ $this->rule = $rule;
+ $this->db = $rule->getConnection();
+ $this->store = $store;
+ }
+
+ /**
+ * Whether the given sync rule would apply modifications
+ *
+ * @return boolean
+ * @throws Exception
+ */
+ public function hasModifications()
+ {
+ return count($this->getExpectedModifications()) > 0;
+ }
+
+ /**
+ * Retrieve modifications a given SyncRule would apply
+ *
+ * @return array Array of IcingaObject elements
+ * @throws \Icinga\Exception\NotFoundError
+ * @throws \Icinga\Module\Director\Exception\DuplicateKeyException
+ */
+ public function getExpectedModifications()
+ {
+ $modified = [];
+ $objects = $this->prepare();
+ $updateOnly = $this->rule->get('update_policy') === 'update-only';
+ $allowCreate = ! $updateOnly;
+ foreach ($objects as $object) {
+ if ($object->hasBeenModified()) {
+ if ($allowCreate || $object->hasBeenLoadedFromDb()) {
+ $modified[] = $object;
+ }
+ } elseif (! $updateOnly && $object->shouldBeRemoved()) {
+ $modified[] = $object;
+ }
+ }
+
+ return $modified;
+ }
+
+ /**
+ * Transform the given value to an array
+ *
+ * @param array|string|null $value
+ *
+ * @return array
+ */
+ protected function wantArray($value)
+ {
+ if (is_array($value)) {
+ return $value;
+ } elseif ($value === null) {
+ return [];
+ } else {
+ return [$value];
+ }
+ }
+
+ /**
+ * Raise PHP resource limits
+ *
+ * @return self;
+ */
+ protected function raiseLimits()
+ {
+ MemoryLimit::raiseTo('1024M');
+ ini_set('max_execution_time', 0);
+
+ return $this;
+ }
+
+ /**
+ * Initialize run summary measurements
+ *
+ * @return self;
+ */
+ protected function startMeasurements()
+ {
+ $this->run = SyncRun::start($this->rule);
+ $this->runStartTime = microtime(true);
+ Benchmark::measure('Starting sync');
+ return $this;
+ }
+
+ /**
+ * Fetch the configured properties involved in this sync
+ *
+ * @return self
+ */
+ protected function fetchSyncProperties()
+ {
+ $this->syncProperties = $this->rule->getSyncProperties();
+ foreach ($this->syncProperties as $key => $prop) {
+ $destinationField = $prop->get('destination_field');
+ if ($destinationField === 'vars' && $prop->get('merge_policy') === 'override') {
+ $this->replaceVars = true;
+ }
+
+ if ($destinationField === 'disabled') {
+ $this->hasPropertyDisabled = true;
+ }
+
+ if ($prop->get('filter_expression') === null || strlen($prop->get('filter_expression')) === 0) {
+ continue;
+ }
+
+ $this->columnFilters[$key] = Filter::fromQueryString(
+ $prop->get('filter_expression')
+ );
+ }
+
+ return $this;
+ }
+
+ protected function rowMatchesPropertyFilter($row, $key)
+ {
+ if (!array_key_exists($key, $this->columnFilters)) {
+ return true;
+ }
+
+ return $this->columnFilters[$key]->matches($row);
+ }
+
+ /**
+ * Instantiates all related ImportSource objects
+ *
+ * @return self
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ protected function prepareRelatedImportSources()
+ {
+ $this->sources = [];
+ foreach ($this->syncProperties as $p) {
+ $id = $p->get('source_id');
+ if (! array_key_exists($id, $this->sources)) {
+ $this->sources[$id] = ImportSource::loadWithAutoIncId(
+ (int) $id,
+ $this->db
+ );
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Prepare the source columns we want to fetch
+ *
+ * @return self
+ */
+ protected function prepareSourceColumns()
+ {
+ // $fieldMap = [];
+ $this->sourceColumns = [];
+
+ foreach ($this->syncProperties as $p) {
+ $sourceId = $p->get('source_id');
+ if (! array_key_exists($sourceId, $this->sourceColumns)) {
+ $this->sourceColumns[$sourceId] = [];
+ }
+
+ foreach (SyncUtils::extractVariableNames($p->get('source_expression')) as $varname) {
+ $this->sourceColumns[$sourceId][$varname] = $varname;
+ // -> ? $fieldMap[
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Fetch latest imported data rows from all involved import sources
+ * @return Sync
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ protected function fetchImportedData()
+ {
+ Benchmark::measure('Begin loading imported data');
+ if ($this->rule->get('object_type') === 'host') {
+ $this->serviceOverrideKeyName = $this->db->settings()->override_services_varname;
+ }
+
+ $this->imported = [];
+
+ $sourceKeyPattern = $this->rule->getSourceKeyPattern();
+ $combinedKey = $this->rule->hasCombinedKey();
+
+ foreach ($this->sources as $source) {
+ /** @var ImportSource $source */
+ $sourceId = $source->get('id');
+
+ // Provide an alias column for our key. TODO: double-check this!
+ $key = $source->key_column;
+ $this->sourceColumns[$sourceId][$key] = $key;
+ $run = $source->fetchLastRun(true);
+
+ $usedColumns = SyncUtils::getRootVariables($this->sourceColumns[$sourceId]);
+
+ $filterColumns = [];
+ foreach ($this->columnFilters as $filter) {
+ foreach ($filter->listFilteredColumns() as $column) {
+ $filterColumns[$column] = $column;
+ }
+ }
+ if (($ruleFilter = $this->rule->filter()) !== null) {
+ foreach ($ruleFilter->listFilteredColumns() as $column) {
+ $filterColumns[$column] = $column;
+ }
+ }
+
+ if (! empty($filterColumns)) {
+ foreach (SyncUtils::getRootVariables($filterColumns) as $column) {
+ $usedColumns[$column] = $column;
+ }
+ }
+ Benchmark::measure(sprintf('Done pre-processing columns for source %s', $source->source_name));
+
+ $rows = $run->fetchRows($usedColumns);
+ Benchmark::measure(sprintf('Fetched source %s', $source->source_name));
+
+ $this->imported[$sourceId] = [];
+ foreach ($rows as $row) {
+ if ($combinedKey) {
+ $key = SyncUtils::fillVariables($sourceKeyPattern, $row);
+ if ($this->usedLowerCasedKeys) {
+ $key = strtolower($key);
+ }
+
+ if (array_key_exists($key, $this->imported[$sourceId])) {
+ throw new InvalidArgumentException(sprintf(
+ 'Trying to import row "%s" (%s) twice: %s VS %s',
+ $key,
+ $sourceKeyPattern,
+ json_encode($this->imported[$sourceId][$key]),
+ json_encode($row)
+ ));
+ }
+ } else {
+ if (! property_exists($row, $key)) {
+ throw new InvalidArgumentException(sprintf(
+ 'There is no key column "%s" in this row from "%s": %s',
+ $key,
+ $source->source_name,
+ json_encode($row)
+ ));
+ }
+ }
+
+ if (! $this->rule->matches($row)) {
+ continue;
+ }
+
+ if ($combinedKey) {
+ $this->imported[$sourceId][$key] = $row;
+ } else {
+ if ($this->usedLowerCasedKeys) {
+ $this->imported[$sourceId][strtolower($row->$key)] = $row;
+ } else {
+ $this->imported[$sourceId][$row->$key] = $row;
+ }
+ }
+ }
+
+ unset($rows);
+ }
+
+ Benchmark::measure('Done loading imported data');
+
+ return $this;
+ }
+
+ /**
+ * TODO: This is rubbish, we need to filter at fetch time
+ */
+ protected function removeForeignListEntries()
+ {
+ $listId = null;
+ foreach ($this->syncProperties as $prop) {
+ if ($prop->get('destination_field') === 'list_id') {
+ $listId = (int) $prop->get('source_expression');
+ }
+ }
+
+ if ($listId === null) {
+ throw new InvalidArgumentException(
+ 'Cannot sync datalist entry without list_id'
+ );
+ }
+
+ $no = [];
+ foreach ($this->objects as $k => $o) {
+ if ((int) $o->get('list_id') !== $listId) {
+ $no[] = $k;
+ }
+ }
+
+ foreach ($no as $k) {
+ unset($this->objects[$k]);
+ }
+ }
+
+ /**
+ * @return $this
+ */
+ protected function loadExistingObjects()
+ {
+ Benchmark::measure('Begin loading existing objects');
+
+ $ruleObjectType = $this->rule->get('object_type');
+ $useLowerCaseKeys = $ruleObjectType !== 'datalistEntry';
+ // TODO: Make object_type (template, object...) and object_name mandatory?
+ if ($this->rule->hasCombinedKey()) {
+ $this->objects = [];
+ $destinationKeyPattern = $this->rule->getDestinationKeyPattern();
+ $table = DbObjectTypeRegistry::tableNameByType($ruleObjectType);
+ if ($this->store && BranchSupport::existsForTableName($table)) {
+ $objects = $this->store->loadAll($table);
+ } else {
+ $objects = IcingaObject::loadAllByType($ruleObjectType, $this->db);
+ }
+
+ foreach ($objects as $object) {
+ if ($object instanceof IcingaService) {
+ if (strstr($destinationKeyPattern, '${host}')
+ && $object->get('host_id') === null
+ ) {
+ continue;
+ } elseif (strstr($destinationKeyPattern, '${service_set}')
+ && $object->get('service_set_id') === null
+ ) {
+ continue;
+ }
+ }
+
+ $key = SyncUtils::fillVariables(
+ $destinationKeyPattern,
+ $object
+ );
+ if ($useLowerCaseKeys) {
+ $key = strtolower($key);
+ }
+
+ if (array_key_exists($key, $this->objects)) {
+ throw new InvalidArgumentException(sprintf(
+ 'Combined destination key "%s" is not unique, got "%s" twice',
+ $destinationKeyPattern,
+ $key
+ ));
+ }
+
+ $this->objects[$key] = $object;
+ }
+ } else {
+ if ($this->store) {
+ $objects = $this->store->loadAll(DbObjectTypeRegistry::tableNameByType($ruleObjectType), 'object_name');
+ } else {
+ $objects = IcingaObject::loadAllByType($ruleObjectType, $this->db);
+ }
+
+ if ($useLowerCaseKeys) {
+ $this->objects = [];
+ foreach ($objects as $key => $object) {
+ $this->objects[strtolower($key)] = $object;
+ }
+ } else {
+ $this->objects = $objects;
+ }
+ }
+
+ $this->usedLowerCasedKeys = $useLowerCaseKeys;
+ // TODO: should be obsoleted by a better "loadFiltered" method
+ if ($ruleObjectType === 'datalistEntry') {
+ $this->removeForeignListEntries();
+ }
+
+ Benchmark::measure('Done loading existing objects');
+
+ return $this;
+ }
+
+ /**
+ * @return array
+ * @throws \Icinga\Exception\NotFoundError
+ * @throws \Icinga\Module\Director\Exception\DuplicateKeyException
+ */
+ protected function prepareNewObjects()
+ {
+ $objects = [];
+ $ruleObjectType = $this->rule->get('object_type');
+
+ foreach ($this->sources as $source) {
+ $sourceId = $source->id;
+ $keyColumn = $source->get('key_column');
+
+ foreach ($this->imported[$sourceId] as $key => $row) {
+ // Workaround: $a["10"] = "val"; -> array_keys($a) = [(int) 10]
+ $key = (string) $key;
+ $originalKey = $row->$keyColumn;
+ if ($this->usedLowerCasedKeys) {
+ $key = strtolower($key);
+ }
+ if (! array_key_exists($key, $objects)) {
+ // Safe default values for object_type and object_name
+ if ($ruleObjectType === 'datalistEntry') {
+ $props = [];
+ } else {
+ $props = [
+ 'object_type' => 'object',
+ 'object_name' => $originalKey,
+ ];
+ }
+
+ $objects[$key] = IcingaObject::createByType(
+ $ruleObjectType,
+ $props,
+ $this->db
+ );
+ }
+
+ $object = $objects[$key];
+ $this->prepareNewObject($row, $object, $key, $sourceId);
+ }
+ }
+
+ return $objects;
+ }
+
+ /**
+ * @param $row
+ * @param DbObject $object
+ * @param $sourceId
+ * @throws \Icinga\Exception\NotFoundError
+ * @throws \Icinga\Module\Director\Exception\DuplicateKeyException
+ */
+ protected function prepareNewObject($row, DbObject $object, $objectKey, $sourceId)
+ {
+ foreach ($this->syncProperties as $propertyKey => $p) {
+ if ($p->get('source_id') !== $sourceId) {
+ continue;
+ }
+
+ if (! $this->rowMatchesPropertyFilter($row, $propertyKey)) {
+ continue;
+ }
+
+ $prop = $p->get('destination_field');
+ $val = SyncUtils::fillVariables($p->get('source_expression'), $row);
+
+ if ($object instanceof IcingaObject) {
+ if ($prop === 'import') {
+ if ($val !== null) {
+ $object->imports()->add($val);
+ }
+ } elseif ($prop === 'groups') {
+ if ($val !== null) {
+ $object->groups()->add($val);
+ }
+ } elseif (substr($prop, 0, 5) === 'vars.') {
+ $varName = substr($prop, 5);
+ if (substr($varName, -2) === '[]') {
+ $varName = substr($varName, 0, -2);
+ $current = $this->wantArray($object->vars()->$varName);
+ $object->vars()->$varName = array_merge(
+ $current,
+ $this->wantArray($val)
+ );
+ } else {
+ if ($val === null) {
+ $this->setNull[$objectKey][$prop] = $prop;
+ } else {
+ unset($this->setNull[$objectKey][$prop]);
+ $object->vars()->$varName = $val;
+ }
+ }
+ } else {
+ if ($val === null) {
+ $this->setNull[$objectKey][$prop] = $prop;
+ } else {
+ unset($this->setNull[$objectKey][$prop]);
+ $object->set($prop, $val);
+ }
+ }
+ } else {
+ if ($val === null) {
+ $this->setNull[$objectKey][$prop] = $prop;
+ } else {
+ unset($this->setNull[$objectKey][$prop]);
+ $object->set($prop, $val);
+ }
+ }
+ }
+ }
+
+ /**
+ * @return $this
+ */
+ protected function deferResolvers()
+ {
+ if (in_array($this->rule->get('object_type'), ['host', 'hostgroup'])) {
+ $resolver = $this->getHostGroupMembershipResolver();
+ $resolver->defer()->setUseTransactions(false);
+ }
+
+ return $this;
+ }
+
+ /**
+ * @param DbObject $object
+ * @return $this
+ */
+ protected function setResolver($object)
+ {
+ if (! ($object instanceof IcingaHost || $object instanceof IcingaHostGroup)) {
+ return $this;
+ }
+ if ($resolver = $this->getHostGroupMembershipResolver()) {
+ $object->setHostGroupMembershipResolver($resolver);
+ }
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ * @throws \Zend_Db_Adapter_Exception
+ */
+ protected function notifyResolvers()
+ {
+ if ($resolver = $this->getHostGroupMembershipResolver()) {
+ $resolver->refreshDb(true);
+ }
+
+ return $this;
+ }
+
+ /**
+ * @return bool|HostGroupMembershipResolver
+ */
+ protected function getHostGroupMembershipResolver()
+ {
+ if ($this->hostGroupMembershipResolver === null) {
+ if (in_array(
+ $this->rule->get('object_type'),
+ ['host', 'hostgroup']
+ )) {
+ $this->hostGroupMembershipResolver = new HostGroupMembershipResolver(
+ $this->db
+ );
+ } else {
+ $this->hostGroupMembershipResolver = false;
+ }
+ }
+
+ return $this->hostGroupMembershipResolver;
+ }
+
+ /**
+ * Evaluates a SyncRule and returns a list of modified objects
+ *
+ * TODO: Split this into smaller methods
+ *
+ * @return DbObject|IcingaObject[] List of modified IcingaObjects
+ * @throws \Icinga\Exception\NotFoundError
+ * @throws \Icinga\Module\Director\Exception\DuplicateKeyException
+ */
+ protected function prepare()
+ {
+ if ($this->isPrepared) {
+ return $this->objects;
+ }
+
+ $this->raiseLimits()
+ ->startMeasurements()
+ ->prepareCache()
+ ->fetchSyncProperties()
+ ->prepareRelatedImportSources()
+ ->prepareSourceColumns()
+ ->loadExistingObjects()
+ ->fetchImportedData()
+ ->deferResolvers();
+
+ Benchmark::measure('Begin preparing updated objects');
+ $newObjects = $this->prepareNewObjects();
+
+ Benchmark::measure('Ready to process objects');
+ /** @var DbObject|IcingaObject $object */
+ foreach ($newObjects as $key => $object) {
+ $this->processObject($key, $object);
+ }
+
+ Benchmark::measure('Modified objects are ready, applying purge strategy');
+ $noAction = [];
+ $purgeAction = $this->rule->get('purge_action');
+ foreach ($this->rule->purgeStrategy()->listObjectsToPurge() as $key) {
+ $key = strtolower($key);
+ if (array_key_exists($key, $newObjects)) {
+ // Object has been touched, do not delete
+ continue;
+ }
+
+ if (array_key_exists($key, $this->objects)) {
+ $object = $this->objects[$key];
+ if (! $object->hasBeenModified()) {
+ switch ($purgeAction) {
+ case 'delete':
+ $object->markForRemoval();
+ break;
+ case 'disable':
+ $object->set('disabled', 'y');
+ break;
+ default:
+ throw new RuntimeException(
+ "Unsupported purge action: '$purgeAction'"
+ );
+ }
+ }
+ }
+ }
+
+ Benchmark::measure('Done marking objects for purge');
+
+ foreach ($this->objects as $key => $object) {
+ if (! $object->hasBeenModified() && ! $object->shouldBeRemoved()) {
+ $noAction[] = $key;
+ }
+ }
+
+ foreach ($noAction as $key) {
+ unset($this->objects[$key]);
+ }
+
+ $this->isPrepared = true;
+
+ Benchmark::measure('Done preparing objects');
+
+ return $this->objects;
+ }
+
+ /**
+ * @param $key
+ * @param DbObject|IcingaObject $object
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ protected function processObject($key, $object)
+ {
+ if (array_key_exists($key, $this->objects)) {
+ $this->refreshObject($key, $object);
+ } else {
+ $this->addNewObject($key, $object);
+ }
+ }
+
+ /**
+ * @param $key
+ * @param DbObject|IcingaObject $object
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ protected function refreshObject($key, $object)
+ {
+ $policy = $this->rule->get('update_policy');
+
+ switch ($policy) {
+ case 'override':
+ if ($object instanceof IcingaHost
+ && !in_array('api_key', $this->rule->getSyncProperties())
+ ) {
+ $this->objects[$key]->replaceWith($object, ['api_key']);
+ } else {
+ $this->objects[$key]->replaceWith($object);
+ }
+ break;
+
+ case 'merge':
+ case 'update-only':
+ // TODO: re-evaluate merge settings. vars.x instead of
+ // just "vars" might suffice.
+ $this->objects[$key]->merge($object, $this->replaceVars);
+ if (! $this->hasPropertyDisabled && $object->hasProperty('disabled')) {
+ $this->objects[$key]->resetProperty('disabled');
+ }
+ break;
+
+ default:
+ // policy 'ignore', no action
+ }
+
+ if ($policy === 'override' || $policy === 'merge') {
+ if ($object instanceof IcingaHost) {
+ $keyName = $this->serviceOverrideKeyName;
+ if (! $object->hasInitializedVars() || ! isset($object->vars()->$key)) {
+ $this->objects[$key]->vars()->restoreStoredVar($keyName);
+ }
+ }
+ }
+
+ if (isset($this->setNull[$key])) {
+ foreach ($this->setNull[$key] as $property) {
+ $this->objects[$key]->set($property, null);
+ }
+ }
+ }
+
+ /**
+ * @param $key
+ * @param DbObject|IcingaObject $object
+ */
+ protected function addNewObject($key, $object)
+ {
+ $this->objects[$key] = $object;
+ }
+
+ /**
+ * Runs a SyncRule and applies all resulting changes
+ * @return int
+ * @throws Exception
+ * @throws IcingaException
+ */
+ public function apply()
+ {
+ Benchmark::measure('Begin applying objects');
+
+ $objects = $this->prepare();
+ $db = $this->db;
+ $dba = $db->getDbAdapter();
+ if (! $this->store) { // store has it's own transaction
+ $dba->beginTransaction();
+ }
+
+ $object = null;
+ $updateOnly = $this->rule->get('update_policy') === 'update-only';
+ $allowCreate = ! $updateOnly;
+
+ try {
+ $formerActivityChecksum = hex2bin(
+ $db->getLastActivityChecksum()
+ );
+ $created = 0;
+ $modified = 0;
+ $deleted = 0;
+ // TODO: Count also failed ones, once we allow such
+ // $failed = 0;
+ foreach ($objects as $object) {
+ $this->setResolver($object);
+ if (! $updateOnly && $object->shouldBeRemoved()) {
+ if ($this->store) {
+ $this->store->delete($object);
+ } else {
+ $object->delete();
+ }
+ $deleted++;
+ continue;
+ }
+
+ if ($object->hasBeenModified()) {
+ $existing = $object->hasBeenLoadedFromDb();
+ if ($existing) {
+ if ($this->store) {
+ $this->store->store($object);
+ } else {
+ $object->store($db);
+ }
+ $modified++;
+ } elseif ($allowCreate) {
+ if ($this->store) {
+ $this->store->store($object);
+ } else {
+ $object->store($db);
+ }
+ $created++;
+ }
+ }
+ }
+
+ $runProperties = [
+ 'objects_created' => $created,
+ 'objects_deleted' => $deleted,
+ 'objects_modified' => $modified,
+ ];
+
+ if ($created + $deleted + $modified > 0) {
+ // TODO: What if this has been the very first activity?
+ $runProperties['last_former_activity'] = $db->quoteBinary($formerActivityChecksum);
+ $runProperties['last_related_activity'] = $db->quoteBinary(hex2bin(
+ $db->getLastActivityChecksum()
+ ));
+ }
+
+ $this->run->setProperties($runProperties);
+ if (!$this->store || !$this->store->getBranch()->isBranch()) {
+ $this->run->store();
+ }
+ $this->notifyResolvers();
+ if (! $this->store) {
+ $dba->commit();
+ }
+
+ // Store duration after commit, as the commit might take some time
+ $this->run->set('duration_ms', (int) round(
+ (microtime(true) - $this->runStartTime) * 1000
+ ));
+ if (!$this->store || !$this->store->getBranch()->isBranch()) {
+ $this->run->store();
+ }
+
+ Benchmark::measure('Done applying objects');
+ } catch (Exception $e) {
+ if (! $this->store) {
+ $dba->rollBack();
+ }
+
+ if ($object instanceof IcingaObject) {
+ throw new IcingaException(
+ 'Exception while syncing %s %s: %s',
+ get_class($object),
+ $object->getObjectName(),
+ $e->getMessage(),
+ $e
+ );
+ } else {
+ throw $e;
+ }
+ }
+
+ return $this->run->get('id');
+ }
+
+ protected function prepareCache()
+ {
+ if ($this->store) {
+ return $this;
+ }
+ PrefetchCache::initialize($this->db);
+ IcingaTemplateRepository::clear();
+
+ $ruleObjectType = $this->rule->get('object_type');
+
+ $dummy = IcingaObject::createByType($ruleObjectType);
+ if ($dummy instanceof IcingaObject) {
+ IcingaObject::prefetchAllRelationsByType($ruleObjectType, $this->db);
+ }
+
+ return $this;
+ }
+}
diff --git a/library/Director/Import/SyncUtils.php b/library/Director/Import/SyncUtils.php
new file mode 100644
index 0000000..5528b2d
--- /dev/null
+++ b/library/Director/Import/SyncUtils.php
@@ -0,0 +1,153 @@
+<?php
+
+namespace Icinga\Module\Director\Import;
+
+use Icinga\Module\Director\Data\Db\DbObject;
+use InvalidArgumentException;
+
+class SyncUtils
+{
+ /**
+ * Extract variable names in the form ${var_name} from a given string
+ *
+ * @param string $string
+ *
+ * @return array List of variable names (without ${})
+ */
+ public static function extractVariableNames($string)
+ {
+ if (preg_match_all('/\${([^}]+)}/', $string, $m, PREG_PATTERN_ORDER)) {
+ return $m[1];
+ } else {
+ return array();
+ }
+ }
+
+ /**
+ * Whether the given string contains variable names in the form ${var_name}
+ *
+ * @param string $string
+ *
+ * @return bool
+ */
+ public static function hasVariables($string)
+ {
+ return preg_match('/\${([^}]+)}/', $string);
+ }
+
+ /**
+ * Recursively extract a value from a nested structure
+ *
+ * For a $val looking like
+ *
+ * { 'vars' => { 'disk' => { 'sda' => { 'size' => '256G' } } } }
+ *
+ * and a key vars.disk.sda given as [ 'vars', 'disk', 'sda' ] this would
+ * return { size => '255GB' }
+ *
+ * @param string $val The value to extract data from
+ * @param array $keys A list of nested keys pointing to desired data
+ *
+ * @return mixed
+ */
+ public static function getDeepValue($val, array $keys)
+ {
+ $key = array_shift($keys);
+ if (! property_exists($val, $key)) {
+ return null;
+ }
+
+ if (empty($keys)) {
+ return $val->$key;
+ }
+
+ return static::getDeepValue($val->$key, $keys);
+ }
+
+ /**
+ * Return a specific value from a given row object
+ *
+ * Supports also keys pointing to nested structures like vars.disk.sda
+ *
+ * @param object $row stdClass object providing property values
+ * @param string $var Variable/property name
+ *
+ * @return mixed
+ */
+ public static function getSpecificValue($row, $var)
+ {
+ if (strpos($var, '.') === false) {
+ if ($row instanceof DbObject) {
+ return $row->$var;
+ }
+ if (! property_exists($row, $var)) {
+ return null;
+ }
+
+ return $row->$var;
+ } else {
+ $parts = explode('.', $var);
+ $main = array_shift($parts);
+ if (! property_exists($row, $main)) {
+ return null;
+ }
+ if ($row->$main === null) {
+ return null;
+ }
+
+ if (! is_object($row->$main)) {
+ throw new InvalidArgumentException(sprintf(
+ 'Data is not nested, cannot access %s: %s',
+ $var,
+ var_export($row, 1)
+ ));
+ }
+
+ return static::getDeepValue($row->$main, $parts);
+ }
+ }
+
+ /**
+ * Fill variables in the given string pattern
+ *
+ * This replaces all occurrences of ${var_name} with the corresponding
+ * property $row->var_name of the given row object. Missing variables are
+ * replaced by an empty string. This works also fine in case there are
+ * multiple variables to be found in your string.
+ *
+ * @param string $string String with optional variables/placeholders
+ * @param object $row stdClass object providing property values
+ *
+ * @return string
+ */
+ public static function fillVariables($string, $row)
+ {
+ if (preg_match('/^\${([^}]+)}$/', $string, $m)) {
+ return static::getSpecificValue($row, $m[1]);
+ }
+
+ $func = function ($match) use ($row) {
+ return SyncUtils::getSpecificValue($row, $match[1]);
+ };
+
+ return preg_replace_callback('/\${([^}]+)}/', $func, $string);
+ }
+
+ public static function getRootVariables($vars)
+ {
+ $res = array();
+ foreach ($vars as $p) {
+ if (false === ($pos = strpos($p, '.')) || $pos === strlen($p) - 1) {
+ $res[] = $p;
+ } else {
+ $res[] = substr($p, 0, $pos);
+ }
+ }
+
+ if (empty($res)) {
+ return array();
+ }
+
+ return array_combine($res, $res);
+ }
+}
diff --git a/library/Director/Job/ConfigJob.php b/library/Director/Job/ConfigJob.php
new file mode 100644
index 0000000..fda3043
--- /dev/null
+++ b/library/Director/Job/ConfigJob.php
@@ -0,0 +1,75 @@
+<?php
+
+namespace Icinga\Module\Director\Job;
+
+use Icinga\Module\Director\Deployment\ConditionalConfigRenderer;
+use Icinga\Module\Director\Deployment\ConditionalDeployment;
+use Icinga\Module\Director\Deployment\DeploymentGracePeriod;
+use Icinga\Module\Director\Hook\JobHook;
+use Icinga\Module\Director\Web\Form\QuickForm;
+
+class ConfigJob extends JobHook
+{
+ public function run()
+ {
+ $db = $this->db();
+ $deployer = new ConditionalDeployment($db);
+ $renderer = new ConditionalConfigRenderer($db);
+ if ($grace = $this->getSetting('grace_period')) {
+ $deployer->setGracePeriod(new DeploymentGracePeriod((int) $grace, $db));
+ }
+ if ($this->getSetting('force_generate') === 'y') {
+ $renderer->forceRendering();
+ }
+
+ $deployer->deploy($renderer->getConfig());
+ }
+
+ public static function addSettingsFormFields(QuickForm $form)
+ {
+ $form->addElement('select', 'force_generate', [
+ 'label' => $form->translate('Force rendering'),
+ 'description' => $form->translate(
+ 'Whether rendering should be forced. If not enforced, this'
+ . ' job re-renders the configuration only when there have been'
+ . ' activities since the last rendered config'
+ ),
+ 'value' => 'n',
+ 'multiOptions' => [
+ 'y' => $form->translate('Yes'),
+ 'n' => $form->translate('No'),
+ ]
+ ]);
+
+ $form->addElement('select', 'deploy_when_changed', [
+ 'label' => $form->translate('Deploy modified config'),
+ 'description' => $form->translate(
+ 'This allows you to immediately deploy a modified configuration'
+ ),
+ 'value' => 'n',
+ 'multiOptions' => [
+ 'y' => $form->translate('Yes'),
+ 'n' => $form->translate('No'),
+ ]
+ ]);
+
+ $form->addElement('text', 'grace_period', array(
+ 'label' => $form->translate('Grace period'),
+ 'description' => $form->translate(
+ 'When deploying configuration, wait at least this amount of'
+ . ' seconds unless the next deployment should take place'
+ ),
+ 'value' => 600,
+ ));
+
+ return $form;
+ }
+
+ public static function getDescription(QuickForm $form)
+ {
+ return $form->translate(
+ 'The Config job allows you to generate and eventually deploy your'
+ . ' Icinga 2 configuration'
+ );
+ }
+}
diff --git a/library/Director/Job/HousekeepingJob.php b/library/Director/Job/HousekeepingJob.php
new file mode 100644
index 0000000..9f3f596
--- /dev/null
+++ b/library/Director/Job/HousekeepingJob.php
@@ -0,0 +1,39 @@
+<?php
+
+namespace Icinga\Module\Director\Job;
+
+use Icinga\Module\Director\Db\Housekeeping;
+use Icinga\Module\Director\Hook\JobHook;
+use Icinga\Module\Director\Web\Form\QuickForm;
+
+class HousekeepingJob extends JobHook
+{
+ protected $housekeeping;
+
+ public function run()
+ {
+ $this->housekeeping()->runAllTasks();
+ }
+
+ public static function getDescription(QuickForm $form)
+ {
+ return $form->translate(
+ 'The Housekeeping job provides various task that keep your Director'
+ . ' database fast and clean'
+ );
+ }
+
+ public function isPending()
+ {
+ return $this->housekeeping()->hasPendingTasks();
+ }
+
+ protected function housekeeping()
+ {
+ if ($this->housekeeping === null) {
+ $this->housekeeping = new Housekeeping($this->db());
+ }
+
+ return $this->housekeeping;
+ }
+}
diff --git a/library/Director/Job/ImportJob.php b/library/Director/Job/ImportJob.php
new file mode 100644
index 0000000..5f2c81c
--- /dev/null
+++ b/library/Director/Job/ImportJob.php
@@ -0,0 +1,122 @@
+<?php
+
+namespace Icinga\Module\Director\Job;
+
+use Icinga\Module\Director\Hook\JobHook;
+use Icinga\Module\Director\Objects\ImportSource;
+use Icinga\Module\Director\Web\Form\DirectorObjectForm;
+use Icinga\Module\Director\Web\Form\QuickForm;
+
+class ImportJob extends JobHook
+{
+ /**
+ * @throws \Icinga\Exception\NotFoundError
+ * @throws \Icinga\Module\Director\Exception\DuplicateKeyException
+ */
+ public function run()
+ {
+ $db = $this->db();
+ $id = $this->getSetting('source_id');
+ if ($id === '__ALL__') {
+ foreach (ImportSource::loadAll($db) as $source) {
+ $this->runForSource($source);
+ }
+ } else {
+ $this->runForSource(ImportSource::loadWithAutoIncId($id, $db));
+ }
+ }
+
+ /**
+ * @return array
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function exportSettings()
+ {
+ $settings = parent::exportSettings();
+ if (array_key_exists('source_id', $settings)) {
+ $id = $settings['source_id'];
+ if ($id !== '__ALL__') {
+ $settings['source'] = ImportSource::loadWithAutoIncId(
+ $id,
+ $this->db()
+ )->get('source_name');
+ }
+
+ unset($settings['source_id']);
+ }
+
+ return $settings;
+ }
+
+ /**
+ * @param ImportSource $source
+ * @throws \Icinga\Module\Director\Exception\DuplicateKeyException
+ */
+ protected function runForSource(ImportSource $source)
+ {
+ if ($this->getSetting('run_import') === 'y') {
+ $source->runImport();
+ } else {
+ $source->checkForChanges();
+ }
+ }
+
+ public static function getDescription(QuickForm $form)
+ {
+ return $form->translate(
+ 'The "Import" job allows to run import actions at regular intervals'
+ );
+ }
+
+ /**
+ * @param QuickForm $form
+ * @throws \Zend_Form_Exception
+ */
+ public static function addSettingsFormFields(QuickForm $form)
+ {
+ $rules = self::enumImportSources($form);
+
+ $form->addElement('select', 'source_id', array(
+ 'label' => $form->translate('Import source'),
+ 'description' => $form->translate(
+ 'Please choose your import source that should be executed.'
+ . ' You could create different schedules for different sources'
+ . ' or also opt for running all of them at once.'
+ ),
+ 'required' => true,
+ 'class' => 'autosubmit',
+ 'multiOptions' => $rules
+ ));
+
+ $form->addElement('select', 'run_import', array(
+ 'label' => $form->translate('Run import'),
+ 'description' => $form->translate(
+ 'You could immediately apply eventual changes or just learn about them.'
+ . ' In case you do not want them to be applied immediately, defining a'
+ . ' job still makes sense. You will be made aware of available changes'
+ . ' in your Director GUI.'
+ ),
+ 'value' => 'n',
+ 'multiOptions' => array(
+ 'y' => $form->translate('Yes'),
+ 'n' => $form->translate('No'),
+ )
+ ));
+ }
+
+ protected static function enumImportSources(QuickForm $form)
+ {
+ /** @var DirectorObjectForm $form */
+ $db = $form->getDb();
+ $query = $db->select()->from(
+ 'import_source',
+ array('id', 'source_name')
+ )->order('source_name');
+
+ $res = $db->fetchPairs($query);
+ return array(
+ null => $form->translate('- please choose -'),
+ '__ALL__' => $form->translate('Run all imports at once')
+ ) + $res;
+ }
+}
diff --git a/library/Director/Job/SyncJob.php b/library/Director/Job/SyncJob.php
new file mode 100644
index 0000000..0a5aa37
--- /dev/null
+++ b/library/Director/Job/SyncJob.php
@@ -0,0 +1,128 @@
+<?php
+
+namespace Icinga\Module\Director\Job;
+
+use Icinga\Module\Director\Hook\JobHook;
+use Icinga\Module\Director\Web\Form\DirectorObjectForm;
+use Icinga\Module\Director\Web\Form\QuickForm;
+use Icinga\Module\Director\Objects\SyncRule;
+
+class SyncJob extends JobHook
+{
+ protected $rule;
+
+ /**
+ * @throws \Icinga\Exception\NotFoundError
+ * @throws \Icinga\Module\Director\Exception\DuplicateKeyException
+ */
+ public function run()
+ {
+ $db = $this->db();
+ $id = $this->getSetting('rule_id');
+ if ($id === '__ALL__') {
+ foreach (SyncRule::loadAll($db) as $rule) {
+ $this->runForRule($rule);
+ }
+ } else {
+ $this->runForRule(SyncRule::loadWithAutoIncId((int) $id, $db));
+ }
+ }
+
+ /**
+ * @return array
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function exportSettings()
+ {
+ $settings = [
+ 'apply_changes' => $this->getSetting('apply_changes') === 'y'
+ ];
+ $id = $this->getSetting('rule_id');
+ if ($id !== '__ALL__') {
+ $settings['rule'] = SyncRule::loadWithAutoIncId((int) $id, $this->db())
+ ->get('rule_name');
+ }
+
+ return $settings;
+ }
+
+ /**
+ * @param SyncRule $rule
+ * @throws \Icinga\Module\Director\Exception\DuplicateKeyException
+ */
+ protected function runForRule(SyncRule $rule)
+ {
+ if ($this->getSetting('apply_changes') === 'y') {
+ $rule->applyChanges();
+ } else {
+ $rule->checkForChanges();
+ }
+ }
+
+ public static function getDescription(QuickForm $form)
+ {
+ return $form->translate(
+ 'The "Sync" job allows to run sync actions at regular intervals'
+ );
+ }
+
+ /**
+ * @param QuickForm $form
+ * @return DirectorObjectForm|QuickForm
+ * @throws \Zend_Form_Exception
+ */
+ public static function addSettingsFormFields(QuickForm $form)
+ {
+ /** @var DirectorObjectForm $form */
+ $rules = self::enumSyncRules($form);
+
+ $form->addElement('select', 'rule_id', array(
+ 'label' => $form->translate('Synchronization rule'),
+ 'description' => $form->translate(
+ 'Please choose your synchronization rule that should be executed.'
+ . ' You could create different schedules for different rules or also'
+ . ' opt for running all of them at once.'
+ ),
+ 'required' => true,
+ 'class' => 'autosubmit',
+ 'multiOptions' => $rules
+ ));
+
+ $form->addElement('select', 'apply_changes', array(
+ 'label' => $form->translate('Apply changes'),
+ 'description' => $form->translate(
+ 'You could immediately apply eventual changes or just learn about them.'
+ . ' In case you do not want them to be applied immediately, defining a'
+ . ' job still makes sense. You will be made aware of available changes'
+ . ' in your Director GUI.'
+ ),
+ 'value' => 'n',
+ 'multiOptions' => array(
+ 'y' => $form->translate('Yes'),
+ 'n' => $form->translate('No'),
+ )
+ ));
+
+ if ((string) $form->getSentOrObjectValue('job_name') !== '') {
+ if (($ruleId = $form->getSentValue('rule_id')) && array_key_exists($ruleId, $rules)) {
+ $name = sprintf('Sync job: %s', $rules[$ruleId]);
+ $form->getElement('job_name')->setValue($name);
+ ///$form->getObject()->set('job_name', $name);
+ }
+ }
+
+ return $form;
+ }
+
+ protected static function enumSyncRules(QuickForm $form)
+ {
+ /** @var DirectorObjectForm $form */
+ $db = $form->getDb();
+ $query = $db->select()->from('sync_rule', array('id', 'rule_name'))->order('rule_name');
+ $res = $db->fetchPairs($query);
+ return array(
+ null => $form->translate('- please choose -'),
+ '__ALL__' => $form->translate('Run all rules at once')
+ ) + $res;
+ }
+}
diff --git a/library/Director/KickstartHelper.php b/library/Director/KickstartHelper.php
new file mode 100644
index 0000000..5010255
--- /dev/null
+++ b/library/Director/KickstartHelper.php
@@ -0,0 +1,555 @@
+<?php
+
+namespace Icinga\Module\Director;
+
+use Exception;
+use Icinga\Application\Config;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Module\Director\Exception\NestingError;
+use Icinga\Module\Director\Objects\IcingaApiUser;
+use Icinga\Module\Director\Objects\IcingaCommand;
+use Icinga\Module\Director\Objects\IcingaEndpoint;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Icinga\Module\Director\Objects\IcingaZone;
+use Icinga\Module\Director\Core\CoreApi;
+use Icinga\Module\Director\Core\RestApiClient;
+use RuntimeException;
+
+class KickstartHelper
+{
+ /** @var Db */
+ protected $db;
+
+ /** @var CoreApi */
+ protected $api;
+
+ /** @var IcingaApiUser */
+ protected $apiUser;
+
+ /** @var IcingaEndpoint */
+ protected $deploymentEndpoint;
+
+ /** @var IcingaEndpoint[] */
+ protected $loadedEndpoints;
+
+ /** @var IcingaEndpoint[] */
+ protected $removeEndpoints;
+
+ /** @var IcingaZone[] */
+ protected $loadedZones;
+
+ /** @var IcingaZone[] */
+ protected $removeZones;
+
+ /** @var IcingaCommand[] */
+ protected $loadedCommands;
+
+ /** @var IcingaCommand[] */
+ protected $removeCommands;
+
+ protected $config = [
+ 'endpoint' => null,
+ 'host' => null,
+ 'port' => null,
+ 'username' => null,
+ 'password' => null,
+ ];
+
+ /**
+ * KickstartHelper constructor.
+ * @param Db $db
+ */
+ public function __construct(Db $db)
+ {
+ $this->db = $db;
+ }
+
+ /**
+ * Trigger a complete kickstart run
+ */
+ public function run()
+ {
+ $this->fetchEndpoints()
+ ->reconnectToDeploymentEndpoint()
+ ->fetchZones()
+ ->fetchCommands()
+ ->storeZones()
+ ->storeEndpoints()
+ ->storeCommands()
+ ->removeEndpoints()
+ ->removeZones()
+ ->removeCommands();
+
+ $this->apiUser()->store();
+ }
+
+ /**
+ * @return bool
+ */
+ public function isConfigured()
+ {
+ $config = $this->fetchConfigFileSection();
+ return array_key_exists('endpoint', $config)
+ && array_key_exists('username', $config);
+ }
+
+ /**
+ * @return KickstartHelper
+ * @throws ProgrammingError
+ */
+ public function loadConfigFromFile()
+ {
+ return $this->setConfig($this->fetchConfigFileSection());
+ }
+
+ /**
+ * @return array
+ */
+ protected function fetchConfigFileSection()
+ {
+ return Config::module('director', 'kickstart')
+ ->getSection('config')
+ ->toArray();
+ }
+
+ /**
+ * @param array $config
+ * @return $this
+ * @throws ProgrammingError
+ */
+ public function setConfig($config)
+ {
+ foreach ($config as $key => $value) {
+ if ($value === '') {
+ continue;
+ }
+
+ if (! array_key_exists($key, $this->config)) {
+ throw new ProgrammingError(
+ '"%s" is not a valid config setting for the kickstart helper',
+ $key
+ );
+ }
+
+ $this->config[$key] = $value;
+ }
+
+ return $this;
+ }
+
+ /**
+ * @return bool
+ */
+ public function isRequired()
+ {
+ $stats = $this->db->getObjectSummary();
+ return (int) $stats['apiuser']->cnt_total === 0;
+ }
+
+ /**
+ * @param $key
+ * @param mixed $default
+ * @return mixed
+ */
+ protected function getValue($key, $default = null)
+ {
+ if ($this->config[$key] === null) {
+ return $default;
+ }
+
+ return $this->config[$key];
+ }
+
+ /**
+ * @return IcingaApiUser
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ protected function apiUser()
+ {
+ if ($this->apiUser === null) {
+ $name = $this->getValue('username');
+
+ $user = IcingaApiUser::create(array(
+ 'object_name' => $this->getValue('username'),
+ 'object_type' => 'external_object',
+ 'password' => $this->getValue('password')
+ ), $this->db);
+
+ if (IcingaApiUser::exists($name, $this->db)) {
+ $this->apiUser = IcingaApiUser::load($name, $this->db)->replaceWith($user);
+ } else {
+ $this->apiUser = $user;
+ }
+
+ $this->apiUser->store();
+ }
+
+ return $this->apiUser;
+ }
+
+ /**
+ * @param IcingaObject[] $objects
+ * @return IcingaObject[]
+ * @throws NestingError
+ */
+ protected function sortByParent(array $objects)
+ {
+ $sorted = array();
+
+ $cnt = 0;
+ while (! empty($objects)) {
+ $cnt++;
+ if ($cnt > 20) {
+ $this->throwObjectLoop($objects);
+ }
+
+ $unset = array();
+ foreach ($objects as $key => $object) {
+ $parentName = $object->get('parent');
+ if ($parentName === null || array_key_exists($parentName, $sorted)) {
+ $sorted[$object->getObjectName()] = $object;
+ $unset[] = $key;
+ }
+ }
+
+ foreach ($unset as $key) {
+ unset($objects[$key]);
+ }
+ }
+
+ return $sorted;
+ }
+
+ /**
+ * @param IcingaObject[] $objects
+ * @throws NestingError
+ */
+ protected function throwObjectLoop(array $objects)
+ {
+ $names = array();
+ if (empty($objects)) {
+ $class = 'Nothing';
+ } else {
+ $class = explode('/\\/', get_class(current($objects)))[0];
+ }
+
+ foreach ($objects as $object) {
+ $names[] = $object->getObjectName();
+ }
+
+ throw new NestingError(
+ 'Loop detected while resolving %s: %s',
+ $class,
+ implode(', ', $names)
+ );
+ }
+
+ /**
+ * @return $this
+ * @throws NestingError
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ protected function fetchZones()
+ {
+ $db = $this->db;
+ $this->loadedZones = $this->sortByParent(
+ $this->api()->setDb($db)->getZoneObjects()
+ );
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ * @throws \Icinga\Module\Director\Exception\DuplicateKeyException
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ protected function storeZones()
+ {
+ $db = $this->db;
+ $existing = IcingaObject::loadAllExternalObjectsByType('zone', $db);
+
+ foreach ($this->loadedZones as $name => $object) {
+ if (array_key_exists($name, $existing)) {
+ $object = $existing[$name]->replaceWith($object);
+ unset($existing[$name]);
+ }
+
+ $object->store();
+ }
+
+ $this->removeZones = $existing;
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ */
+ protected function removeZones()
+ {
+ return $this->removeObjects($this->removeEndpoints, 'External Zone');
+ }
+
+ /**
+ * @return $this
+ * @throws \Icinga\Module\Director\Exception\DuplicateKeyException
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ protected function fetchEndpoints()
+ {
+ $db = $this->db;
+ $this->loadedEndpoints = $this->api()->setDb($db)->getEndpointObjects();
+
+ $master = $this->getValue('endpoint');
+ if (array_key_exists($master, $this->loadedEndpoints)) {
+ $apiuser = $this->apiUser();
+ $apiuser->store();
+ $object = $this->loadedEndpoints[$master];
+ $object->apiuser = $apiuser->object_name;
+ $this->deploymentEndpoint = $object;
+ }
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ * @throws \Icinga\Exception\NotFoundError
+ * @throws \Icinga\Module\Director\Exception\DuplicateKeyException
+ */
+ protected function storeEndpoints()
+ {
+ $db = $this->db;
+ $existing = IcingaObject::loadAllExternalObjectsByType('endpoint', $db);
+
+ foreach ($this->loadedEndpoints as $name => $object) {
+ if (array_key_exists($name, $existing)) {
+ $object = $existing[$name]->replaceWith($object);
+ unset($existing[$name]);
+ }
+
+ $object->store();
+ }
+
+ $this->removeEndpoints = $existing;
+
+ $db->settings()->master_zone = $this->deploymentEndpoint->zone;
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ */
+ protected function removeEndpoints()
+ {
+ return $this->removeObjects($this->removeEndpoints, 'External Endpoint');
+ }
+
+ /**
+ * @return $this
+ * @throws ConfigurationError
+ */
+ protected function reconnectToDeploymentEndpoint()
+ {
+ $master = $this->getValue('endpoint');
+
+ if (! $this->deploymentEndpoint) {
+ throw new ConfigurationError(
+ 'I found no Endpoint object called "%s" on %s:%d',
+ $master,
+ $this->getHost(),
+ $this->getPort()
+ );
+ }
+
+ $ep = $this->deploymentEndpoint;
+
+ $epHost = $ep->get('host');
+ if (! $epHost) {
+ $epHost = $ep->getObjectName();
+ }
+
+ try {
+ $this->switchToDeploymentApi()->getStatus();
+ } catch (Exception $e) {
+ throw new ConfigurationError(
+ 'I was unable to re-establish a connection to the Endpoint "%s" (%s:%d).'
+ . ' When reconnecting to the configured Endpoint (%s:%d) I get an error: %s'
+ . ' Please re-check your Icinga 2 endpoint configuration',
+ $master,
+ $this->getHost(),
+ $this->getPort(),
+ $epHost,
+ $ep->get('port'),
+ $e->getMessage()
+ );
+ }
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ protected function fetchCommands()
+ {
+ $api = $this->api()->setDb($this->db);
+ $this->loadedCommands = array_merge(
+ $api->getSpecificCommandObjects('Check'),
+ $api->getSpecificCommandObjects('Notification'),
+ $api->getSpecificCommandObjects('Event')
+ );
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ * @throws \Icinga\Exception\NotFoundError
+ * @throws \Icinga\Module\Director\Exception\DuplicateKeyException
+ */
+ protected function storeCommands()
+ {
+ $db = $this->db;
+ $existing = IcingaObject::loadAllExternalObjectsByType('command', $db);
+
+ foreach ($this->loadedCommands as $name => $object) {
+ if (array_key_exists($name, $existing)) {
+ $object = $existing[$name]->replaceWith($object);
+ unset($existing[$name]);
+ }
+
+ $object->store();
+ }
+
+ $this->removeCommands = $existing;
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ */
+ protected function removeCommands()
+ {
+ return $this->removeObjects($this->removeCommands, 'External Command');
+ }
+
+ protected function removeObjects(array $objects, $typeName)
+ {
+ foreach ($objects as $object) {
+ try {
+ $object->delete();
+ } catch (Exception $e) {
+ throw new RuntimeException(sprintf(
+ "Failed to remove %s '%s', it's eventually still in use",
+ $typeName,
+ $object->getObjectName()
+ ), 0, $e);
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * @param Db $db
+ * @return $this
+ */
+ public function setDb(Db $db)
+ {
+ $this->db = $db;
+ return $this;
+ }
+
+ /**
+ * @return string
+ */
+ protected function getHost()
+ {
+ return $this->getValue('host', $this->getValue('endpoint'));
+ }
+
+ /**
+ * @return int
+ */
+ protected function getPort()
+ {
+ return (int) $this->getValue('port', 5665);
+ }
+
+ /**
+ * @return CoreApi
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ protected function getDeploymentApi()
+ {
+ unset($this->api);
+ $ep = $this->deploymentEndpoint;
+
+ $epHost = $ep->get('host');
+ if (!$epHost) {
+ $epHost = $ep->object_name;
+ }
+
+ $client = new RestApiClient(
+ $epHost,
+ $ep->get('port')
+ );
+
+ $apiuser = $this->apiUser();
+ $client->setCredentials($apiuser->object_name, $apiuser->password);
+
+ $api = new CoreApi($client);
+ $api->setDb($this->db);
+
+ return $api;
+ }
+
+ /**
+ * @return CoreApi
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ protected function getConfiguredApi()
+ {
+ unset($this->api);
+ $client = new RestApiClient(
+ $this->getHost(),
+ $this->getPort()
+ );
+
+ $apiuser = $this->apiUser();
+ $client->setCredentials($apiuser->object_name, $apiuser->password);
+
+ $api = new CoreApi($client);
+ $api->setDb($this->db);
+
+ return $api;
+ }
+
+ /**
+ * @return CoreApi
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ protected function switchToDeploymentApi()
+ {
+ return $this->api = $this->getDeploymentApi();
+ }
+
+ /**
+ * @return CoreApi
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ protected function api()
+ {
+ if ($this->api === null) {
+ $this->api = $this->getConfiguredApi();
+ }
+
+ return $this->api;
+ }
+}
diff --git a/library/Director/Monitoring.php b/library/Director/Monitoring.php
new file mode 100644
index 0000000..f5d4108
--- /dev/null
+++ b/library/Director/Monitoring.php
@@ -0,0 +1,149 @@
+<?php
+
+namespace Icinga\Module\Director;
+
+use Icinga\Application\Icinga;
+use Icinga\Authentication\Auth;
+use Icinga\Data\Filter\Filter;
+use Icinga\Module\Monitoring\Backend\MonitoringBackend;
+
+class Monitoring
+{
+ protected $backend;
+
+ public function __construct()
+ {
+ $app = Icinga::app();
+ $modules = $app->getModuleManager();
+ if (!$modules->hasLoaded('monitoring') && $app->isCli()) {
+ $app->getModuleManager()->loadEnabledModules();
+ }
+
+ if ($modules->hasLoaded('monitoring')) {
+ $this->backend = MonitoringBackend::instance();
+ }
+ }
+
+ public function isAvailable()
+ {
+ return $this->backend !== null;
+ }
+
+ public function hasHost($hostname)
+ {
+ return $this->backend->select()->from('hostStatus', [
+ 'hostname' => 'host_name',
+ ])->where('host_name', $hostname)->fetchOne() === $hostname;
+ }
+
+ public function hasService($hostname, $service)
+ {
+ return (array) $this->prepareServiceKeyColumnQuery($hostname, $service)->fetchRow() === [
+ 'hostname' => $hostname,
+ 'service' => $service,
+ ];
+ }
+
+ public function authCanEditHost(Auth $auth, $hostname)
+ {
+ if ($auth->hasPermission('director/monitoring/hosts')) {
+ $restriction = null;
+ foreach ($auth->getRestrictions('director/monitoring/rw-object-filter') as $restriction) {
+ if ($this->hasHostWithExtraFilter($hostname, Filter::fromQueryString($restriction))) {
+ return true;
+ }
+ }
+ if ($restriction === null) {
+ return $this->hasHost($hostname);
+ }
+ }
+
+ return false;
+ }
+
+ public function authCanEditService(Auth $auth, $hostname, $service)
+ {
+ if ($hostname === null || $service === null) {
+ // TODO: UUID support!
+ return false;
+ }
+ if ($auth->hasPermission('director/monitoring/services')) {
+ $restriction = null;
+ foreach ($auth->getRestrictions('director/monitoring/rw-object-filter') as $restriction) {
+ if ($this->hasServiceWithExtraFilter($hostname, $service, Filter::fromQueryString($restriction))) {
+ return true;
+ }
+ }
+ if ($restriction === null) {
+ return $this->hasService($hostname, $service);
+ }
+ }
+
+ return false;
+ }
+
+ public function hasHostWithExtraFilter($hostname, Filter $filter)
+ {
+ return $this->backend->select()->from('hostStatus', [
+ 'hostname' => 'host_name',
+ ])->where('host_name', $hostname)->applyFilter($filter)->fetchOne() === $hostname;
+ }
+
+ public function hasServiceWithExtraFilter($hostname, $service, Filter $filter)
+ {
+ return (array) $this
+ ->prepareServiceKeyColumnQuery($hostname, $service)
+ ->applyFilter($filter)
+ ->fetchRow() === [
+ 'hostname' => $hostname,
+ 'service' => $service,
+ ];
+ }
+
+ public function getHostState($hostname)
+ {
+ $hostStates = [
+ '0' => 'up',
+ '1' => 'down',
+ '2' => 'unreachable',
+ '99' => 'pending',
+ ];
+
+ $query = $this->backend->select()->from('hostStatus', [
+ 'hostname' => 'host_name',
+ 'state' => 'host_state',
+ 'problem' => 'host_problem',
+ 'acknowledged' => 'host_acknowledged',
+ 'in_downtime' => 'host_in_downtime',
+ 'output' => 'host_output',
+ ])->where('host_name', $hostname);
+
+ $res = $query->fetchRow();
+ if ($res === false) {
+ $res = (object) [
+ 'hostname' => $hostname,
+ 'state' => '99',
+ 'problem' => '0',
+ 'acknowledged' => '0',
+ 'in_downtime' => '0',
+ 'output' => null,
+ ];
+ }
+
+ $res->state = $hostStates[$res->state];
+
+ return $res;
+ }
+
+ protected function prepareServiceKeyColumnQuery($hostname, $service)
+ {
+ return $this->backend
+ ->select()
+ ->from('serviceStatus', [
+ 'hostname' => 'host_name',
+ 'service' => 'service_description',
+ ])
+ ->where('host_name', $hostname)
+ ->where('service_description', $service);
+ }
+}
diff --git a/library/Director/Objects/DirectorActivityLog.php b/library/Director/Objects/DirectorActivityLog.php
new file mode 100644
index 0000000..cb041b6
--- /dev/null
+++ b/library/Director/Objects/DirectorActivityLog.php
@@ -0,0 +1,232 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Icinga\Module\Director\Data\Db\DbObject;
+use Icinga\Module\Director\Db;
+use Icinga\Authentication\Auth;
+use Icinga\Application\Icinga;
+use Icinga\Application\Logger;
+
+class DirectorActivityLog extends DbObject
+{
+ const ACTION_CREATE = 'create';
+ const ACTION_DELETE = 'delete';
+ const ACTION_MODIFY = 'modify';
+
+ /** @deprecated */
+ const AUDIT_REMOVE = 'remove';
+
+ protected $table = 'director_activity_log';
+
+ protected $keyName = 'id';
+
+ protected $autoincKeyName = 'id';
+
+ protected $defaultProperties = [
+ 'id' => null,
+ 'object_name' => null,
+ 'action_name' => null,
+ 'object_type' => null,
+ 'old_properties' => null,
+ 'new_properties' => null,
+ 'author' => null,
+ 'change_time' => null,
+ 'checksum' => null,
+ 'parent_checksum' => null,
+ ];
+
+ protected $binaryProperties = [
+ 'checksum',
+ 'parent_checksum'
+ ];
+
+ /** @var ?string */
+ protected static $overriddenUsername = null;
+
+ /**
+ * @param $name
+ *
+ * @codingStandardsIgnoreStart
+ *
+ * @return self
+ */
+ protected function setObject_Name($name)
+ {
+ // @codingStandardsIgnoreEnd
+
+ if ($name === null) {
+ $name = '';
+ }
+
+ return $this->reallySet('object_name', $name);
+ }
+
+ public static function username()
+ {
+ if (self::$overriddenUsername) {
+ return self::$overriddenUsername;
+ }
+
+ if (Icinga::app()->isCli()) {
+ return 'cli';
+ }
+
+ $auth = Auth::getInstance();
+ if ($auth->isAuthenticated()) {
+ return $auth->getUser()->getUsername();
+ } elseif (array_key_exists('HTTP_X_FORWARDED_FOR', $_SERVER)) {
+ return '<' . $_SERVER['HTTP_X_FORWARDED_FOR'] . '>';
+ } elseif (array_key_exists('REMOTE_ADDR', $_SERVER)) {
+ return '<' . $_SERVER['REMOTE_ADDR'] . '>';
+ } else {
+ return '<unknown>';
+ }
+ }
+
+ protected static function ip()
+ {
+ if (Icinga::app()->isCli()) {
+ return 'cli';
+ }
+
+ if (array_key_exists('REMOTE_ADDR', $_SERVER)) {
+ return $_SERVER['REMOTE_ADDR'];
+ } else {
+ return '0.0.0.0';
+ }
+ }
+
+ /**
+ * @param Db $connection
+ * @return DirectorActivityLog
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public static function loadLatest(Db $connection)
+ {
+ $db = $connection->getDbAdapter();
+ $query = $db->select()->from('director_activity_log', ['id' => 'MAX(id)']);
+
+ return static::load($db->fetchOne($query), $connection);
+ }
+
+ public static function logCreation(IcingaObject $object, Db $db)
+ {
+ // TODO: extend this to support non-IcingaObjects and multikey objects
+ $name = $object->getObjectName();
+ $type = $object->getTableName();
+ $newProps = $object->toJson(null, true);
+
+ $data = [
+ 'object_name' => $name,
+ 'action_name' => self::ACTION_CREATE,
+ 'author' => static::username(),
+ 'object_type' => $type,
+ 'new_properties' => $newProps,
+ 'change_time' => date('Y-m-d H:i:s'),
+ 'parent_checksum' => $db->getLastActivityChecksum()
+ ];
+
+ $data['checksum'] = sha1(json_encode($data), true);
+ $data['parent_checksum'] = hex2bin($data['parent_checksum']);
+
+ static::audit($db, [
+ 'action' => self::ACTION_CREATE,
+ 'object_type' => $type,
+ 'object_name' => $name,
+ 'new_props' => $newProps,
+ ]);
+
+ return static::create($data)->store($db);
+ }
+
+ public static function logModification(IcingaObject $object, Db $db)
+ {
+ $name = $object->getObjectName();
+ $type = $object->getTableName();
+ $oldProps = json_encode($object->getPlainUnmodifiedObject());
+ $newProps = $object->toJson(null, true);
+
+ $data = [
+ 'object_name' => $name,
+ 'action_name' => self::ACTION_MODIFY,
+ 'author' => static::username(),
+ 'object_type' => $type,
+ 'old_properties' => $oldProps,
+ 'new_properties' => $newProps,
+ 'change_time' => date('Y-m-d H:i:s'),
+ 'parent_checksum' => $db->getLastActivityChecksum()
+ ];
+
+ $data['checksum'] = sha1(json_encode($data), true);
+ $data['parent_checksum'] = hex2bin($data['parent_checksum']);
+
+ static::audit($db, [
+ 'action' => self::ACTION_MODIFY,
+ 'object_type' => $type,
+ 'object_name' => $name,
+ 'old_props' => $oldProps,
+ 'new_props' => $newProps,
+ ]);
+
+ return static::create($data)->store($db);
+ }
+
+ public static function logRemoval(IcingaObject $object, Db $db)
+ {
+ $name = $object->getObjectName();
+ $type = $object->getTableName();
+ $oldProps = json_encode($object->getPlainUnmodifiedObject());
+
+ $data = [
+ 'object_name' => $name,
+ 'action_name' => self::ACTION_DELETE,
+ 'author' => static::username(),
+ 'object_type' => $type,
+ 'old_properties' => $oldProps,
+ 'change_time' => date('Y-m-d H:i:s'),
+ 'parent_checksum' => $db->getLastActivityChecksum()
+ ];
+
+ $data['checksum'] = sha1(json_encode($data), true);
+ $data['parent_checksum'] = hex2bin($data['parent_checksum']);
+
+ static::audit($db, [
+ 'action' => self::AUDIT_REMOVE,
+ 'object_type' => $type,
+ 'object_name' => $name,
+ 'old_props' => $oldProps
+ ]);
+
+ return static::create($data)->store($db);
+ }
+
+ public static function audit(Db $db, $properties)
+ {
+ if ($db->settings()->get('enable_audit_log') !== 'y') {
+ return;
+ }
+
+ $log = [];
+ $properties = array_merge([
+ 'username' => static::username(),
+ 'address' => static::ip(),
+ ], $properties);
+
+ foreach ($properties as $key => $val) {
+ $log[] = "$key=" . json_encode($val);
+ }
+
+ Logger::info('(director) ' . implode(' ', $log));
+ }
+
+ public static function overrideUsername($username)
+ {
+ self::$overriddenUsername = $username;
+ }
+
+ public static function restoreUsername()
+ {
+ self::$overriddenUsername = null;
+ }
+}
diff --git a/library/Director/Objects/DirectorDatafield.php b/library/Director/Objects/DirectorDatafield.php
new file mode 100644
index 0000000..84db068
--- /dev/null
+++ b/library/Director/Objects/DirectorDatafield.php
@@ -0,0 +1,344 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Icinga\Module\Director\Core\Json;
+use Icinga\Module\Director\Data\Db\DbObjectWithSettings;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\DirectorObject\Automation\CompareBasketObject;
+use Icinga\Module\Director\Exception\DuplicateKeyException;
+use Icinga\Module\Director\Forms\IcingaServiceForm;
+use Icinga\Module\Director\Hook\DataTypeHook;
+use Icinga\Module\Director\Resolver\OverriddenVarsResolver;
+use Icinga\Module\Director\Web\Form\DirectorObjectForm;
+use InvalidArgumentException;
+use Zend_Form_Element as ZfElement;
+
+class DirectorDatafield extends DbObjectWithSettings
+{
+ protected $table = 'director_datafield';
+
+ protected $keyName = 'id';
+
+ protected $autoincKeyName = 'id';
+
+ protected $defaultProperties = [
+ 'id' => null,
+ 'category_id' => null,
+ 'varname' => null,
+ 'caption' => null,
+ 'description' => null,
+ 'datatype' => null,
+ 'format' => null,
+ ];
+
+ protected $relations = [
+ 'category' => 'DirectorDatafieldCategory'
+ ];
+
+ protected $settingsTable = 'director_datafield_setting';
+
+ protected $settingsRemoteId = 'datafield_id';
+
+ /** @var DirectorDatafieldCategory|null */
+ private $category;
+
+ private $object;
+
+ public static function fromDbRow($row, Db $connection)
+ {
+ $obj = static::create((array) $row, $connection);
+ $obj->loadedFromDb = true;
+ // TODO: $obj->setUnmodified();
+ $obj->hasBeenModified = false;
+ $obj->modifiedProperties = array();
+ $settings = $obj->getSettings();
+ // TODO: eventually prefetch
+ $obj->onLoadFromDb();
+
+ // Restoring values eventually destroyed by onLoadFromDb
+ foreach ($settings as $key => $value) {
+ $obj->settings[$key] = $value;
+ }
+
+ return $obj;
+ }
+
+ public function hasCategory()
+ {
+ return $this->category !== null || $this->get('category_id') !== null;
+ }
+
+ /**
+ * @return DirectorDatafieldCategory|null
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function getCategory()
+ {
+ if ($this->category) {
+ return $this->category;
+ } elseif ($id = $this->get('category_id')) {
+ return DirectorDatafieldCategory::loadWithAutoIncId($id, $this->getConnection());
+ } else {
+ return null;
+ }
+ }
+
+ public function getCategoryName()
+ {
+ $category = $this->getCategory();
+ if ($category === null) {
+ return null;
+ } else {
+ return $category->get('category_name');
+ }
+ }
+
+ public function setCategory($category)
+ {
+ 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 {
+ if (DirectorDatafieldCategory::exists($category, $this->getConnection())) {
+ $this->setCategory(DirectorDatafieldCategory::load($category, $this->getConnection()));
+ } else {
+ $this->setCategory(DirectorDatafieldCategory::create([
+ 'category_name' => $category
+ ], $this->getConnection()));
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * @return object
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function export()
+ {
+ $plain = (object) $this->getProperties();
+ $plain->originalId = $plain->id;
+ unset($plain->id);
+ $plain->settings = (object) $this->getSettings();
+
+ if (property_exists($plain->settings, 'datalist_id')) {
+ $plain->settings->datalist = DirectorDatalist::loadWithAutoIncId(
+ $plain->settings->datalist_id,
+ $this->getConnection()
+ )->get('list_name');
+ unset($plain->settings->datalist_id);
+ }
+ if (property_exists($plain, 'category_id')) {
+ $plain->category = $this->getCategoryName();
+ unset($plain->category_id);
+ }
+
+ return $plain;
+ }
+
+ /**
+ * @param $plain
+ * @param Db $db
+ * @param bool $replace
+ * @return DirectorDatafield
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public static function import($plain, Db $db, $replace = false)
+ {
+ $properties = (array) $plain;
+ if (isset($properties['originalId'])) {
+ $id = $properties['originalId'];
+ unset($properties['originalId']);
+ } else {
+ $id = null;
+ }
+
+ if (isset($properties['settings']->datalist)) {
+ // Just try to load the list, import should fail if missing
+ $list = DirectorDatalist::load(
+ $properties['settings']->datalist,
+ $db
+ );
+ } else {
+ $list = null;
+ }
+
+ $compare = Json::decode(Json::encode($properties));
+ if ($id && static::exists($id, $db)) {
+ $existing = static::loadWithAutoIncId($id, $db);
+ $existingProperties = (array) $existing->export();
+ unset($existingProperties['originalId']);
+ if (CompareBasketObject::equals((object) $compare, (object) $existingProperties)) {
+ return $existing;
+ }
+ }
+
+ if ($list) {
+ unset($properties['settings']->datalist);
+ $properties['settings']->datalist_id = $list->get('id');
+ }
+
+ $dba = $db->getDbAdapter();
+ $query = $dba->select()
+ ->from('director_datafield')
+ ->where('varname = ?', $plain->varname);
+ $candidates = DirectorDatafield::loadAll($db, $query);
+
+ foreach ($candidates as $candidate) {
+ $export = $candidate->export();
+ unset($export->originalId);
+ CompareBasketObject::normalize($export);
+ if (CompareBasketObject::equals($export, $compare)) {
+ return $candidate;
+ }
+ }
+
+ return static::create($properties, $db);
+ }
+
+ protected function beforeStore()
+ {
+ if ($this->category) {
+ if (!$this->category->hasBeenLoadedFromDb()) {
+ throw new \RuntimeException('Trying to store a datafield with an unstored Category');
+ }
+ $this->set('category_id', $this->category->get('id'));
+ }
+ }
+
+ protected function setObject(IcingaObject $object)
+ {
+ $this->object = $object;
+ }
+
+ protected function getObject()
+ {
+ return $this->object;
+ }
+
+ public function getFormElement(DirectorObjectForm $form, $name = null)
+ {
+ $className = $this->get('datatype');
+
+ if ($name === null) {
+ $name = 'var_' . $this->get('varname');
+ }
+
+ if (! class_exists($className)) {
+ $form->addElement('text', $name, array('disabled' => 'disabled'));
+ $el = $form->getElement($name);
+ $msg = $form->translate('Form element could not be created, %s is missing');
+ $el->addError(sprintf($msg, $className));
+ return $el;
+ }
+
+ /** @var DataTypeHook $dataType */
+ $dataType = new $className;
+ $dataType->setSettings($this->getSettings());
+ $el = $dataType->getFormElement($name, $form);
+
+ if ($this->getSetting('icinga_type') !== 'command'
+ && $this->getSetting('is_required') === 'y'
+ ) {
+ $el->setRequired(true);
+ }
+ if ($caption = $this->get('caption')) {
+ $el->setLabel($caption);
+ }
+
+ if ($description = $this->get('description')) {
+ $el->setDescription($description);
+ }
+
+ $this->applyObjectData($el, $form);
+
+ return $el;
+ }
+
+ protected function applyObjectData(ZfElement $el, DirectorObjectForm $form)
+ {
+ $object = $form->getObject();
+ if (! ($object instanceof IcingaObject)) {
+ return;
+ }
+ if ($object->isTemplate()) {
+ $el->setRequired(false);
+ }
+
+ $varName = $this->get('varname');
+ $inherited = $origin = null;
+
+ if ($form instanceof IcingaServiceForm && $form->providesOverrides()) {
+ $resolver = new OverriddenVarsResolver($form->getDb());
+ $vars = $resolver->fetchForServiceName($form->getHost(), $object->getObjectName());
+ foreach ($vars as $host => $values) {
+ if (\property_exists($values, $varName)) {
+ $inherited = $values->$varName;
+ $origin = $host;
+ }
+ }
+ }
+
+ if ($inherited === null) {
+ $inherited = $object->getInheritedVar($varName);
+ if (null !== $inherited) {
+ $origin = $object->getOriginForVar($varName);
+ }
+ }
+
+ if ($inherited === null) {
+ $cmd = $this->eventuallyGetResolvedCommandVar($object, $varName);
+ if ($cmd !== null) {
+ list($inherited, $origin) = $cmd;
+ }
+ }
+
+ if ($inherited !== null) {
+ $form->setInheritedValue($el, $inherited, $origin);
+ }
+ }
+
+ protected function eventuallyGetResolvedCommandVar(IcingaObject $object, $varName)
+ {
+ if (! $object->hasRelation('check_command')) {
+ return null;
+ }
+
+ // TODO: Move all of this elsewhere and test it
+ try {
+ /** @var IcingaCommand $command */
+ $command = $object->getResolvedRelated('check_command');
+ if ($command === null) {
+ return null;
+ }
+ $inherited = $command->vars()->get($varName);
+ $inheritedFrom = null;
+
+ if ($inherited !== null) {
+ $inherited = $inherited->getValue();
+ }
+
+ if ($inherited === null) {
+ $inherited = $command->getResolvedVar($varName);
+ if ($inherited === null) {
+ $inheritedFrom = $command->getOriginForVar($varName);
+ }
+ } else {
+ $inheritedFrom = $command->getObjectName();
+ }
+
+ $inherited = $command->getResolvedVar($varName);
+
+ return [$inherited, $inheritedFrom];
+ } catch (\Exception $e) {
+ return null;
+ }
+ }
+}
diff --git a/library/Director/Objects/DirectorDatafieldCategory.php b/library/Director/Objects/DirectorDatafieldCategory.php
new file mode 100644
index 0000000..6cb4fb4
--- /dev/null
+++ b/library/Director/Objects/DirectorDatafieldCategory.php
@@ -0,0 +1,64 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Icinga\Module\Director\Data\Db\DbObject;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Exception\DuplicateKeyException;
+
+class DirectorDatafieldCategory extends DbObject
+{
+ protected $table = 'director_datafield_category';
+
+ protected $keyName = 'category_name';
+
+ protected $autoincKeyName = 'id';
+
+ protected $defaultProperties = [
+ 'id' => null,
+ 'category_name' => null,
+ 'description' => null,
+ ];
+
+ /**
+ * @deprecated please use \Icinga\Module\Director\Data\Exporter
+ * @return object
+ */
+ public function export()
+ {
+ $plain = (object) $this->getProperties();
+ $plain->originalId = $plain->id;
+ unset($plain->id);
+ return $plain;
+ }
+
+ /**
+ * @param $plain
+ * @param Db $db
+ * @param bool $replace
+ * @return static
+ * @throws DuplicateKeyException
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public static function import($plain, Db $db, $replace = false)
+ {
+ $properties = (array) $plain;
+ unset($properties['originalId']);
+ $key = $properties['category_name'];
+
+ if ($replace && static::exists($key, $db)) {
+ $object = static::load($key, $db);
+ } elseif (static::exists($key, $db)) {
+ throw new DuplicateKeyException(
+ 'Cannot import, DatafieldCategory "%s" already exists',
+ $key
+ );
+ } else {
+ $object = static::create([], $db);
+ }
+
+ $object->setProperties($properties);
+
+ return $object;
+ }
+}
diff --git a/library/Director/Objects/DirectorDatalist.php b/library/Director/Objects/DirectorDatalist.php
new file mode 100644
index 0000000..ae5c983
--- /dev/null
+++ b/library/Director/Objects/DirectorDatalist.php
@@ -0,0 +1,225 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Exception;
+use Icinga\Module\Director\Data\Db\DbObject;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\DirectorObject\Automation\ExportInterface;
+use Icinga\Module\Director\Exception\DuplicateKeyException;
+
+class DirectorDatalist extends DbObject implements ExportInterface
+{
+ protected $table = 'director_datalist';
+
+ protected $keyName = 'list_name';
+
+ protected $autoincKeyName = 'id';
+
+ protected $defaultProperties = array(
+ 'id' => null,
+ 'list_name' => null,
+ 'owner' => null
+ );
+
+ /** @var DirectorDatalistEntry[] */
+ protected $storedEntries;
+
+ public function getUniqueIdentifier()
+ {
+ return $this->get('list_name');
+ }
+
+ /**
+ * @param $plain
+ * @param Db $db
+ * @param bool $replace
+ * @return static
+ * @throws \Icinga\Exception\NotFoundError
+ * @throws DuplicateKeyException
+ */
+ public static function import($plain, Db $db, $replace = false)
+ {
+ $properties = (array) $plain;
+ if (isset($properties['originalId'])) {
+ unset($properties['originalId']);
+ } else {
+ $id = null;
+ }
+ $name = $properties['list_name'];
+
+ if ($replace && static::exists($name, $db)) {
+ $object = static::load($name, $db);
+ } elseif (static::exists($name, $db)) {
+ throw new DuplicateKeyException(
+ 'Data List %s already exists',
+ $name
+ );
+ } else {
+ $object = static::create([], $db);
+ }
+ $object->setProperties($properties);
+
+ return $object;
+ }
+
+ public function setEntries($entries)
+ {
+ $existing = $this->getStoredEntries();
+
+ $new = [];
+ $seen = [];
+ $modified = false;
+
+ foreach ($entries as $entry) {
+ $name = $entry->entry_name;
+ $entry = DirectorDatalistEntry::create((array) $entry);
+ $seen[$name] = true;
+ if (isset($existing[$name])) {
+ $existing[$name]->replaceWith($entry);
+ if (! $modified && $existing[$name]->hasBeenModified()) {
+ $modified = true;
+ }
+ } else {
+ $modified = true;
+ $new[] = $entry;
+ }
+ }
+
+ foreach (array_keys($existing) as $key) {
+ if (! isset($seen[$key])) {
+ $existing[$key]->markForRemoval();
+ $modified = true;
+ }
+ }
+
+ foreach ($new as $entry) {
+ $existing[$entry->get('entry_name')] = $entry;
+ }
+
+ if ($modified) {
+ $this->hasBeenModified = true;
+ }
+
+ $this->storedEntries = $existing;
+ ksort($this->storedEntries);
+
+ return $this;
+ }
+
+ protected function beforeDelete()
+ {
+ if ($this->hasBeenUsed()) {
+ throw new Exception(
+ sprintf(
+ "Cannot delete '%s', as the datalist '%s' is currently being used.",
+ $this->get('list_name'),
+ $this->get('list_name')
+ )
+ );
+ }
+ }
+
+ protected function hasBeenUsed()
+ {
+ $datalistType = 'Icinga\\Module\\Director\\DataType\\DataTypeDatalist';
+ $db = $this->getDb();
+
+ $dataFieldsCheck = $db->select()
+ ->from(['df' =>'director_datafield'], ['varname'])
+ ->join(
+ ['dfs' => 'director_datafield_setting'],
+ 'dfs.datafield_id = df.id AND dfs.setting_name = \'datalist_id\'',
+ []
+ )
+ ->join(
+ ['l' => 'director_datalist'],
+ 'l.id = dfs.setting_value',
+ []
+ )
+ ->where('datatype = ?', $datalistType)
+ ->where('setting_value = ?', $this->get('id'));
+
+ if ($db->fetchOne($dataFieldsCheck)) {
+ return true;
+ }
+
+ $syncCheck = $db->select()
+ ->from(['sp' =>'sync_property'], ['source_expression'])
+ ->where('sp.destination_field = ?', 'list_id')
+ ->where('sp.source_expression = ?', $this->get('id'));
+
+ if ($db->fetchOne($syncCheck)) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * @throws DuplicateKeyException
+ */
+ public function onStore()
+ {
+ if ($this->storedEntries) {
+ $db = $this->getConnection();
+ $removedKeys = [];
+ $myId = $this->get('id');
+
+ foreach ($this->storedEntries as $key => $entry) {
+ if ($entry->shouldBeRemoved()) {
+ $entry->delete();
+ $removedKeys[] = $key;
+ } else {
+ if (! $entry->hasBeenLoadedFromDb()) {
+ $entry->set('list_id', $myId);
+ }
+ $entry->set('list_id', $myId);
+ $entry->store($db);
+ }
+ }
+
+ foreach ($removedKeys as $key) {
+ unset($this->storedEntries[$key]);
+ }
+ }
+ }
+
+ /**
+ * @deprecated please use \Icinga\Module\Director\Data\Exporter
+ * @return object
+ */
+ public function export()
+ {
+ $plain = (object) $this->getProperties();
+ $plain->originalId = $plain->id;
+ unset($plain->id);
+
+ $plain->entries = [];
+ foreach ($this->getStoredEntries() as $key => $entry) {
+ if ($entry->shouldBeRemoved()) {
+ continue;
+ }
+ $plainEntry = (object) $entry->getProperties();
+ unset($plainEntry->list_id);
+
+ $plain->entries[] = $plainEntry;
+ }
+
+ return $plain;
+ }
+
+ protected function getStoredEntries()
+ {
+ if ($this->storedEntries === null) {
+ if ($id = $this->get('id')) {
+ $this->storedEntries = DirectorDatalistEntry::loadAllForList($this);
+ ksort($this->storedEntries);
+ } else {
+ $this->storedEntries = [];
+ }
+ }
+
+ return $this->storedEntries;
+ }
+}
diff --git a/library/Director/Objects/DirectorDatalistEntry.php b/library/Director/Objects/DirectorDatalistEntry.php
new file mode 100644
index 0000000..086686a
--- /dev/null
+++ b/library/Director/Objects/DirectorDatalistEntry.php
@@ -0,0 +1,112 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Icinga\Module\Director\Data\Db\DbObject;
+use RuntimeException;
+
+class DirectorDatalistEntry extends DbObject
+{
+ protected $keyName = ['list_id', 'entry_name'];
+
+ protected $table = 'director_datalist_entry';
+
+ private $shouldBeRemoved = false;
+
+ protected $defaultProperties = [
+ 'list_id' => null,
+ 'entry_name' => null,
+ 'entry_value' => null,
+ 'format' => null,
+ 'allowed_roles' => null,
+ ];
+
+ /**
+ * @param DirectorDatalist $list
+ * @return static[]
+ */
+ public static function loadAllForList(DirectorDatalist $list)
+ {
+ $query = $list->getDb()
+ ->select()
+ ->from('director_datalist_entry')
+ ->where('list_id = ?', $list->get('id'))
+ ->order('entry_name ASC');
+
+ return static::loadAll($list->getConnection(), $query, 'entry_name');
+ }
+
+ /**
+ * @param $roles
+ * @codingStandardsIgnoreStart
+ */
+ public function setAllowed_roles($roles)
+ {
+ // @codingStandardsIgnoreEnd
+ $key = 'allowed_roles';
+ if (is_array($roles)) {
+ $this->reallySet($key, json_encode($roles));
+ } elseif (null === $roles) {
+ $this->reallySet($key, null);
+ } else {
+ throw new RuntimeException(
+ 'Expected array or null for allowed_roles, got %s',
+ var_export($roles, 1)
+ );
+ }
+ }
+
+ /**
+ * @return array|null
+ * @codingStandardsIgnoreStart
+ */
+ public function getAllowed_roles()
+ {
+ // @codingStandardsIgnoreEnd
+ $roles = $this->getProperty('allowed_roles');
+ if (is_string($roles)) {
+ return json_decode($roles);
+ } else {
+ return $roles;
+ }
+ }
+
+ public function replaceWith(DirectorDatalistEntry $object)
+ {
+ $this->set('entry_value', $object->get('entry_value'));
+ if ($object->get('format')) {
+ $this->set('format', $object->get('format'));
+ }
+
+ return $this;
+ }
+
+ public function merge(DirectorDatalistEntry $object)
+ {
+ return $this->replaceWith($object);
+ }
+
+ public function markForRemoval($remove = true)
+ {
+ $this->shouldBeRemoved = $remove;
+
+ return $this;
+ }
+
+ public function shouldBeRemoved()
+ {
+ return $this->shouldBeRemoved;
+ }
+
+ public function onInsert()
+ {
+ }
+
+ public function onUpdate()
+ {
+ }
+
+ public function onDelete()
+ {
+ }
+}
diff --git a/library/Director/Objects/DirectorDeploymentLog.php b/library/Director/Objects/DirectorDeploymentLog.php
new file mode 100644
index 0000000..0794a3c
--- /dev/null
+++ b/library/Director/Objects/DirectorDeploymentLog.php
@@ -0,0 +1,199 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Exception;
+use Icinga\Exception\NotFoundError;
+use Icinga\Module\Director\Core\CoreApi;
+use Icinga\Module\Director\Data\Db\DbObject;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\IcingaConfig\IcingaConfig;
+use Icinga\Module\Director\Util;
+
+class DirectorDeploymentLog extends DbObject
+{
+ protected $table = 'director_deployment_log';
+
+ protected $keyName = 'id';
+
+ protected $autoincKeyName = 'id';
+
+ protected $config;
+
+ protected $defaultProperties = [
+ 'id' => null,
+ 'config_checksum' => null,
+ 'last_activity_checksum' => null,
+ 'peer_identity' => null,
+ 'start_time' => null,
+ 'end_time' => null,
+ 'abort_time' => null,
+ 'duration_connection' => null,
+ 'duration_dump' => null,
+ 'stage_name' => null,
+ 'stage_collected' => null,
+ 'connection_succeeded' => null,
+ 'dump_succeeded' => null,
+ 'startup_succeeded' => null,
+ 'username' => null,
+ 'startup_log' => null,
+ ];
+
+ protected $binaryProperties = [
+ 'config_checksum',
+ 'last_activity_checksum'
+ ];
+
+ public function getConfigHexChecksum()
+ {
+ return bin2hex($this->config_checksum);
+ }
+
+ public function getConfig()
+ {
+ if ($this->config === null) {
+ $this->config = IcingaConfig::load($this->config_checksum, $this->connection);
+ }
+
+ return $this->config;
+ }
+
+ public function isPending()
+ {
+ return $this->dump_succeeded === 'y' && $this->startup_log === null;
+ }
+
+ public function succeeded()
+ {
+ return $this->startup_succeeded === 'y';
+ }
+
+ public function configEquals(IcingaConfig $config)
+ {
+ return $this->config_checksum === $config->getChecksum();
+ }
+
+ public function getDeploymentTimestamp()
+ {
+ return strtotime($this->start_time);
+ }
+
+ public static function hasDeployments(Db $connection)
+ {
+ $db = $connection->getDbAdapter();
+ $query = $db->select()->from(
+ 'director_deployment_log',
+ array('c' => 'COUNT(*)')
+ );
+
+ return (int) $db->fetchOne($query) > 0;
+ }
+
+ public static function getConfigChecksumForStageName(Db $connection, $stage)
+ {
+ if ($stage === null) {
+ return null;
+ }
+ $db = $connection->getDbAdapter();
+ $query = $db->select()
+ ->from(
+ array('l' => 'director_deployment_log'),
+ array('c' => $connection->dbHexFunc('l.config_checksum'))
+ )->where('l.stage_name = ?');
+
+ return $db->fetchOne($query, $stage);
+ }
+
+ /**
+ * @param Db $connection
+ * @return DirectorDeploymentLog
+ * @throws NotFoundError
+ */
+ public static function loadLatest(Db $connection)
+ {
+ $db = $connection->getDbAdapter();
+ $query = $db->select()->from(
+ array('l' => 'director_deployment_log'),
+ array('id' => 'MAX(l.id)')
+ );
+
+ return static::load($db->fetchOne($query), $connection);
+ }
+
+ /**
+ * @param Db $connection
+ * @return ?DirectorDeploymentLog
+ */
+ public static function optionalLatest(Db $connection)
+ {
+ try {
+ return static::loadLatest($connection);
+ } catch (NotFoundError $exception) {
+ return null;
+ }
+ }
+
+ /**
+ * @param CoreApi $api
+ * @param Db $connection
+ * @return DirectorDeploymentLog
+ */
+ public static function getRelatedToActiveStage(CoreApi $api, Db $connection)
+ {
+ try {
+ return static::requireRelatedToActiveStage($api, $connection);
+ } catch (Exception $e) {
+ return null;
+ }
+ }
+
+ /**
+ * @param CoreApi $api
+ * @param Db $connection
+ * @return DirectorDeploymentLog
+ * @throws NotFoundError
+ */
+ public static function requireRelatedToActiveStage(CoreApi $api, Db $connection)
+ {
+ $stage = $api->getActiveStageName();
+
+ if (! strlen($stage)) {
+ throw new NotFoundError('Got no active stage name');
+ }
+ $db = $connection->getDbAdapter();
+ $query = $db->select()->from(
+ ['l' => 'director_deployment_log'],
+ ['id' => 'MAX(l.id)']
+ )->where('l.stage_name = ?', $stage);
+
+ return static::load($db->fetchOne($query), $connection);
+ }
+
+ /**
+ * @return static[]
+ */
+ public static function getUncollected(Db $connection)
+ {
+ $db = $connection->getDbAdapter();
+ $query = $db->select()
+ ->from('director_deployment_log')
+ ->where('stage_name IS NOT NULL')
+ ->where('stage_collected IS NULL')
+ ->where('startup_succeeded IS NULL')
+ ->order('stage_name');
+
+ return static::loadAll($connection, $query, 'stage_name');
+ }
+
+ public static function hasUncollected(Db $connection)
+ {
+ $db = $connection->getDbAdapter();
+ $query = $db->select()
+ ->from('director_deployment_log', ['cnt' => 'COUNT(*)'])
+ ->where('stage_name IS NOT NULL')
+ ->where('stage_collected IS NULL')
+ ->where('startup_succeeded IS NULL');
+
+ return $db->fetchOne($query) > 0;
+ }
+}
diff --git a/library/Director/Objects/DirectorJob.php b/library/Director/Objects/DirectorJob.php
new file mode 100644
index 0000000..361f764
--- /dev/null
+++ b/library/Director/Objects/DirectorJob.php
@@ -0,0 +1,314 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Icinga\Exception\NotFoundError;
+use Icinga\Module\Director\Daemon\Logger;
+use Icinga\Module\Director\Data\Db\DbObjectWithSettings;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\DirectorObject\Automation\ExportInterface;
+use Icinga\Module\Director\Exception\DuplicateKeyException;
+use Icinga\Module\Director\Hook\JobHook;
+use Exception;
+use InvalidArgumentException;
+
+class DirectorJob extends DbObjectWithSettings implements ExportInterface, InstantiatedViaHook
+{
+ /** @var JobHook */
+ protected $job;
+
+ protected $table = 'director_job';
+
+ protected $keyName = 'job_name';
+
+ protected $autoincKeyName = 'id';
+
+ protected $protectAutoinc = false;
+
+ protected $defaultProperties = [
+ 'id' => null,
+ 'job_name' => null,
+ 'job_class' => null,
+ 'disabled' => null,
+ 'run_interval' => null,
+ 'last_attempt_succeeded' => null,
+ 'ts_last_attempt' => null,
+ 'ts_last_error' => null,
+ 'last_error_message' => null,
+ 'timeperiod_id' => null,
+ ];
+
+ protected $stateProperties = [
+ 'last_attempt_succeeded',
+ 'last_error_message',
+ 'ts_last_attempt',
+ 'ts_last_error',
+ ];
+
+ protected $settingsTable = 'director_job_setting';
+
+ protected $settingsRemoteId = 'job_id';
+
+ public function getUniqueIdentifier()
+ {
+ return $this->get('job_name');
+ }
+
+ /**
+ * @deprecated please use JobHook::getInstance()
+ * @return JobHook
+ */
+ public function job()
+ {
+ return $this->getInstance();
+ }
+
+ /**
+ * @return JobHook
+ */
+ public function getInstance()
+ {
+ if ($this->job === null) {
+ $class = $this->get('job_class');
+ $this->job = new $class;
+ $this->job->setDb($this->connection);
+ $this->job->setDefinition($this);
+ }
+
+ return $this->job;
+ }
+
+ /**
+ * @throws \Icinga\Module\Director\Exception\DuplicateKeyException
+ */
+ public function run()
+ {
+ $job = $this->getInstance();
+ $this->set('ts_last_attempt', date('Y-m-d H:i:s'));
+
+ try {
+ $job->run();
+ $this->set('last_attempt_succeeded', 'y');
+ $success = true;
+ } catch (Exception $e) {
+ Logger::error($e->getMessage());
+ $this->set('ts_last_error', date('Y-m-d H:i:s'));
+ $this->set('last_error_message', $e->getMessage());
+ $this->set('last_attempt_succeeded', 'n');
+ $success = false;
+ }
+
+ if ($this->hasBeenModified()) {
+ $this->store();
+ }
+
+ return $success;
+ }
+
+ /**
+ * @return bool
+ */
+ public function shouldRun()
+ {
+ return (! $this->hasBeenDisabled()) && $this->isPending();
+ }
+
+ /**
+ * @return bool
+ */
+ public function isOverdue()
+ {
+ if (! $this->shouldRun()) {
+ return false;
+ }
+
+ return (
+ strtotime((int) $this->get('ts_last_attempt')) + $this->get('run_interval') * 2
+ ) < time();
+ }
+
+ public function hasBeenDisabled()
+ {
+ return $this->get('disabled') === 'y';
+ }
+
+ /**
+ * @return bool
+ */
+ public function isPending()
+ {
+ if ($this->get('ts_last_attempt') === null) {
+ return $this->isWithinTimeperiod();
+ }
+
+ if (strtotime($this->get('ts_last_attempt')) + $this->get('run_interval') < time()) {
+ return $this->isWithinTimeperiod();
+ }
+
+ return false;
+ }
+
+ /**
+ * @return bool
+ */
+ public function isWithinTimeperiod()
+ {
+ if ($this->hasTimeperiod()) {
+ return $this->timeperiod()->isActive();
+ } else {
+ return true;
+ }
+ }
+
+ public function lastAttemptSucceeded()
+ {
+ return $this->get('last_attempt_succeeded') === 'y';
+ }
+
+ public function lastAttemptFailed()
+ {
+ return $this->get('last_attempt_succeeded') === 'n';
+ }
+
+ public function hasTimeperiod()
+ {
+ return $this->get('timeperiod_id') !== null;
+ }
+
+ /**
+ * @param $timeperiod
+ * @return $this
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function setTimeperiod($timeperiod)
+ {
+ if (is_string($timeperiod)) {
+ $timeperiod = IcingaTimePeriod::load($timeperiod, $this->connection);
+ } elseif (! $timeperiod instanceof IcingaTimePeriod) {
+ throw new InvalidArgumentException('TimePeriod expected');
+ }
+
+ $this->set('timeperiod_id', $timeperiod->get('id'));
+
+ return $this;
+ }
+
+ /**
+ * @return object
+ * @deprecated please use \Icinga\Module\Director\Data\Exporter
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function export()
+ {
+ $plain = (object) $this->getProperties();
+ $plain->originalId = $plain->id;
+ unset($plain->id);
+ unset($plain->timeperiod_id);
+ if ($this->hasTimeperiod()) {
+ $plain->timeperiod = $this->timeperiod()->getObjectName();
+ }
+
+ foreach ($this->stateProperties as $key) {
+ unset($plain->$key);
+ }
+ $plain->settings = $this->getInstance()->exportSettings();
+
+ return $plain;
+ }
+
+ /**
+ * @param $plain
+ * @param Db $db
+ * @param bool $replace
+ * @return DirectorJob
+ * @throws DuplicateKeyException
+ * @throws NotFoundError
+ */
+ public static function import($plain, Db $db, $replace = false)
+ {
+ $dummy = new static;
+ $idCol = $dummy->autoincKeyName;
+ $keyCol = $dummy->keyName;
+ $properties = (array) $plain;
+ if (isset($properties['originalId'])) {
+ $id = $properties['originalId'];
+ unset($properties['originalId']);
+ } else {
+ $id = null;
+ }
+ $name = $properties[$keyCol];
+
+ if ($replace && $id && static::existsWithNameAndId($name, $id, $db)) {
+ $object = static::loadWithAutoIncId($id, $db);
+ } elseif ($replace && static::exists($name, $db)) {
+ $object = static::load($name, $db);
+ } elseif (static::exists($name, $db)) {
+ throw new DuplicateKeyException(
+ 'Director Job "%s" already exists',
+ $name
+ );
+ } else {
+ $object = static::create([], $db);
+ }
+
+ $settings = (array) $properties['settings'];
+
+ if (array_key_exists('source', $settings) && ! (array_key_exists('source_id', $settings))) {
+ $val = ImportSource::load($settings['source'], $db)->get('id');
+ $settings['source_id'] = $val;
+ unset($settings['source']);
+ }
+
+ if (array_key_exists('rule', $settings) && ! (array_key_exists('rule_id', $settings))) {
+ $val = SyncRule::load($settings['rule'], $db)->get('id');
+ $settings['rule_id'] = $val;
+ unset($settings['rule']);
+ }
+
+ $properties['settings'] = (object) $settings;
+ $object->setProperties($properties);
+ if ($id !== null) {
+ $object->reallySet($idCol, $id);
+ }
+
+ return $object;
+ }
+
+ /**
+ * @param string $name
+ * @param int $id
+ * @param Db $connection
+ * @api internal
+ * @return bool
+ */
+ protected static function existsWithNameAndId($name, $id, Db $connection)
+ {
+ $db = $connection->getDbAdapter();
+ $dummy = new static;
+ $idCol = $dummy->autoincKeyName;
+ $keyCol = $dummy->keyName;
+
+ return (string) $id === (string) $db->fetchOne(
+ $db->select()
+ ->from($dummy->table, $idCol)
+ ->where("$idCol = ?", $id)
+ ->where("$keyCol = ?", $name)
+ );
+ }
+
+ /**
+ * @api internal Exporter only
+ * @return IcingaTimePeriod
+ */
+ public function timeperiod()
+ {
+ try {
+ return IcingaTimePeriod::loadWithAutoIncId($this->get('timeperiod_id'), $this->connection);
+ } catch (NotFoundError $e) {
+ throw new \RuntimeException(sprintf(
+ 'The TimePeriod configured for Job "%s" could not have been found',
+ $this->get('name')
+ ));
+ }
+ }
+}
diff --git a/library/Director/Objects/DynamicApplyMatches.php b/library/Director/Objects/DynamicApplyMatches.php
new file mode 100644
index 0000000..9341d1a
--- /dev/null
+++ b/library/Director/Objects/DynamicApplyMatches.php
@@ -0,0 +1,14 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+class DynamicApplyMatches extends ObjectApplyMatches
+{
+ protected static $type = '';
+
+ public static function setType($type)
+ {
+ static::$type = $type;
+ return static::$type;
+ }
+}
diff --git a/library/Director/Objects/Extension/Arguments.php b/library/Director/Objects/Extension/Arguments.php
new file mode 100644
index 0000000..3acbdd3
--- /dev/null
+++ b/library/Director/Objects/Extension/Arguments.php
@@ -0,0 +1,61 @@
+<?php
+
+namespace Icinga\Module\Director\Objects\Extension;
+
+use Icinga\Module\Director\Objects\IcingaArguments;
+use Icinga\Module\Director\Objects\IcingaObject;
+
+trait Arguments
+{
+ private $arguments;
+
+ public function arguments()
+ {
+ /** @var IcingaObject $this */
+ if ($this->arguments === null) {
+ if ($this->hasBeenLoadedFromDb()) {
+ $this->arguments = IcingaArguments::loadForStoredObject($this);
+ } else {
+ $this->arguments = new IcingaArguments($this);
+ }
+ }
+
+ return $this->arguments;
+ }
+
+ public function gotArguments()
+ {
+ return null !== $this->arguments;
+ }
+
+ public function unsetArguments()
+ {
+ unset($this->arguments);
+ }
+
+ /**
+ * @return string
+ */
+ protected function renderArguments()
+ {
+ return $this->arguments()->toConfigString();
+ }
+
+ /**
+ * @param $value
+ * @return $this
+ */
+ protected function setArguments($value)
+ {
+ $this->arguments()->setArguments($value);
+ return $this;
+ }
+
+ /**
+ * @return array
+ */
+ protected function getArguments()
+ {
+ return $this->arguments()->toPlainObject();
+ }
+}
diff --git a/library/Director/Objects/Extension/FlappingSupport.php b/library/Director/Objects/Extension/FlappingSupport.php
new file mode 100644
index 0000000..a86f10d
--- /dev/null
+++ b/library/Director/Objects/Extension/FlappingSupport.php
@@ -0,0 +1,54 @@
+<?php
+
+namespace Icinga\Module\Director\Objects\Extension;
+
+use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c;
+use Icinga\Module\Director\IcingaConfig\IcingaLegacyConfigHelper as c1;
+
+trait FlappingSupport
+{
+ /**
+ * @param $value
+ * @return string
+ * @codingStandardsIgnoreStart
+ */
+ protected function renderFlapping_threshold_high($value)
+ {
+ return $this->renderFlappingThreshold('flapping_threshold_high', $value);
+ }
+
+ /**
+ * @param $value
+ * @return string
+ */
+ protected function renderFlapping_threshold_low($value)
+ {
+ return $this->renderFlappingThreshold('flapping_threshold_low', $value);
+ }
+
+ protected function renderFlappingThreshold($key, $value)
+ {
+ return sprintf(
+ " try { // This setting is only available in Icinga >= 2.8.0\n"
+ . " %s"
+ . " } except { globals.directorWarnOnceForThresholds() }\n",
+ c::renderKeyValue($key, c::renderFloat($value))
+ );
+ }
+
+ protected function renderLegacyEnable_flapping($value)
+ {
+ return c1::renderKeyValue('flap_detection_enabled', c1::renderBoolean($value));
+ }
+
+ protected function renderLegacyFlapping_threshold_high($value)
+ {
+ return c1::renderKeyValue('high_flap_threshold', $value);
+ }
+
+ protected function renderLegacyFlapping_threshold_low($value)
+ {
+ // @codingStandardsIgnoreEnd
+ return c1::renderKeyValue('low_flap_threshold', $value);
+ }
+}
diff --git a/library/Director/Objects/Extension/PriorityColumn.php b/library/Director/Objects/Extension/PriorityColumn.php
new file mode 100644
index 0000000..638bdc6
--- /dev/null
+++ b/library/Director/Objects/Extension/PriorityColumn.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace Icinga\Module\Director\Objects\Extension;
+
+use Zend_Db_Expr as Expr;
+
+trait PriorityColumn
+{
+ public function setNextPriority($prioSetColumn = null, $prioColumn = 'priority')
+ {
+ /** @var \Zend_Db_Adapter_Abstract $db */
+ $db = $this->getDb();
+ $prioValue = '(CASE WHEN MAX(priosub.priority) IS NULL THEN 1'
+ . ' ELSE MAX(priosub.priority) + 1 END)';
+ $query = $db->select()
+ ->from(
+ ['priosub' => $this->getTableName()],
+ "$prioValue"
+ );
+
+ if ($prioSetColumn !== null) {
+ $query->where("priosub.$prioSetColumn = ?", $this->get($prioSetColumn));
+ }
+
+ $this->set($prioColumn, new Expr('(' . $query . ')'));
+
+ return $this;
+ }
+
+ protected function refreshPriortyProperty($prioColumn = 'priority')
+ {
+ /** @var \Zend_Db_Adapter_Abstract $db */
+ $db = $this->getDb();
+ $idCol = $this->getAutoincKeyName();
+ $query = $db->select()
+ ->from($this->getTableName(), $prioColumn)
+ ->where("$idCol = ?", $this->get($idCol));
+ $this->reallySet($prioColumn, $db->fetchOne($query));
+ }
+}
diff --git a/library/Director/Objects/GroupMembershipResolver.php b/library/Director/Objects/GroupMembershipResolver.php
new file mode 100644
index 0000000..f5ef418
--- /dev/null
+++ b/library/Director/Objects/GroupMembershipResolver.php
@@ -0,0 +1,689 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Icinga\Application\Benchmark;
+use Icinga\Data\Filter\Filter;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Db\IcingaObjectFilterHelper;
+use Icinga\Module\Director\Repository\IcingaTemplateRepository;
+use InvalidArgumentException;
+use LogicException;
+use Zend_Db_Select as ZfSelect;
+
+/**
+ * Class GroupMembershipResolver
+ *
+ * - Fetches all involved assignments
+ * - Fetch all (or one) object
+ * - Fetch all (or one) group
+ */
+abstract class GroupMembershipResolver
+{
+ /** @var string Object type, 'host', 'service', 'user' or similar */
+ protected $type;
+
+ /** @var array */
+ protected $existingMappings;
+
+ /** @var array */
+ protected $newMappings;
+
+ /** @var Db */
+ protected $connection;
+
+ /** @var \Zend_Db_Adapter_Abstract */
+ protected $db;
+
+ /** @var IcingaObject[] */
+ protected $objects;
+
+ /** @var IcingaObjectGroup[] */
+ protected $groups = array();
+
+ /** @var array */
+ protected $staticGroups = array();
+
+ /** @var bool */
+ protected $deferred = false;
+
+ /** @var bool */
+ protected $checked = false;
+
+ /** @var bool */
+ protected $useTransactions = false;
+
+ protected $groupMap;
+
+ public function __construct(Db $connection)
+ {
+ $this->connection = $connection;
+ $this->db = $connection->getDbAdapter();
+ }
+
+ /**
+ * @return $this
+ * @throws \Zend_Db_Adapter_Exception
+ */
+ public function refreshAllMappings()
+ {
+ return $this->clearGroups()->clearObjects()->refreshDb(true);
+ }
+
+ public function checkDb()
+ {
+ if ($this->checked) {
+ return $this;
+ }
+
+ if ($this->isDeferred()) {
+ // ensure we are not working with cached data
+ IcingaTemplateRepository::clear();
+ }
+
+ Benchmark::measure('Rechecking all objects');
+ $this->recheckAllObjects($this->getAppliedGroups());
+ if (empty($this->objects) && empty($this->groups)) {
+ Benchmark::measure('Nothing to check, got no qualified object');
+ return $this;
+ }
+
+ Benchmark::measure('Recheck done, loading existing mappings');
+ $this->fetchStoredMappings();
+ Benchmark::measure('Got stored group mappings');
+
+ $this->checked = true;
+ return $this;
+ }
+
+ /**
+ * @param bool $force
+ * @return $this
+ * @throws \Zend_Db_Adapter_Exception
+ */
+ public function refreshDb($force = false)
+ {
+ if ($force || ! $this->isDeferred()) {
+ $this->checkDb();
+
+ if (empty($this->objects) && empty($this->groups)) {
+ Benchmark::measure('Nothing to check, got no qualified object');
+
+ return $this;
+ }
+
+ Benchmark::measure('Ready, going to store new mappings');
+ $this->storeNewMappings();
+ $this->removeOutdatedMappings();
+ Benchmark::measure('Updated group mappings in db');
+ }
+
+ return $this;
+ }
+
+ /**
+ * @param bool $defer
+ * @return $this
+ */
+ public function defer($defer = true)
+ {
+ $this->deferred = $defer;
+ return $this;
+ }
+
+ /**
+ * @param $use
+ * @return $this
+ */
+ public function setUseTransactions($use)
+ {
+ $this->useTransactions = $use;
+ return $this;
+ }
+
+ public function getType()
+ {
+ if ($this->type === null) {
+ throw new LogicException(sprintf(
+ '"type" is required when extending %s, got none in %s',
+ __CLASS__,
+ get_class($this)
+ ));
+ }
+
+ return $this->type;
+ }
+
+ /**
+ * @return bool
+ */
+ public function isDeferred()
+ {
+ return $this->deferred;
+ }
+
+ /**
+ * @param IcingaObject $object
+ * @return $this
+ */
+ public function addObject(IcingaObject $object)
+ {
+ // Hint: cannot use hasBeenLoadedFromDB, as it is false in onStore()
+ // for new objects
+ if (null === ($id = $object->get('id'))) {
+ return $this;
+ }
+ // Disabling for now, how should this work?
+ // $this->assertBeenLoadedFromDb($object);
+ if ($this->objects === null) {
+ $this->objects = [];
+ }
+
+ if ($object->isTemplate()) {
+ $this->includeChildObjects($object);
+ } else {
+ $this->objects[$id] = $object;
+ }
+
+ return $this;
+ }
+
+ /**
+ * @param IcingaObject[] $objects
+ * @return $this
+ */
+ public function addObjects(array $objects)
+ {
+ foreach ($objects as $object) {
+ $this->addObject($object);
+ }
+
+ return $this;
+ }
+
+ protected function includeChildObjects(IcingaObject $object)
+ {
+ $query = $this->db->select()
+ ->from(['o' => $object->getTableName()])
+ ->where('o.object_type = ?', 'object');
+
+ IcingaObjectFilterHelper::filterByTemplate(
+ $query,
+ $object,
+ 'o',
+ Db\IcingaObjectFilterHelper::INHERIT_DIRECT_OR_INDIRECT
+ );
+
+ foreach ($object::loadAll($this->connection, $query) as $child) {
+ $this->objects[$child->getProperty('id')] = $child;
+ }
+
+ return $this;
+ }
+
+ /**
+ * @param IcingaObject $object
+ * @return $this
+ */
+ public function setObject(IcingaObject $object)
+ {
+ $this->clearObjects();
+ return $this->addObject($object);
+ }
+
+ /**
+ * @param IcingaObject[] $objects
+ * @return $this
+ */
+ public function setObjects(array $objects)
+ {
+ $this->clearObjects();
+ return $this->addObjects($objects);
+ }
+
+ /**
+ * @return $this
+ */
+ public function clearObjects()
+ {
+ $this->objects = array();
+ return $this;
+ }
+
+ /**
+ * @param IcingaObjectGroup $group
+ * @return $this
+ */
+ public function addGroup(IcingaObjectGroup $group)
+ {
+ $this->assertBeenLoadedFromDb($group);
+ $this->groups[$group->get('id')] = $group;
+
+ $this->checked = false;
+
+ return $this;
+ }
+
+ /**
+ * @param IcingaObjectGroup[] $groups
+ * @return $this
+ */
+ public function addGroups(array $groups)
+ {
+ foreach ($groups as $group) {
+ $this->addGroup($group);
+ }
+
+ $this->checked = false;
+
+ return $this;
+ }
+
+ /**
+ * @param IcingaObjectGroup $group
+ * @return $this
+ */
+ public function setGroup(IcingaObjectGroup $group)
+ {
+ $this->clearGroups();
+ return $this->addGroup($group);
+ }
+
+ /**
+ * @param array $groups
+ * @return $this
+ */
+ public function setGroups(array $groups)
+ {
+ $this->clearGroups();
+ return $this->addGroups($groups);
+ }
+
+ /**
+ * @return $this
+ */
+ public function clearGroups()
+ {
+ $this->objects = array();
+ $this->checked = false;
+ return $this;
+ }
+
+ public function getNewMappings()
+ {
+ if ($this->newMappings !== null && $this->existingMappings !== null) {
+ return $this->getDifference($this->newMappings, $this->existingMappings);
+ } else {
+ return [];
+ }
+ }
+
+ /**
+ * @throws \Zend_Db_Adapter_Exception
+ */
+ protected function storeNewMappings()
+ {
+ $diff = $this->getNewMappings();
+ $count = count($diff);
+ if ($count === 0) {
+ return;
+ }
+
+ $db = $this->db;
+ $this->beginTransaction();
+ foreach ($diff as $row) {
+ $db->insert(
+ $this->getResolvedTableName(),
+ $row
+ );
+ }
+
+ $this->commit();
+ Benchmark::measure(
+ sprintf(
+ 'Stored %d new resolved group memberships',
+ $count
+ )
+ );
+ }
+
+ protected function getGroupId($name)
+ {
+ $type = $this->type;
+ if ($this->groupMap === null) {
+ $this->groupMap = $this->db->fetchPairs(
+ $this->db->select()->from("icinga_${type}group", ['object_name', 'id'])
+ );
+ }
+
+ if (array_key_exists($name, $this->groupMap)) {
+ return $this->groupMap[$name];
+ } else {
+ throw new InvalidArgumentException(sprintf(
+ 'Unable to lookup the group name for "%s"',
+ $name
+ ));
+ }
+ }
+
+ public function getOutdatedMappings()
+ {
+ if ($this->newMappings !== null && $this->existingMappings !== null) {
+ return $this->getDifference($this->existingMappings, $this->newMappings);
+ } else {
+ return [];
+ }
+ }
+
+ protected function removeOutdatedMappings()
+ {
+ $diff = $this->getOutdatedMappings();
+ $count = count($diff);
+ if ($count === 0) {
+ return;
+ }
+
+ $type = $this->getType();
+ $db = $this->db;
+ $this->beginTransaction();
+ foreach ($diff as $row) {
+ $db->delete(
+ $this->getResolvedTableName(),
+ sprintf(
+ "(${type}group_id = %d AND ${type}_id = %d)",
+ $row["${type}group_id"],
+ $row["${type}_id"]
+ )
+ );
+ }
+
+ $this->commit();
+ Benchmark::measure(
+ sprintf(
+ 'Removed %d outdated group memberships',
+ $count
+ )
+ );
+ }
+
+ protected function getDifference(&$left, &$right)
+ {
+ $diff = array();
+
+ $type = $this->getType();
+ foreach ($left as $groupId => $objectIds) {
+ if (array_key_exists($groupId, $right)) {
+ foreach ($objectIds as $objectId) {
+ if (! array_key_exists($objectId, $right[$groupId])) {
+ $diff[] = array(
+ "${type}group_id" => $groupId,
+ "${type}_id" => $objectId,
+ );
+ }
+ }
+ } else {
+ foreach ($objectIds as $objectId) {
+ $diff[] = array(
+ "${type}group_id" => $groupId,
+ "${type}_id" => $objectId,
+ );
+ }
+ }
+ }
+
+ return $diff;
+ }
+
+ /**
+ * This fetches already resolved memberships
+ */
+ protected function fetchStoredMappings()
+ {
+ $mappings = array();
+
+ $type = $this->getType();
+ $query = $this->db->select()->from(
+ array('hgh' => $this->getResolvedTableName()),
+ array(
+ 'group_id' => "${type}group_id",
+ 'object_id' => "${type}_id",
+ )
+ );
+
+ $this->addMembershipWhere($query, "${type}_id", $this->objects);
+ $this->addMembershipWhere($query, "${type}group_id", $this->groups);
+ if (! empty($this->groups)) {
+ // load staticGroups (we touched here) additionally, so we can compare changes
+ $this->addMembershipWhere($query, "${type}group_id", $this->staticGroups);
+ }
+
+ foreach ($this->db->fetchAll($query) as $row) {
+ $groupId = $row->group_id;
+ $objectId = $row->object_id;
+ if (! array_key_exists($groupId, $mappings)) {
+ $mappings[$groupId] = array();
+ }
+
+ $mappings[$groupId][$objectId] = $objectId;
+ }
+
+ $this->existingMappings = $mappings;
+ }
+
+ /**
+ * @param ZfSelect $query
+ * @param string $column
+ * @param IcingaObject[]|int[] $objects
+ * @return ZfSelect
+ */
+ protected function addMembershipWhere(ZfSelect $query, $column, &$objects)
+ {
+ if (empty($objects)) {
+ return $query;
+ }
+
+ $ids = array();
+ foreach ($objects as $k => $object) {
+ if (is_int($object)) {
+ $ids[] = $k;
+ } elseif (is_string($object)) {
+ $ids[] = (int) $object;
+ } else {
+ $ids[] = (int) $object->get('id');
+ }
+ }
+
+ if (count($ids) === 1) {
+ $query->orWhere($column . ' = ?', $ids[0]);
+ } else {
+ $query->orWhere($column . ' IN (?)', $ids);
+ }
+
+ return $query;
+ }
+
+ protected function recheckAllObjects($groups)
+ {
+ $mappings = [];
+ $staticGroups = [];
+
+ if ($this->objects === null) {
+ $objects = $this->fetchAllObjects();
+ } else {
+ $objects = & $this->objects;
+ }
+
+ $times = array();
+
+ foreach ($objects as $object) {
+ if ($object->shouldBeRemoved()) {
+ continue;
+ }
+ if ($object->isTemplate()) {
+ continue;
+ }
+
+ $mt = microtime(true);
+ $id = $object->get('id');
+
+ DynamicApplyMatches::setType($this->type);
+ $resolver = DynamicApplyMatches::prepare($object);
+ foreach ($groups as $groupId => $filter) {
+ if ($resolver->matchesFilter($filter)) {
+ if (! array_key_exists($groupId, $mappings)) {
+ $mappings[$groupId] = [];
+ }
+ $mappings[$groupId][$id] = $id;
+ }
+ }
+
+ // can only be run reliably when updating for all groups
+ $groupNames = $object->get('groups');
+ if (empty($groupNames)) {
+ $groupNames = $object->listInheritedGroupNames();
+ }
+ foreach ($groupNames as $name) {
+ $groupId = $this->getGroupId($name);
+ if (! array_key_exists($groupId, $mappings)) {
+ $mappings[$groupId] = [];
+ }
+
+ $mappings[$groupId][$id] = $id;
+ $staticGroups[$groupId] = $groupId;
+ }
+
+ $times[] = (microtime(true) - $mt) * 1000;
+ }
+
+ $count = count($times);
+ $min = $max = $avg = 0;
+ if ($count > 0) {
+ $min = min($times);
+ $max = max($times);
+ $avg = array_sum($times) / $count;
+ }
+
+ Benchmark::measure(sprintf(
+ '%sgroup apply recalculated: objects=%d groups=%d min=%d max=%d avg=%d (in ms)',
+ $this->type,
+ $count,
+ count($groups),
+ $min,
+ $max,
+ $avg
+ ));
+
+ Benchmark::measure('Done with single assignments');
+
+ $this->newMappings = $mappings;
+ $this->staticGroups = $staticGroups;
+ }
+
+ protected function getAppliedGroups()
+ {
+ if (empty($this->groups)) {
+ return $this->fetchAppliedGroups();
+ } else {
+ return $this->buildAppliedGroups();
+ }
+ }
+
+ protected function buildAppliedGroups()
+ {
+ $list = array();
+ foreach ($this->groups as $id => $group) {
+ $list[$id] = $group->get('assign_filter');
+ }
+
+ return $this->parseFilters($list);
+ }
+
+ protected function fetchAppliedGroups()
+ {
+ $type = $this->getType();
+ $query = $this->db->select()->from(
+ array('hg' => "icinga_${type}group"),
+ array(
+ 'id',
+ 'assign_filter',
+ )
+ )->where("assign_filter IS NOT NULL AND assign_filter != ''");
+
+ return $this->parseFilters($this->db->fetchPairs($query));
+ }
+
+ /**
+ * Parsing a list of query strings to Filter
+ *
+ * @param string[] $list List of query strings
+ *
+ * @return Filter[]
+ */
+ protected function parseFilters($list)
+ {
+ return array_map(function ($s) {
+ return Filter::fromQueryString($s);
+ }, $list);
+ }
+
+ protected function getTableName()
+ {
+ $type = $this->getType();
+ return "icinga_${type}group_${type}";
+ }
+
+ protected function getResolvedTableName()
+ {
+ return $this->getTableName() . '_resolved';
+ }
+
+ /**
+ * @return $this
+ */
+ protected function beginTransaction()
+ {
+ if ($this->useTransactions) {
+ $this->db->beginTransaction();
+ }
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ */
+ protected function commit()
+ {
+ if ($this->useTransactions) {
+ $this->db->commit();
+ }
+
+ return $this;
+ }
+
+ /**
+ * @return IcingaObject[]
+ */
+ protected function getObjects()
+ {
+ if ($this->objects === null) {
+ $this->objects = $this->fetchAllObjects();
+ }
+
+ return $this->objects;
+ }
+
+ protected function fetchAllObjects()
+ {
+ return IcingaObject::loadAllByType($this->getType(), $this->connection);
+ }
+
+ protected function assertBeenLoadedFromDb(IcingaObject $object)
+ {
+ if (! is_int($object->get('id')) && ! ctype_digit($object->get('id'))) {
+ throw new LogicException(
+ 'Group resolver does not support unstored objects'
+ );
+ }
+ }
+}
diff --git a/library/Director/Objects/HostApplyMatches.php b/library/Director/Objects/HostApplyMatches.php
new file mode 100644
index 0000000..5feaee7
--- /dev/null
+++ b/library/Director/Objects/HostApplyMatches.php
@@ -0,0 +1,8 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+class HostApplyMatches extends ObjectApplyMatches
+{
+ protected static $type = 'host';
+}
diff --git a/library/Director/Objects/HostGroupMembershipResolver.php b/library/Director/Objects/HostGroupMembershipResolver.php
new file mode 100644
index 0000000..b597017
--- /dev/null
+++ b/library/Director/Objects/HostGroupMembershipResolver.php
@@ -0,0 +1,8 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+class HostGroupMembershipResolver extends GroupMembershipResolver
+{
+ protected $type = 'host';
+}
diff --git a/library/Director/Objects/IcingaApiUser.php b/library/Director/Objects/IcingaApiUser.php
new file mode 100644
index 0000000..bb4f9f8
--- /dev/null
+++ b/library/Director/Objects/IcingaApiUser.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c;
+
+class IcingaApiUser extends IcingaObject
+{
+ protected $table = 'icinga_apiuser';
+
+ protected $uuidColumn = 'uuid';
+
+ // TODO: Enable (and add table) if required
+ protected $supportsImports = false;
+
+ protected $defaultProperties = [
+ 'id' => null,
+ 'uuid' => null,
+ 'object_name' => null,
+ 'object_type' => null,
+ 'disabled' => 'n',
+ 'password' => null,
+ 'client_dn' => null,
+ 'permissions' => null,
+ ];
+
+ protected function renderPassword()
+ {
+ return c::renderKeyValue('password', c::renderString('***'));
+ }
+}
diff --git a/library/Director/Objects/IcingaArguments.php b/library/Director/Objects/IcingaArguments.php
new file mode 100644
index 0000000..e788da8
--- /dev/null
+++ b/library/Director/Objects/IcingaArguments.php
@@ -0,0 +1,442 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Countable;
+use Exception;
+use Icinga\Module\Director\IcingaConfig\IcingaConfigRenderer;
+use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c;
+use InvalidArgumentException;
+use Iterator;
+
+class IcingaArguments implements Iterator, Countable, IcingaConfigRenderer
+{
+ const COMMENT_DSL_UNSUPPORTED = '/* Icinga 2 does not export DSL function bodies via API */';
+
+ /** @var IcingaCommandArgument[] */
+ protected $storedArguments = [];
+
+ /** @var IcingaCommandArgument[] */
+ protected $arguments = [];
+
+ protected $modified = false;
+
+ protected $object;
+
+ private $position = 0;
+
+ protected $idx = [];
+
+ public function __construct(IcingaObject $object)
+ {
+ $this->object = $object;
+ }
+
+ #[\ReturnTypeWillChange]
+ public function count()
+ {
+ return count($this->arguments);
+ }
+
+ #[\ReturnTypeWillChange]
+ public function rewind()
+ {
+ $this->position = 0;
+ }
+
+ public function hasBeenModified()
+ {
+ return $this->modified;
+ }
+
+ #[\ReturnTypeWillChange]
+ public function current()
+ {
+ if (! $this->valid()) {
+ return null;
+ }
+
+ return $this->arguments[$this->idx[$this->position]];
+ }
+
+ #[\ReturnTypeWillChange]
+ public function key()
+ {
+ return $this->idx[$this->position];
+ }
+
+ #[\ReturnTypeWillChange]
+ public function next()
+ {
+ ++$this->position;
+ }
+
+ #[\ReturnTypeWillChange]
+ public function valid()
+ {
+ return array_key_exists($this->position, $this->idx);
+ }
+
+ public function get($key)
+ {
+ if (array_key_exists($key, $this->arguments)) {
+ if ($this->arguments[$key]->shouldBeRemoved()) {
+ return null;
+ }
+
+ return $this->arguments[$key];
+ }
+
+ return null;
+ }
+
+ public function set($key, $value)
+ {
+ if ($value === null) {
+ return $this->remove($key);
+ }
+
+ if ($value instanceof IcingaCommandArgument) {
+ $argument = $value;
+ } else {
+ $argument = IcingaCommandArgument::create(
+ $this->mungeCommandArgument($key, $value)
+ );
+ }
+
+ $argument->set('command_id', $this->object->get('id'));
+
+ $key = $argument->argument_name;
+ if (array_key_exists($key, $this->arguments)) {
+ $this->arguments[$key]->replaceWith($argument);
+ if ($this->arguments[$key]->hasBeenModified()) {
+ $this->modified = true;
+ }
+ } elseif (array_key_exists($key, $this->storedArguments)) {
+ $this->arguments[$key] = clone($this->storedArguments[$key]);
+ $this->arguments[$key]->replaceWith($argument);
+ if ($this->arguments[$key]->hasBeenModified()) {
+ $this->modified = true;
+ }
+ } else {
+ $this->add($argument);
+ $this->modified = true;
+ }
+
+ return $this;
+ }
+
+ protected function mungeCommandArgument($key, $value)
+ {
+ $attrs = [
+ 'argument_name' => (string) $key,
+ ];
+
+ $map = [
+ 'skip_key' => 'skip_key',
+ 'repeat_key' => 'repeat_key',
+ 'required' => 'required',
+ // 'order' => 'sort_order',
+ 'description' => 'description',
+ 'set_if' => 'set_if',
+ ];
+
+ $argValue = null;
+ if (is_object($value)) {
+ if (property_exists($value, 'order')) {
+ $attrs['sort_order'] = (string) $value->order;
+ }
+
+ foreach ($map as $apiKey => $dbKey) {
+ if (property_exists($value, $apiKey)) {
+ $attrs[$dbKey] = $value->$apiKey;
+ }
+ }
+ if (property_exists($value, 'type')) {
+ // argument is directly set as function, no further properties
+ if ($value->type === 'Function') {
+ $attrs['argument_value'] = self::COMMENT_DSL_UNSUPPORTED;
+ $attrs['argument_format'] = 'expression';
+ }
+ } elseif (property_exists($value, 'value')) {
+ // argument is a dictionary with further settings
+ if (is_object($value->value)) {
+ if ($value->value->type === 'Function' && property_exists($value->value, 'body')) {
+ // likely an export from Baskets that contains the actual function body
+ $attrs['argument_value'] = $value->value->body;
+ $attrs['argument_format'] = 'expression';
+ } elseif ($value->value->type === 'Function') {
+ $attrs['argument_value'] = self::COMMENT_DSL_UNSUPPORTED;
+ $attrs['argument_format'] = 'expression';
+ } else {
+ die('Unable to resolve command argument');
+ }
+ } else {
+ $argValue = $value->value;
+ if (is_string($argValue)) {
+ $attrs['argument_value'] = $argValue;
+ $attrs['argument_format'] = 'string';
+ } else {
+ $attrs['argument_value'] = $argValue;
+ $attrs['argument_format'] = 'json';
+ }
+ }
+ }
+ } else {
+ if (is_string($value)) {
+ $attrs['argument_value'] = $value;
+ $attrs['argument_format'] = 'string';
+ } else {
+ $attrs['argument_value'] = $value;
+ $attrs['argument_format'] = 'json';
+ }
+ }
+
+ if (array_key_exists('set_if', $attrs)) {
+ if (is_object($attrs['set_if']) && $attrs['set_if']->type === 'Function') {
+ $attrs['set_if'] = self::COMMENT_DSL_UNSUPPORTED;
+ $attrs['set_if_format'] = 'expression';
+ } elseif (property_exists($value, 'set_if_format')) {
+ if (in_array($value->set_if_format, ['string', 'expression', 'json'])) {
+ $attrs['set_if_format'] = $value->set_if_format;
+ }
+ }
+ }
+
+ return $attrs;
+ }
+
+ public function setArguments($arguments)
+ {
+ $arguments = (array) $arguments;
+
+ foreach ($arguments as $arg => $val) {
+ $this->set($arg, $val);
+ }
+
+ foreach (array_diff(
+ array_keys($this->arguments),
+ array_keys($arguments)
+ ) as $arg) {
+ if ($this->arguments[$arg]->hasBeenLoadedFromDb()) {
+ $this->arguments[$arg]->markForRemoval();
+ $this->modified = true;
+ } else {
+ unset($this->arguments[$arg]);
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Magic isset check
+ *
+ * @param string $argument
+ * @return boolean
+ */
+ public function __isset($argument)
+ {
+ return array_key_exists($argument, $this->arguments);
+ }
+
+ public function remove($argument)
+ {
+ if (array_key_exists($argument, $this->arguments)) {
+ $this->arguments[$argument]->markForRemoval();
+ $this->modified = true;
+ $this->refreshIndex();
+ }
+
+ return $this;
+ }
+
+ protected function refreshIndex()
+ {
+ ksort($this->arguments);
+ $this->idx = array_keys($this->arguments);
+ }
+
+ public function add(IcingaCommandArgument $argument)
+ {
+ $name = $argument->get('argument_name');
+ if (array_key_exists($name, $this->arguments)) {
+ // TODO: Fail unless $argument equals existing one
+ return $this;
+ }
+
+ $this->arguments[$name] = $argument;
+ $this->modified = true;
+ $this->refreshIndex();
+
+ return $this;
+ }
+
+ protected function getGroupTableName()
+ {
+ return $this->object->getTableName() . 'group';
+ }
+
+ protected function loadFromDb()
+ {
+ $db = $this->object->getDb();
+ $connection = $this->object->getConnection();
+
+ $table = $this->object->getTableName();
+ $query = $db->select()->from(
+ ['o' => $table],
+ []
+ )->join(
+ ['a' => 'icinga_command_argument'],
+ 'o.id = a.command_id',
+ '*'
+ )->where('o.object_name = ?', $this->object->getObjectName())
+ ->order('a.sort_order')->order('a.argument_name');
+
+ $this->arguments = IcingaCommandArgument::loadAll($connection, $query, 'argument_name');
+ $this->cloneStored();
+ $this->refreshIndex();
+
+ return $this;
+ }
+
+ public function toPlainObject(
+ $resolved = false,
+ $skipDefaults = false,
+ array $chosenProperties = null,
+ $resolveIds = true
+ ) {
+ if ($chosenProperties !== null) {
+ throw new InvalidArgumentException(
+ 'IcingaArguments does not support chosenProperties[]'
+ );
+ }
+
+ $args = [];
+ foreach ($this->arguments as $arg) {
+ if ($arg->shouldBeRemoved()) {
+ continue;
+ }
+
+ $args[$arg->get('argument_name')] = $arg->toPlainObject(
+ $resolved,
+ $skipDefaults,
+ null,
+ $resolveIds
+ );
+ }
+
+ return $args;
+ }
+
+ public function toUnmodifiedPlainObject()
+ {
+ $args = [];
+ foreach ($this->storedArguments as $key => $arg) {
+ $args[$arg->argument_name] = $arg->toPlainObject();
+ }
+
+ return $args;
+ }
+
+ protected function cloneStored()
+ {
+ $this->storedArguments = [];
+ foreach ($this->arguments as $k => $v) {
+ $this->storedArguments[$k] = clone($v);
+ }
+ }
+
+ public static function loadForStoredObject(IcingaObject $object)
+ {
+ $arguments = new static($object);
+ return $arguments->loadFromDb();
+ }
+
+ public function setBeingLoadedFromDb()
+ {
+ foreach ($this->arguments as $argument) {
+ $argument->setBeingLoadedFromDb();
+ }
+ $this->refreshIndex();
+ $this->cloneStored();
+ }
+
+ /**
+ * @return $this
+ * @throws \Icinga\Module\Director\Exception\DuplicateKeyException
+ */
+ public function store()
+ {
+ $db = $this->object->getConnection();
+ $deleted = [];
+ foreach ($this->arguments as $key => $argument) {
+ if ($argument->shouldBeRemoved()) {
+ $deleted[] = $key;
+ } else {
+ if ($argument->hasBeenModified()) {
+ if ($argument->hasBeenLoadedFromDb()) {
+ $argument->setLoadedProperty('command_id', $this->object->get('id'));
+ } else {
+ $argument->set('command_id', $this->object->get('id'));
+ }
+ $argument->store($db);
+ }
+ }
+ }
+
+ foreach ($deleted as $key) {
+ $argument = $this->arguments[$key];
+ $argument->setLoadedProperty('command_id', $this->object->get('id'));
+ $argument->setConnection($this->object->getConnection());
+ $argument->delete();
+ unset($this->arguments[$key]);
+ }
+
+ $this->cloneStored();
+
+ return $this;
+ }
+
+ public function toConfigString()
+ {
+ if (empty($this->arguments)) {
+ return '';
+ }
+
+ $args = [];
+ foreach ($this->arguments as $arg) {
+ if ($arg->shouldBeRemoved()) {
+ continue;
+ }
+
+ $args[$arg->get('argument_name')] = $arg->toConfigString();
+ }
+ return c::renderKeyOperatorValue('arguments', '+=', c::renderDictionary($args));
+ }
+
+ public function __toString()
+ {
+ try {
+ return $this->toConfigString();
+ } catch (Exception $e) {
+ trigger_error($e);
+ $previousHandler = set_exception_handler(
+ function () {
+ }
+ );
+ restore_error_handler();
+ if ($previousHandler !== null) {
+ call_user_func($previousHandler, $e);
+ die();
+ } else {
+ die($e->getMessage());
+ }
+ }
+ }
+
+ public function toLegacyConfigString()
+ {
+ return 'UNSUPPORTED';
+ }
+}
diff --git a/library/Director/Objects/IcingaCommand.php b/library/Director/Objects/IcingaCommand.php
new file mode 100644
index 0000000..35f38a4
--- /dev/null
+++ b/library/Director/Objects/IcingaCommand.php
@@ -0,0 +1,365 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\DirectorObject\Automation\ExportInterface;
+use Icinga\Module\Director\Exception\DuplicateKeyException;
+use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c;
+use Icinga\Module\Director\IcingaConfig\IcingaLegacyConfigHelper as c1;
+use Icinga\Module\Director\Objects\Extension\Arguments;
+use Zend_Db_Select as DbSelect;
+
+class IcingaCommand extends IcingaObject implements ObjectWithArguments, ExportInterface
+{
+ use Arguments;
+
+ protected $table = 'icinga_command';
+
+ protected $type = 'CheckCommand';
+
+ protected $uuidColumn = 'uuid';
+
+ protected $defaultProperties = [
+ 'id' => null,
+ 'uuid' => null,
+ 'object_name' => null,
+ 'object_type' => null,
+ 'disabled' => 'n',
+ 'methods_execute' => null,
+ 'command' => null,
+ 'timeout' => null,
+ 'zone_id' => null,
+ 'is_string' => null,
+ ];
+
+ protected $booleans = [
+ 'is_string' => 'is_string',
+ ];
+
+ protected $supportsCustomVars = true;
+
+ protected $supportsFields = true;
+
+ protected $supportsImports = true;
+
+ protected $supportedInLegacy = true;
+
+ protected $intervalProperties = [
+ 'timeout' => 'timeout',
+ ];
+
+ protected $relations = [
+ 'zone' => 'IcingaZone',
+ ];
+
+ protected static $pluginDir;
+
+ protected $hiddenExecuteTemplates = [
+ 'PluginCheck' => 'plugin-check-command',
+ 'PluginNotification' => 'plugin-notification-command',
+ 'PluginEvent' => 'plugin-event-command',
+
+ // Special, internal:
+ 'IcingaCheck' => 'icinga-check-command',
+ 'ClusterCheck' => 'cluster-check-command',
+ 'ClusterZoneCheck' => 'plugin-check-command',
+ 'IdoCheck' => 'ido-check-command',
+ 'RandomCheck' => 'random-check-command',
+ ];
+
+ /**
+ * Render the 'medhods_execute' property as 'execute'
+ *
+ * Execute is a reserved word in SQL, column name was prefixed
+ *
+ * Avoid complaints for method names with underscore:
+ * @codingStandardsIgnoreStart
+ *
+ * @return string
+ */
+ protected function renderMethods_execute()
+ {
+ // @codingStandardsIgnoreEnd
+ return '';
+ }
+
+ protected function renderObjectHeader()
+ {
+ if ($execute = $this->get('methods_execute')) {
+ $itlImport = sprintf(
+ ' import "%s"' . "\n",
+ $this->hiddenExecuteTemplates[$execute]
+ );
+ } else {
+ $itlImport = '';
+ }
+
+ $execute = $this->getSingleResolvedProperty('methods_execute');
+ if ($execute === 'PluginNotification') {
+ return $this->renderObjectHeaderWithType('NotificationCommand') . $itlImport;
+ } elseif ($execute === 'PluginEvent') {
+ return $this->renderObjectHeaderWithType('EventCommand') . $itlImport;
+ } else {
+ return parent::renderObjectHeader() . $itlImport;
+ }
+ }
+
+ /**
+ * @param $type
+ * @return string
+ */
+ protected function renderObjectHeaderWithType($type)
+ {
+ return sprintf(
+ "%s %s %s {\n",
+ $this->getObjectTypeName(),
+ $type,
+ c::renderString($this->getObjectName())
+ );
+ }
+
+ public function mungeCommand($value)
+ {
+ if (is_array($value)) {
+ $value = implode(' ', $value);
+ } elseif (is_object($value)) {
+ // { type => Function } -> really??
+ return null;
+ // return $value;
+ }
+
+ if (self::$pluginDir !== null) {
+ if (($pos = strpos($value, self::$pluginDir)) === 0) {
+ $value = substr($value, strlen(self::$pluginDir) + 1);
+ }
+ }
+
+ return $value;
+ }
+
+ public function getNextSkippableKeyName()
+ {
+ $key = $this->makeSkipKey();
+ $cnt = 1;
+ while (isset($this->arguments()->$key)) {
+ $cnt++;
+ $key = $this->makeSkipKey($cnt);
+ }
+
+ return $key;
+ }
+
+ protected function makeSkipKey($num = null)
+ {
+ if ($num === null) {
+ return '(no key)';
+ }
+
+ return sprintf('(no key.%d)', $num);
+ }
+
+ protected function prefersGlobalZone()
+ {
+ return true;
+ }
+
+ /**
+ * @return string
+ * @throws \Zend_Db_Select_Exception
+ */
+ public function countDirectUses()
+ {
+ $db = $this->getDb();
+ $id = (int) $this->get('id');
+
+ $qh = $db->select()->from(
+ array('h' => 'icinga_host'),
+ array('cnt' => 'COUNT(*)')
+ )->where('h.check_command_id = ?', $id)
+ ->orWhere('h.event_command_id = ?', $id);
+ $qs = $db->select()->from(
+ array('s' => 'icinga_service'),
+ array('cnt' => 'COUNT(*)')
+ )->where('s.check_command_id = ?', $id)
+ ->orWhere('s.event_command_id = ?', $id);
+ $qn = $db->select()->from(
+ array('n' => 'icinga_notification'),
+ array('cnt' => 'COUNT(*)')
+ )->where('n.command_id = ?', $id);
+ $query = $db->select()->union(
+ [$qh, $qs, $qn],
+ DbSelect::SQL_UNION_ALL
+ );
+
+ return $db->fetchOne($db->select()->from(
+ ['all_cnts' => $query],
+ ['cnt' => 'SUM(cnt)']
+ ));
+ }
+
+ /**
+ * @return bool
+ * @throws \Zend_Db_Select_Exception
+ */
+ public function isInUse()
+ {
+ return $this->countDirectUses() > 0;
+ }
+
+ public function getUniqueIdentifier()
+ {
+ return $this->getObjectName();
+ }
+
+ /**
+ * @return object
+ * @deprecated please use \Icinga\Module\Director\Data\Exporter
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function export()
+ {
+ $props = (array) $this->toPlainObject();
+ if (isset($props['arguments'])) {
+ foreach ($props['arguments'] as $key => $argument) {
+ if (property_exists($argument, 'command_id')) {
+ unset($props['arguments'][$key]->command_id);
+ }
+ }
+ }
+ $props['fields'] = $this->loadFieldReferences();
+ ksort($props);
+
+ return (object) $props;
+ }
+
+ /**
+ * @param $plain
+ * @param Db $db
+ * @param bool $replace
+ * @return IcingaCommand
+ * @throws DuplicateKeyException
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public static function import($plain, Db $db, $replace = false)
+ {
+ $properties = (array) $plain;
+ $name = $properties['object_name'];
+ $key = $name;
+
+ if ($replace && static::exists($key, $db)) {
+ $object = static::load($key, $db);
+ } elseif (static::exists($key, $db)) {
+ throw new DuplicateKeyException(
+ 'Command "%s" already exists',
+ $name
+ );
+ } else {
+ $object = static::create([], $db);
+ }
+
+ unset($properties['fields']);
+ $object->setProperties($properties);
+
+ return $object;
+ }
+
+ /**
+ * @deprecated please use \Icinga\Module\Director\Data\FieldReferenceLoader
+ * @return array
+ */
+ protected function loadFieldReferences()
+ {
+ $db = $this->getDb();
+
+ $res = $db->fetchAll(
+ $db->select()->from([
+ 'cf' => 'icinga_command_field'
+ ], [
+ 'cf.datafield_id',
+ 'cf.is_required',
+ 'cf.var_filter',
+ ])->join(['df' => 'director_datafield'], 'df.id = cf.datafield_id', [])
+ ->where('command_id = ?', $this->get('id'))
+ ->order('varname ASC')
+ );
+
+ if (empty($res)) {
+ return [];
+ } else {
+ foreach ($res as $field) {
+ $field->datafield_id = (int) $field->datafield_id;
+ }
+
+ return $res;
+ }
+ }
+
+ protected function renderCommand()
+ {
+ $command = $this->get('command');
+ $prefix = '';
+ if (preg_match('~^([A-Z][A-Za-z0-9_]+\s\+\s)(.+?)$~', $command, $m)) {
+ $prefix = $m[1];
+ $command = $m[2];
+ } elseif (! $this->isAbsolutePath($command)) {
+ $prefix = 'PluginDir + ';
+ $command = '/' . $command;
+ }
+
+ $inherited = $this->getInheritedProperties();
+
+ if ($this->get('is_string') === 'y' || ($this->get('is_string') === null
+ && property_exists($inherited, 'is_string') && $inherited->is_string === 'y')) {
+ return c::renderKeyValue('command', $prefix . c::renderString($command));
+ } else {
+ $parts = preg_split('/\s+/', $command, -1, PREG_SPLIT_NO_EMPTY);
+ array_unshift($parts, c::alreadyRendered($prefix . c::renderString(array_shift($parts))));
+
+ return c::renderKeyValue('command', c::renderArray($parts));
+ }
+ }
+
+ /**
+ * @codingStandardsIgnoreStart
+ */
+ protected function renderIs_string()
+ {
+ // @codingStandardsIgnoreEnd
+ return '';
+ }
+
+ protected function isAbsolutePath($path)
+ {
+ return $path[0] === '/'
+ || $path[0] === '\\'
+ || preg_match('/^[A-Za-z]:\\\/', substr($path, 0, 3))
+ || preg_match('/^%[A-Z][A-Za-z0-9\(\)-]*%/', $path);
+ }
+
+ public static function setPluginDir($pluginDir)
+ {
+ self::$pluginDir = $pluginDir;
+ }
+
+ public function getLegacyObjectType()
+ {
+ // there is only one type of command in Icinga 1.x
+ return 'command';
+ }
+
+ protected function renderLegacyCommand()
+ {
+ $command = $this->get('command');
+ if (preg_match('~^(\$USER\d+\$/?)(.+)$~', $command)) {
+ // should be fine, since the user decided to use a macro
+ } elseif (! $this->isAbsolutePath($command)) {
+ $command = '$USER1$/'.$command;
+ }
+
+ return c1::renderKeyValue(
+ $this->getLegacyObjectType().'_line',
+ c1::renderString($command)
+ );
+ }
+}
diff --git a/library/Director/Objects/IcingaCommandArgument.php b/library/Director/Objects/IcingaCommandArgument.php
new file mode 100644
index 0000000..96101ce
--- /dev/null
+++ b/library/Director/Objects/IcingaCommandArgument.php
@@ -0,0 +1,263 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c;
+use RuntimeException;
+
+class IcingaCommandArgument extends IcingaObject
+{
+ protected $keyName = ['command_id', 'argument_name'];
+
+ protected $autoincKeyName = 'id';
+
+ protected $table = 'icinga_command_argument';
+
+ protected $supportsImports = false;
+
+ protected $booleans = array(
+ 'skip_key' => 'skip_key',
+ 'repeat_key' => 'repeat_key',
+ 'required' => 'required'
+ );
+
+ protected $defaultProperties = array(
+ 'id' => null,
+ 'command_id' => null,
+ 'argument_name' => null,
+ 'argument_value' => null,
+ 'argument_format' => null,
+ 'key_string' => null,
+ 'description' => null,
+ 'skip_key' => null,
+ 'set_if' => null,
+ 'sort_order' => null,
+ 'repeat_key' => null,
+ 'set_if_format' => null,
+ 'required' => null,
+ );
+
+ public function onInsert()
+ {
+ // No log right now, we have to handle "sub-objects"
+ }
+
+ public function onUpdate()
+ {
+ // No log right now, we have to handle "sub-objects"
+ }
+
+ public function onDelete()
+ {
+ // No log right now, we have to handle "sub-objects"
+ }
+
+ public function isSkippingKey()
+ {
+ return $this->get('skip_key') === 'y' || $this->get('argument_name') === null;
+ }
+
+ // Preserve is not supported
+ public function replaceWith(IcingaObject $object, $preserve = null)
+ {
+ $this->setProperties((array) $object->toPlainObject(
+ false,
+ false,
+ null,
+ false
+ ));
+ return $this;
+ }
+
+ protected function makePlainArgumentValue($value, $format)
+ {
+ if ($format === 'expression') {
+ return (object) [
+ 'type' => 'Function',
+ // TODO: Not for dummy comment
+ 'body' => $value
+ ];
+ } else {
+ // json or string
+ return $value;
+ }
+ }
+
+ protected function extractValueFromPlain($plain)
+ {
+ if ($plain->argument_value) {
+ return $this->makePlainArgumentValue(
+ $plain->argument_value,
+ $plain->argument_format
+ );
+ } else {
+ return null;
+ }
+ }
+
+ protected function transformPlainArgumentValue($plain)
+ {
+ if (property_exists($plain, 'argument_value')) {
+ if (property_exists($plain, 'argument_format')) {
+ $format = $plain->argument_format;
+ } else {
+ $format = 'string';
+ }
+ $plain->value = $this->makePlainArgumentValue(
+ $plain->argument_value,
+ $format
+ );
+ unset($plain->argument_value);
+ unset($plain->argument_format);
+ }
+ }
+
+ public function toCompatPlainObject()
+ {
+ $plain = parent::toPlainObject(
+ false,
+ true,
+ null,
+ false
+ );
+
+ unset($plain->id);
+ unset($plain->argument_name);
+ if (! isset($plain->argument_value)) {
+ unset($plain->argument_format);
+ }
+ if (! isset($plain->set_if)) {
+ unset($plain->set_if_format);
+ }
+
+ $this->transformPlainArgumentValue($plain);
+ unset($plain->command_id);
+
+ // Will happen only combined with $skipDefaults
+ if (array_keys((array) $plain) === ['value']) {
+ return $plain->value;
+ } else {
+ if (property_exists($plain, 'sort_order') && $plain->sort_order !== null) {
+ $plain->order = $plain->sort_order;
+ unset($plain->sort_order);
+ }
+
+ return $plain;
+ }
+ }
+
+ public function toFullPlainObject($skipDefaults = false)
+ {
+ $plain = parent::toPlainObject(
+ false,
+ $skipDefaults,
+ null,
+ false
+ );
+
+ unset($plain->id);
+
+ return $plain;
+ }
+
+ public function toPlainObject(
+ $resolved = false,
+ $skipDefaults = false,
+ array $chosenProperties = null,
+ $resolveIds = true,
+ $keepId = false
+ ) {
+ if ($resolved) {
+ throw new RuntimeException(
+ 'A single CommandArgument cannot be resolved'
+ );
+ }
+
+ if ($chosenProperties) {
+ throw new RuntimeException(
+ 'IcingaCommandArgument does not support chosenProperties[]'
+ );
+ }
+
+ if ($keepId) {
+ throw new RuntimeException(
+ 'IcingaCommandArgument does not support $keepId'
+ );
+ }
+
+ // $resolveIds is misused here
+ if ($resolveIds) {
+ return $this->toCompatPlainObject();
+ } else {
+ return $this->toFullPlainObject($skipDefaults);
+ }
+ }
+
+ public function toConfigString()
+ {
+ $data = array();
+ $value = $this->get('argument_value');
+ if ($value) {
+ switch ($this->get('argument_format')) {
+ case 'string':
+ $data['value'] = c::renderString($value);
+ break;
+ case 'json':
+ if (is_object($value)) {
+ $data['value'] = c::renderDictionary($value);
+ } elseif (is_array($value)) {
+ $data['value'] = c::renderArray($value);
+ } elseif (is_null($value)) {
+ // TODO: recheck all this. I bet we never reach this:
+ $data['value'] = 'null';
+ } elseif (is_bool($value)) {
+ $data['value'] = c::renderBoolean($value);
+ } else {
+ $data['value'] = $value;
+ }
+ break;
+ case 'expression':
+ $data['value'] = c::renderExpression($value);
+ break;
+ }
+ }
+
+ if ($this->get('sort_order') !== null) {
+ $data['order'] = $this->get('sort_order');
+ }
+
+ if (null !== $this->get('set_if')) {
+ switch ($this->get('set_if_format')) {
+ case 'expression':
+ $data['set_if'] = c::renderExpression($this->get('set_if'));
+ break;
+ case 'string':
+ default:
+ $data['set_if'] = c::renderString($this->get('set_if'));
+ break;
+ }
+ }
+
+ if (null !== $this->get('required')) {
+ $data['required'] = c::renderBoolean($this->get('required'));
+ }
+
+ if ($this->isSkippingKey()) {
+ $data['skip_key'] = c::renderBoolean('y');
+ }
+
+ if (null !== $this->get('repeat_key')) {
+ $data['repeat_key'] = c::renderBoolean($this->get('repeat_key'));
+ }
+
+ if (null !== $this->get('description')) {
+ $data['description'] = c::renderString($this->get('description'));
+ }
+
+ if (array_keys($data) === ['value']) {
+ return $data['value'];
+ } else {
+ return c::renderDictionary($data);
+ }
+ }
+}
diff --git a/library/Director/Objects/IcingaCommandField.php b/library/Director/Objects/IcingaCommandField.php
new file mode 100644
index 0000000..086cb56
--- /dev/null
+++ b/library/Director/Objects/IcingaCommandField.php
@@ -0,0 +1,17 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+class IcingaCommandField extends IcingaObjectField
+{
+ protected $keyName = array('command_id', 'datafield_id');
+
+ protected $table = 'icinga_command_field';
+
+ protected $defaultProperties = array(
+ 'command_id' => null,
+ 'datafield_id' => null,
+ 'is_required' => null,
+ 'var_filter' => null,
+ );
+}
diff --git a/library/Director/Objects/IcingaDependency.php b/library/Director/Objects/IcingaDependency.php
new file mode 100644
index 0000000..c9d9b89
--- /dev/null
+++ b/library/Director/Objects/IcingaDependency.php
@@ -0,0 +1,631 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Icinga\Exception\ConfigurationError;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\DirectorObject\Automation\ExportInterface;
+use Icinga\Module\Director\Exception\DuplicateKeyException;
+use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c;
+use Icinga\Exception\NotFoundError;
+use Icinga\Data\Filter\Filter;
+
+class IcingaDependency extends IcingaObject implements ExportInterface
+{
+ protected $table = 'icinga_dependency';
+
+ protected $defaultProperties = [
+ 'id' => null,
+ 'uuid' => null,
+ 'object_name' => null,
+ 'object_type' => null,
+ 'disabled' => 'n',
+ 'apply_to' => null,
+ 'parent_host_id' => null,
+ 'parent_host_var' => null,
+ 'parent_service_id' => null,
+ 'child_host_id' => null,
+ 'child_service_id' => null,
+ 'disable_checks' => null,
+ 'disable_notifications' => null,
+ 'ignore_soft_states' => null,
+ 'period_id' => null,
+ 'zone_id' => null,
+ 'assign_filter' => null,
+ 'parent_service_by_name' => null,
+ ];
+
+ protected $uuidColumn = 'uuid';
+
+ protected $supportsCustomVars = false;
+
+ protected $supportsImports = true;
+
+ protected $supportsApplyRules = true;
+
+ /**
+ * @internal
+ * @var bool
+ */
+ protected $renderApplyForArray = false;
+
+ protected $relatedSets = [
+ 'states' => 'StateFilterSet',
+ ];
+
+ protected $relations = [
+ 'zone' => 'IcingaZone',
+ 'parent_host' => 'IcingaHost',
+ 'parent_service' => 'IcingaService',
+ 'child_host' => 'IcingaHost',
+ 'child_service' => 'IcingaService',
+ 'period' => 'IcingaTimePeriod',
+ ];
+
+ protected $booleans = [
+ 'disable_checks' => 'disable_checks',
+ 'disable_notifications' => 'disable_notifications',
+ 'ignore_soft_states' => 'ignore_soft_states'
+ ];
+
+ protected $propertiesNotForRendering = [
+ 'id',
+ 'object_name',
+ 'object_type',
+ 'apply_to',
+ ];
+
+ public function getUniqueIdentifier()
+ {
+ return $this->getObjectName();
+ }
+
+ /**
+ * @return object
+ * @deprecated please use \Icinga\Module\Director\Data\Exporter
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function export()
+ {
+ $props = (array) $this->toPlainObject();
+ ksort($props);
+
+ return (object) $props;
+ }
+
+ /**
+ * @param $plain
+ * @param Db $db
+ * @param bool $replace
+ * @return static
+ * @throws DuplicateKeyException
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public static function import($plain, Db $db, $replace = false)
+ {
+ $properties = (array) $plain;
+ $name = $properties['object_name'];
+ $key = $name;
+
+ if ($replace && static::exists($key, $db)) {
+ $object = static::load($key, $db);
+ } elseif (static::exists($key, $db)) {
+ throw new DuplicateKeyException(
+ 'Dependency "%s" already exists',
+ $name
+ );
+ } else {
+ $object = static::create([], $db);
+ }
+
+ $object->setProperties($properties);
+
+ return $object;
+ }
+
+ public function parentHostIsVar()
+ {
+ return $this->get('parent_host_var') !== null;
+ }
+
+ /**
+ * @return string
+ * @throws ConfigurationError
+ */
+ protected function renderObjectHeader()
+ {
+ if ($this->isApplyRule()) {
+ if (($to = $this->get('apply_to')) === null) {
+ throw new ConfigurationError(
+ 'Applied dependency "%s" has no valid object type',
+ $this->getObjectName()
+ );
+ }
+
+ if ($this->renderApplyForArray) {
+ return $this->renderArrayObjectHeader($to);
+ }
+
+ return $this->renderSingleObjectHeader($to);
+ }
+
+ return parent::renderObjectHeader();
+ }
+
+ protected function renderSingleObjectHeader($to)
+ {
+ return sprintf(
+ "%s %s %s to %s {\n",
+ $this->getObjectTypeName(),
+ $this->getType(),
+ c::renderString($this->getObjectName()),
+ ucfirst($to)
+ );
+ }
+
+ protected function renderArrayObjectHeader($to)
+ {
+ return sprintf(
+ "%s %s %s for (host_parent_name in %s) to %s {\n",
+ $this->getObjectTypeName(),
+ $this->getType(),
+ c::renderString($this->getObjectName()),
+ $this->get('parent_host_var'),
+ ucfirst($to)
+ );
+ }
+
+ /**
+ * @return string
+ */
+ protected function renderSuffix()
+ {
+ if (! $this->parentHostIsVar()) {
+ return parent::renderSuffix();
+ }
+
+ if ((string) $this->get('assign_filter') !== '') {
+ $suffix = parent::renderSuffix();
+ } else {
+ $suffix = ' assign where ' . $this->renderAssignFilterExtension('')
+ . "\n" . parent::renderSuffix();
+ }
+
+ if ($this->renderApplyForArray) {
+ return $suffix;
+ }
+
+ return $suffix . $this->renderApplyForArrayClone();
+ }
+
+ protected function renderApplyForArrayClone()
+ {
+ $clone = clone($this);
+ $clone->renderApplyForArray = true;
+
+ return $clone->toConfigString();
+ }
+
+ public function isApplyForArrayClone()
+ {
+ return $this->renderApplyForArray;
+ }
+
+ /**
+ * @codingStandardsIgnoreStart
+ */
+ public function renderAssign_Filter()
+ {
+ if ($this->parentHostIsVar()) {
+ return preg_replace(
+ '/\n$/m',
+ $this->renderAssignFilterExtension() . "\n",
+ parent::renderAssign_Filter()
+ );
+ }
+
+ return parent::renderAssign_Filter();
+ }
+
+ protected function renderAssignFilterExtension($pre = ' && ')
+ {
+ $varName = $this->get('parent_host_var');
+ if ($this->renderApplyForArray) {
+ return sprintf('%stypeof(%s) == Array', $pre, $varName);
+ }
+
+ return sprintf('%stypeof(%s) == String', $pre, $varName);
+ }
+
+ protected function setKey($key)
+ {
+ // TODO: Check if this method can be removed
+ if (is_int($key)) {
+ $this->id = $key;
+ } elseif (is_array($key)) {
+ $keys = [
+ 'id',
+ 'parent_host_id',
+ 'parent_service_id',
+ 'child_host_id',
+ 'child_service_id',
+ 'object_name'
+ ];
+
+ foreach ($keys as $k) {
+ if (array_key_exists($k, $key)) {
+ $this->set($k, $key[$k]);
+ }
+ }
+ } else {
+ return parent::setKey($key);
+ }
+
+ return $this;
+ }
+
+ protected function renderAssignments()
+ {
+ // TODO: this will never be reached
+ if ($this->hasBeenAssignedToServiceApply()) {
+ /** @var IcingaService $tmpService */
+ $tmpService = $this->getRelatedObject(
+ 'child_service',
+ $this->get('child_service_id')
+ );
+ // TODO: fix this, will crash:
+ $assigns = $tmpService->assignments()->toConfigString();
+
+ $filter = sprintf(
+ '%s && service.name == "%s"',
+ trim($assigns),
+ $this->get('child_service')
+ );
+ return "\n " . $filter . "\n";
+ }
+
+ if ($this->hasBeenAssignedToHostTemplateService()) {
+ $filter = sprintf(
+ 'assign where "%s" in host.templates && service.name == "%s"',
+ $this->get('child_host'),
+ $this->get('child_service')
+ );
+ return "\n " . $filter . "\n";
+ }
+ if ($this->hasBeenAssignedToHostTemplate()) {
+ $filter = sprintf(
+ 'assign where "%s" in host.templates',
+ $this->get('child_host')
+ );
+ return "\n " . $filter . "\n";
+ }
+
+ if ($this->hasBeenAssignedToServiceTemplate()) {
+ $filter = sprintf(
+ 'assign where "%s" in service.templates',
+ $this->get('child_service')
+ );
+ return "\n " . $filter . "\n";
+ }
+
+ return parent::renderAssignments();
+ }
+
+ protected function hasBeenAssignedToHostTemplate()
+ {
+ try {
+ $id = $this->get('child_host_id');
+ return $id && $this->getRelatedObject(
+ 'child_host',
+ $id
+ )->isTemplate();
+ } catch (NotFoundError $e) {
+ return false;
+ }
+ }
+
+ protected function hasBeenAssignedToServiceTemplate()
+ {
+ try {
+ $id = $this->get('child_service_id');
+ return $id && $this->getRelatedObject(
+ 'child_service',
+ $id
+ )->isTemplate();
+ } catch (NotFoundError $e) {
+ return false;
+ }
+ }
+
+ protected function hasBeenAssignedToHostTemplateService()
+ {
+ if (!$this->hasBeenAssignedToHostTemplate()) {
+ return false;
+ }
+ try {
+ $id = $this->get('child_service_id');
+ return $id && $this->getRelatedObject(
+ 'child_service',
+ $id
+ )->isObject();
+ } catch (NotFoundError $e) {
+ return false;
+ }
+ }
+
+ protected function hasBeenAssignedToServiceApply()
+ {
+ try {
+ $id = $this->get('child_service_id');
+ return $id && $this->getRelatedObject(
+ 'child_service',
+ $id
+ )->isApplyRule();
+ } catch (NotFoundError $e) {
+ return false;
+ }
+ }
+
+ /**
+ * Render child_host_id as host_name
+ *
+ * Avoid complaints for method names with underscore:
+ * @codingStandardsIgnoreStart
+ *
+ * @return string
+ */
+ public function renderChild_host_id()
+ {
+ // @codingStandardsIgnoreEnd
+
+ if ($this->hasBeenAssignedToHostTemplate()) {
+ return '';
+ }
+
+ return $this->renderRelationProperty(
+ 'child_host',
+ $this->get('child_host_id'),
+ 'child_host_name'
+ );
+ }
+
+ /**
+ * Render parent_host_id as parent_host_name
+ *
+ * Avoid complaints for method names with underscore:
+ * @codingStandardsIgnoreStart
+ *
+ * @return string
+ */
+ public function renderParent_host_id()
+ {
+ // @codingStandardsIgnoreEnd
+ return $this->renderRelationProperty(
+ 'parent_host',
+ $this->get('parent_host_id'),
+ 'parent_host_name'
+ );
+ }
+
+ /**
+ * Render parent_host_var as parent_host
+ * @codingStandardsIgnoreStart
+ *
+ * @return string
+ */
+ public function renderParent_host_var()
+ {
+ // @codingStandardsIgnoreEnd
+ if ($this->renderApplyForArray) {
+ return c::renderKeyValue(
+ 'parent_host_name',
+ 'host_parent_name'
+ );
+ }
+
+ // @codingStandardsIgnoreEnd
+ return c::renderKeyValue(
+ 'parent_host_name',
+ $this->get('parent_host_var')
+ );
+ }
+
+ /**
+ * Render child_service_id as host_name
+ *
+ * Avoid complaints for method names with underscore:
+ * @codingStandardsIgnoreStart
+ *
+ * @return string
+ */
+ public function renderChild_service_id()
+ {
+ // @codingStandardsIgnoreEnd
+ if ($this->hasBeenAssignedToServiceTemplate()
+ || $this->hasBeenAssignedToHostTemplateService()
+ || $this->hasBeenAssignedToServiceApply()
+ ) {
+ return '';
+ }
+
+ return $this->renderRelationProperty(
+ 'child_service',
+ $this->get('child_service_id'),
+ 'child_service_name'
+ );
+ }
+
+ /**
+ * Render parent_service_id as parent_service_name
+ *
+ * Avoid complaints for method names with underscore:
+ * @codingStandardsIgnoreStart
+ *
+ * @return string
+ */
+ public function renderParent_service_id()
+ {
+ return $this->renderRelationProperty(
+ 'parent_service',
+ $this->get('parent_service_id'),
+ 'parent_service_name'
+ );
+ }
+
+ //
+ /**
+ * Render parent_service_by_name as parent_service_name
+ *
+ * Special case for parent service set as plain string for Apply rules
+ *
+ * @codingStandardsIgnoreStart
+ *
+ * @return string
+ */
+ public function renderParent_service_by_name()
+ {
+ // @codingStandardsIgnoreEnd
+ return c::renderKeyValue(
+ 'parent_service_name',
+ c::renderString($this->get('parent_service_by_name'))
+ );
+ }
+
+ public function isApplyRule()
+ {
+ if ($this->hasBeenAssignedToHostTemplate()
+ || $this->hasBeenAssignedToServiceTemplate()
+ || $this->hasBeenAssignedToServiceApply()
+ ) {
+ return true;
+ }
+
+ return parent::isApplyRule();
+ }
+
+ protected function resolveUnresolvedRelatedProperty($name)
+ {
+ $short = substr($name, 0, -3);
+ /** @var IcingaObject $class */
+ $class = $this->getRelationClass($short);
+ $objKey = $this->unresolvedRelatedProperties[$name];
+
+ # related services need array key
+ if ($class === IcingaService::class) {
+ if ($name === 'parent_service_id' && $this->get('object_type') === 'apply') {
+ //special case , parent service can be set as simple string for Apply
+ if ($this->properties['parent_host_id'] === null) {
+ $this->reallySet(
+ 'parent_service_by_name',
+ $this->unresolvedRelatedProperties[$name]
+ );
+ $this->reallySet('parent_service_id', null);
+ unset($this->unresolvedRelatedProperties[$name]);
+ return;
+ }
+ }
+
+ $this->reallySet('parent_service_by_name', null);
+ $hostIdProperty = str_replace('service', 'host', $name);
+ if (isset($this->properties[$hostIdProperty])) {
+ $objKey = [
+ 'host_id' => $this->properties[$hostIdProperty],
+ 'object_name' => $this->unresolvedRelatedProperties[$name]
+ ];
+ } else {
+ $objKey = [
+ 'host_id' => null,
+ 'object_name' => $this->unresolvedRelatedProperties[$name]
+ ];
+ }
+
+ try {
+ $class::load($objKey, $this->connection);
+ } catch (NotFoundError $e) {
+ // Not a simple service on host
+ // Hunt through inherited services, use service assigned to
+ // template if found
+ $tmpHost = IcingaHost::loadWithAutoIncId(
+ $this->properties[$hostIdProperty],
+ $this->connection
+ );
+
+ // services for applicable templates
+ $resolver = $tmpHost->templateResolver();
+ foreach ($resolver->fetchResolvedParents() as $template_obj) {
+ $objKey = [
+ 'host_id' => $template_obj->id,
+ 'object_name' => $this->unresolvedRelatedProperties[$name]
+ ];
+ try {
+ $object = $class::load($objKey, $this->connection);
+ } catch (NotFoundError $e) {
+ continue;
+ }
+ break;
+ }
+
+ if (!isset($object)) {
+ // Not an inherited service, now try apply rules
+ $matcher = HostApplyMatches::prepare($tmpHost);
+ foreach ($this->getAllApplyRules() as $rule) {
+ if ($matcher->matchesFilter($rule->filter)) {
+ if ($rule->name === $this->unresolvedRelatedProperties[$name]) {
+ $object = IcingaService::loadWithAutoIncId(
+ $rule->id,
+ $this->connection
+ );
+ break;
+ }
+ }
+ }
+ }
+ }
+ } else {
+ $object = $class::load($objKey, $this->connection);
+ }
+
+ if (isset($object)) {
+ $this->reallySet($name, $object->get('id'));
+ unset($this->unresolvedRelatedProperties[$name]);
+ } else {
+ throw new NotFoundError('Unable to resolve related property: "%s"', $name);
+ }
+ }
+
+ protected function getAllApplyRules()
+ {
+ $allApplyRules = $this->fetchAllApplyRules();
+ foreach ($allApplyRules as $rule) {
+ $rule->filter = Filter::fromQueryString($rule->assign_filter);
+ }
+
+ return $allApplyRules;
+ }
+
+ protected function fetchAllApplyRules()
+ {
+ $db = $this->connection->getDbAdapter();
+ $query = $db->select()->from(['s' => 'icinga_service'], [
+ 'id' => 's.id',
+ 'name' => 's.object_name',
+ 'assign_filter' => 's.assign_filter',
+ ])->where('object_type = ? AND assign_filter IS NOT NULL', 'apply');
+
+ return $db->fetchAll($query);
+ }
+
+ protected function getRelatedProperty($key)
+ {
+ $related = parent::getRelatedProperty($key);
+ // handle special case for plain string parent service on Dependency
+ // Apply rules
+ if ($related === null && $key === 'parent_service'
+ && null !== $this->get('parent_service_by_name')
+ ) {
+ return $this->get('parent_service_by_name');
+ }
+
+ return $related;
+ }
+}
diff --git a/library/Director/Objects/IcingaEndpoint.php b/library/Director/Objects/IcingaEndpoint.php
new file mode 100644
index 0000000..030183b
--- /dev/null
+++ b/library/Director/Objects/IcingaEndpoint.php
@@ -0,0 +1,157 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Icinga\Module\Director\Core\CoreApi;
+use Icinga\Module\Director\Core\LegacyDeploymentApi;
+use Icinga\Module\Director\Core\RestApiClient;
+use Icinga\Module\Director\Exception\NestingError;
+use Icinga\Module\Director\IcingaConfig\IcingaConfig;
+use InvalidArgumentException;
+use RuntimeException;
+
+class IcingaEndpoint extends IcingaObject
+{
+ protected $table = 'icinga_endpoint';
+
+ protected $supportsImports = true;
+
+ protected $uuidColumn = 'uuid';
+
+ protected $defaultProperties = [
+ 'id' => null,
+ 'uuid' => null,
+ 'zone_id' => null,
+ 'object_name' => null,
+ 'object_type' => null,
+ 'disabled' => 'n',
+ 'host' => null,
+ 'port' => null,
+ 'log_duration' => null,
+ 'apiuser_id' => null,
+ ];
+
+ protected $relations = [
+ 'zone' => 'IcingaZone',
+ 'apiuser' => 'IcingaApiUser',
+ ];
+
+ public function hasApiUser()
+ {
+ return $this->getResolvedProperty('apiuser_id') !== null;
+ }
+
+ public function getApiUser()
+ {
+ $id = $this->getResolvedProperty('apiuser_id');
+ if ($id === null) {
+ throw new RuntimeException('Trying to get API User for Endpoint without such: ' . $this->getObjectName());
+ }
+
+ return $this->getRelatedObject('apiuser', $id);
+ }
+
+ /**
+ * Return a core API, depending on the configuration format
+ *
+ * @return CoreApi|LegacyDeploymentApi
+ */
+ public function api()
+ {
+ $format = $this->connection->settings()->config_format;
+ if ($format === 'v2') {
+ $api = new CoreApi($this->getRestApiClient());
+ $api->setDb($this->getConnection());
+
+ return $api;
+ } elseif ($format === 'v1') {
+ return new LegacyDeploymentApi($this->connection);
+ } else {
+ throw new InvalidArgumentException("Unsupported config format: $format");
+ }
+ }
+
+ /**
+ * @return RestApiClient
+ */
+ public function getRestApiClient()
+ {
+ $client = new RestApiClient(
+ $this->getResolvedProperty('host', $this->getObjectName()),
+ $this->getResolvedProperty('port')
+ );
+
+ $user = $this->getApiUser();
+ $client->setCredentials(
+ // TODO: $user->client_dn,
+ $user->object_name,
+ $user->password
+ );
+
+ return $client;
+ }
+
+ public function getRenderingZone(IcingaConfig $config = null)
+ {
+ try {
+ if ($zone = $this->getResolvedRelated('zone')) {
+ return $zone->getRenderingZone($config);
+ }
+ } catch (NestingError $e) {
+ return self::RESOLVE_ERROR;
+ }
+
+ return parent::getRenderingZone($config);
+ }
+
+ /**
+ * @return int
+ */
+ public function getResolvedPort()
+ {
+ $port = $this->getSingleResolvedProperty('port');
+ if (null === $port) {
+ return 5665;
+ } else {
+ return (int) $port;
+ }
+ }
+
+ public function getDescriptiveUrl()
+ {
+ return sprintf(
+ 'https://%s@%s:%d/v1/',
+ $this->getApiUser()->getObjectName(),
+ $this->getResolvedProperty('host', $this->getObjectName()),
+ $this->getResolvedPort()
+ );
+ }
+
+ /**
+ * Use duration time renderer helper
+ *
+ * Avoid complaints for method names with underscore:
+ * @codingStandardsIgnoreStart
+ *
+ * @return string
+ */
+ protected function renderLog_duration()
+ {
+ // @codingStandardsIgnoreEnd
+ return $this->renderPropertyAsSeconds('log_duration');
+ }
+
+ /**
+ * Internal property, will not be rendered
+ *
+ * Avoid complaints for method names with underscore:
+ * @codingStandardsIgnoreStart
+ *
+ * @return string
+ */
+ protected function renderApiuser_id()
+ {
+ // @codingStandardsIgnoreEnd
+ return '';
+ }
+}
diff --git a/library/Director/Objects/IcingaFlatVar.php b/library/Director/Objects/IcingaFlatVar.php
new file mode 100644
index 0000000..3bbf81c
--- /dev/null
+++ b/library/Director/Objects/IcingaFlatVar.php
@@ -0,0 +1,61 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Icinga\Module\Director\CustomVariable\CustomVariable;
+use Icinga\Module\Director\Data\Db\DbObject;
+use Icinga\Module\Director\Db;
+
+class IcingaFlatVar extends DbObject
+{
+ protected $table = 'icinga_flat_var';
+
+ protected $keyName = [
+ 'var_checksum',
+ 'flatname_checksum'
+ ];
+
+ protected $defaultProperties = [
+ 'var_checksum' => null,
+ 'flatname_checksum' => null,
+ 'flatname' => null,
+ 'flatvalue' => null,
+ ];
+
+ protected $binaryProperties = [
+ 'var_checksum',
+ 'flatname_checksum',
+ ];
+
+ public static function generateForCustomVar(CustomVariable $var, Db $db)
+ {
+ $flatVars = static::forCustomVar($var, $db);
+ foreach ($flatVars as $flat) {
+ $flat->store();
+ }
+
+ return $flatVars;
+ }
+
+ public static function forCustomVar(CustomVariable $var, Db $db)
+ {
+ $flat = [];
+ $varSum = $var->checksum();
+ $var->flatten($flat, $var->getKey());
+ $flatVars = [];
+
+ foreach ($flat as $name => $value) {
+ $flatVar = static::create([
+ 'var_checksum' => $varSum,
+ 'flatname_checksum' => sha1($name, true),
+ 'flatname' => $name,
+ 'flatvalue' => $value,
+ ], $db);
+
+ $flatVar->store();
+ $flatVars[] = $flatVar;
+ }
+
+ return $flatVars;
+ }
+}
diff --git a/library/Director/Objects/IcingaHost.php b/library/Director/Objects/IcingaHost.php
new file mode 100644
index 0000000..2731f4a
--- /dev/null
+++ b/library/Director/Objects/IcingaHost.php
@@ -0,0 +1,668 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Icinga\Data\Db\DbConnection;
+use Icinga\Exception\NotFoundError;
+use Icinga\Module\Director\Data\PropertiesFilter;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\DirectorObject\Automation\ExportInterface;
+use Icinga\Module\Director\Exception\DuplicateKeyException;
+use Icinga\Module\Director\IcingaConfig\IcingaConfig;
+use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c;
+use Icinga\Module\Director\IcingaConfig\IcingaLegacyConfigHelper as c1;
+use Icinga\Module\Director\Objects\Extension\FlappingSupport;
+use InvalidArgumentException;
+use RuntimeException;
+
+class IcingaHost extends IcingaObject implements ExportInterface
+{
+ use FlappingSupport;
+
+ protected $table = 'icinga_host';
+
+ protected $defaultProperties = array(
+ 'id' => null,
+ 'uuid' => null,
+ 'object_name' => null,
+ 'object_type' => null,
+ 'disabled' => 'n',
+ 'display_name' => null,
+ 'address' => null,
+ 'address6' => null,
+ 'check_command_id' => null,
+ 'max_check_attempts' => null,
+ 'check_period_id' => null,
+ 'check_interval' => null,
+ 'retry_interval' => null,
+ 'check_timeout' => null,
+ 'enable_notifications' => null,
+ 'enable_active_checks' => null,
+ 'enable_passive_checks' => null,
+ 'enable_event_handler' => null,
+ 'enable_flapping' => null,
+ 'enable_perfdata' => null,
+ 'event_command_id' => null,
+ 'flapping_threshold_high' => null,
+ 'flapping_threshold_low' => null,
+ 'volatile' => null,
+ 'zone_id' => null,
+ 'command_endpoint_id' => null,
+ 'notes' => null,
+ 'notes_url' => null,
+ 'action_url' => null,
+ 'icon_image' => null,
+ 'icon_image_alt' => null,
+ 'has_agent' => null,
+ 'master_should_connect' => null,
+ 'accept_config' => null,
+ 'custom_endpoint_name' => null,
+ 'api_key' => null,
+ 'template_choice_id' => null,
+ );
+
+ protected $relations = array(
+ 'check_command' => 'IcingaCommand',
+ 'event_command' => 'IcingaCommand',
+ 'check_period' => 'IcingaTimePeriod',
+ 'command_endpoint' => 'IcingaEndpoint',
+ 'zone' => 'IcingaZone',
+ 'template_choice' => 'IcingaTemplateChoiceHost',
+ );
+
+ protected $booleans = array(
+ 'enable_notifications' => 'enable_notifications',
+ 'enable_active_checks' => 'enable_active_checks',
+ 'enable_passive_checks' => 'enable_passive_checks',
+ 'enable_event_handler' => 'enable_event_handler',
+ 'enable_flapping' => 'enable_flapping',
+ 'enable_perfdata' => 'enable_perfdata',
+ 'volatile' => 'volatile',
+ 'has_agent' => 'has_agent',
+ 'master_should_connect' => 'master_should_connect',
+ 'accept_config' => 'accept_config',
+ );
+
+ protected $intervalProperties = array(
+ 'check_interval' => 'check_interval',
+ 'check_timeout' => 'check_timeout',
+ 'retry_interval' => 'retry_interval',
+ );
+
+ protected $supportsCustomVars = true;
+
+ protected $supportsGroups = true;
+
+ protected $supportsImports = true;
+
+ protected $supportsFields = true;
+
+ protected $supportsChoices = true;
+
+ protected $supportedInLegacy = true;
+
+ /** @var HostGroupMembershipResolver */
+ protected $hostgroupMembershipResolver;
+
+ protected $uuidColumn = 'uuid';
+
+ public static function enumProperties(
+ DbConnection $connection = null,
+ $prefix = '',
+ $filter = null
+ ) {
+ $hostProperties = array();
+ if ($filter === null) {
+ $filter = new PropertiesFilter();
+ }
+ $realProperties = array_merge(['templates'], static::create()->listProperties());
+ sort($realProperties);
+
+ if ($filter->match(PropertiesFilter::$HOST_PROPERTY, 'name')) {
+ $hostProperties[$prefix . 'name'] = 'name';
+ }
+ foreach ($realProperties as $prop) {
+ if (!$filter->match(PropertiesFilter::$HOST_PROPERTY, $prop)) {
+ continue;
+ }
+
+ if (substr($prop, -3) === '_id') {
+ if ($prop === 'template_choice_id') {
+ continue;
+ }
+ $prop = substr($prop, 0, -3);
+ }
+
+ $hostProperties[$prefix . $prop] = $prop;
+ }
+ unset($hostProperties[$prefix . 'uuid']);
+ unset($hostProperties[$prefix . 'custom_endpoint_name']);
+
+ $hostVars = array();
+
+ if ($connection instanceof Db) {
+ foreach ($connection->fetchDistinctHostVars() as $var) {
+ if ($filter->match(PropertiesFilter::$CUSTOM_PROPERTY, $var->varname, $var)) {
+ if ($var->datatype) {
+ $hostVars[$prefix . 'vars.' . $var->varname] = sprintf(
+ '%s (%s)',
+ $var->varname,
+ $var->caption
+ );
+ } else {
+ $hostVars[$prefix . 'vars.' . $var->varname] = $var->varname;
+ }
+ }
+ }
+ }
+
+ //$properties['vars.*'] = 'Other custom variable';
+ ksort($hostVars);
+
+
+ $props = mt('director', 'Host properties');
+ $vars = mt('director', 'Custom variables');
+
+ $properties = array();
+ if (!empty($hostProperties)) {
+ $properties[$props] = $hostProperties;
+ $properties[$props][$prefix . 'groups'] = 'Groups';
+ }
+
+ if (!empty($hostVars)) {
+ $properties[$vars] = $hostVars;
+ }
+
+ return $properties;
+ }
+
+ public function getCheckCommand()
+ {
+ $id = $this->getSingleResolvedProperty('check_command_id');
+ return IcingaCommand::loadWithAutoIncId(
+ $id,
+ $this->getConnection()
+ );
+ }
+
+ public function hasCheckCommand()
+ {
+ return $this->getSingleResolvedProperty('check_command_id') !== null;
+ }
+
+ public function renderToConfig(IcingaConfig $config)
+ {
+ parent::renderToConfig($config);
+
+ // TODO: We might alternatively let the whole config fail in case we have
+ // used use_agent together with a legacy config
+ if (! $config->isLegacy()) {
+ $this->renderAgentZoneAndEndpoint($config);
+ }
+ }
+
+ public function renderAgentZoneAndEndpoint(IcingaConfig $config = null)
+ {
+ if (!$this->isObject()) {
+ return;
+ }
+
+ if ($this->isDisabled()) {
+ return;
+ }
+
+ if ($this->getRenderingZone($config) === self::RESOLVE_ERROR) {
+ return;
+ }
+
+ if ($this->getSingleResolvedProperty('has_agent') !== 'y') {
+ return;
+ }
+
+ $name = $this->getEndpointName();
+
+ if (IcingaEndpoint::exists($name, $this->connection)) {
+ return;
+ }
+
+ $props = array(
+ 'object_name' => $name,
+ 'object_type' => 'object',
+ 'log_duration' => 0
+ );
+
+ if ($this->getSingleResolvedProperty('master_should_connect') === 'y') {
+ $props['host'] = $this->getSingleResolvedProperty('address');
+ }
+
+ $props['zone_id'] = $this->getSingleResolvedProperty('zone_id');
+
+ $endpoint = IcingaEndpoint::create($props, $this->connection);
+
+ $zone = IcingaZone::create(array(
+ 'object_name' => $name,
+ ), $this->connection)->setEndpointList(array($name));
+
+ if ($props['zone_id']) {
+ $zone->parent_id = $props['zone_id'];
+ } else {
+ $zone->parent = $this->connection->getMasterZoneName();
+ }
+
+ $pre = 'zones.d/' . $this->getRenderingZone($config) . '/';
+ $config->configFile($pre . 'agent_endpoints')->addObject($endpoint);
+ $config->configFile($pre . 'agent_zones')->addObject($zone);
+ }
+
+ /**
+ // @codingStandardsIgnoreStart
+ * @param $value
+ * @return string
+ */
+ protected function renderCustom_endpoint_name($value)
+ {
+ // @codingStandardsIgnoreEnd
+ // When feature flag feature_custom_endpoint is enabled, render custom var
+ if ($this->connection->settings()->get('feature_custom_endpoint') === 'y') {
+ return c::renderKeyValue('vars._director_custom_endpoint_name', c::renderString($value));
+ }
+
+ return '';
+ }
+
+ /**
+ * Returns the hostname or custom endpoint name of the Icinga agent
+ *
+ * @return string
+ */
+ public function getEndpointName()
+ {
+ $name = $this->getObjectName();
+
+ if ($this->connection->settings()->get('feature_custom_endpoint') === 'y') {
+ if (($customName = $this->get('custom_endpoint_name')) !== null) {
+ $name = $customName;
+ }
+ }
+
+ return $name;
+ }
+
+ public function getAgentListenPort()
+ {
+ $conn = $this->connection;
+ $name = $this->getObjectName();
+ if (IcingaEndpoint::exists($name, $conn)) {
+ return IcingaEndpoint::load($name, $conn)->getResolvedPort();
+ } else {
+ return 5665;
+ }
+ }
+
+ public function getUniqueIdentifier()
+ {
+ if ($this->isTemplate()) {
+ return $this->getObjectName();
+ } else {
+ throw new RuntimeException(
+ 'getUniqueIdentifier() is supported by Host Templates only'
+ );
+ }
+ }
+
+ /**
+ * @return object
+ * @deprecated please use \Icinga\Module\Director\Data\Exporter
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function export()
+ {
+ // TODO: ksort in toPlainObject?
+ $props = (array) $this->toPlainObject();
+ $props['fields'] = $this->loadFieldReferences();
+ ksort($props);
+
+ return (object) $props;
+ }
+
+ /**
+ * @param $plain
+ * @param Db $db
+ * @param bool $replace
+ * @return IcingaHost
+ * @throws DuplicateKeyException
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public static function import($plain, Db $db, $replace = false)
+ {
+ $properties = (array) $plain;
+ $name = $properties['object_name'];
+ if ($properties['object_type'] !== 'template') {
+ throw new InvalidArgumentException(sprintf(
+ 'Can import only Templates, got "%s" for "%s"',
+ $properties['object_type'],
+ $name
+ ));
+ }
+ $key = $name;
+
+ if ($replace && static::exists($key, $db)) {
+ $object = static::load($key, $db);
+ } elseif (static::exists($key, $db)) {
+ throw new DuplicateKeyException(
+ 'Service Template "%s" already exists',
+ $name
+ );
+ } else {
+ $object = static::create([], $db);
+ }
+
+ // $object->newFields = $properties['fields'];
+ unset($properties['fields']);
+ $object->setProperties($properties);
+
+ return $object;
+ }
+
+ /**
+ * @deprecated please use \Icinga\Module\Director\Data\FieldReferenceLoader
+ * @return array
+ */
+ protected function loadFieldReferences()
+ {
+ $db = $this->getDb();
+
+ $res = $db->fetchAll(
+ $db->select()->from([
+ 'hf' => 'icinga_host_field'
+ ], [
+ 'hf.datafield_id',
+ 'hf.is_required',
+ 'hf.var_filter',
+ ])->join(['df' => 'director_datafield'], 'df.id = hf.datafield_id', [])
+ ->where('host_id = ?', $this->get('id'))
+ ->order('varname ASC')
+ );
+
+ if (empty($res)) {
+ return [];
+ } else {
+ foreach ($res as $field) {
+ $field->datafield_id = (int) $field->datafield_id;
+ }
+ return $res;
+ }
+ }
+
+ public function beforeDelete()
+ {
+ foreach ($this->fetchServices() as $service) {
+ $service->delete();
+ }
+ foreach ($this->fetchServiceSets() as $set) {
+ $set->delete();
+ }
+
+ parent::beforeDelete();
+ }
+
+ public function hasAnyOverridenServiceVars()
+ {
+ $varname = $this->getServiceOverrivesVarname();
+ return isset($this->vars()->$varname);
+ }
+
+ public function getAllOverriddenServiceVars()
+ {
+ if ($this->hasAnyOverridenServiceVars()) {
+ $varname = $this->getServiceOverrivesVarname();
+ return $this->vars()->$varname->getValue();
+ } else {
+ return (object) array();
+ }
+ }
+
+ public function hasOverriddenServiceVars($service)
+ {
+ $all = $this->getAllOverriddenServiceVars();
+ return property_exists($all, $service);
+ }
+
+ public function getOverriddenServiceVars($service)
+ {
+ if ($this->hasOverriddenServiceVars($service)) {
+ $all = $this->getAllOverriddenServiceVars();
+ return $all->$service;
+ } else {
+ return (object) array();
+ }
+ }
+
+ public function overrideServiceVars($service, $vars)
+ {
+ // For PHP < 5.5.0:
+ $array = (array) $vars;
+ if (empty($array)) {
+ return $this->unsetOverriddenServiceVars($service);
+ }
+
+ $all = $this->getAllOverriddenServiceVars();
+ $all->$service = $vars;
+ $varname = $this->getServiceOverrivesVarname();
+ $this->vars()->$varname = $all;
+
+ return $this;
+ }
+
+ public function unsetOverriddenServiceVars($service)
+ {
+ if ($this->hasOverriddenServiceVars($service)) {
+ $all = (array) $this->getAllOverriddenServiceVars();
+ unset($all[$service]);
+
+ $varname = $this->getServiceOverrivesVarname();
+ if (empty($all)) {
+ unset($this->vars()->$varname);
+ } else {
+ $this->vars()->$varname = (object) $all;
+ }
+ }
+
+ return $this;
+ }
+
+ protected function notifyResolvers()
+ {
+ $resolver = $this->getHostGroupMembershipResolver();
+ $resolver->addObject($this);
+ $resolver->refreshDb();
+
+ return $this;
+ }
+
+ protected function getHostGroupMembershipResolver()
+ {
+ if ($this->hostgroupMembershipResolver === null) {
+ $this->hostgroupMembershipResolver = new HostGroupMembershipResolver(
+ $this->getConnection()
+ );
+ }
+
+ return $this->hostgroupMembershipResolver;
+ }
+
+ public function setHostGroupMembershipResolver(HostGroupMembershipResolver $resolver)
+ {
+ $this->hostgroupMembershipResolver = $resolver;
+ return $this;
+ }
+
+ protected function getServiceOverrivesVarname()
+ {
+ return $this->connection->settings()->override_services_varname;
+ }
+
+ /**
+ * Internal property, will not be rendered
+ *
+ * Avoid complaints for method names with underscore:
+ * @codingStandardsIgnoreStart
+ *
+ * @return string
+ */
+ protected function renderHas_Agent()
+ {
+ return '';
+ }
+
+ /**
+ * Internal property, will not be rendered
+ *
+ * @return string
+ */
+ protected function renderMaster_should_connect()
+ {
+ return '';
+ }
+
+ /**
+ * Internal property, will not be rendered
+ *
+ * @return string
+ */
+ protected function renderApi_key()
+ {
+ return '';
+ }
+
+ /**
+ * Internal property, will not be rendered
+ *
+ * @return string
+ */
+ protected function renderTemplate_choice_id()
+ {
+ return '';
+ }
+
+ /**
+ * Internal property, will not be rendered
+ *
+ * @return string
+ */
+ protected function renderAccept_config()
+ {
+ // @codingStandardsIgnoreEnd
+ return '';
+ }
+
+ /**
+ * @codingStandardsIgnoreStart
+ *
+ * @return string
+ */
+ protected function renderLegacyDisplay_Name()
+ {
+ // @codingStandardsIgnoreEnd
+ return c1::renderKeyValue('display_name', $this->display_name);
+ }
+
+ protected function renderLegacyVolatile()
+ {
+ // not available for hosts in Icinga 1.x
+ return;
+ }
+
+ protected function renderLegacyCustomExtensions()
+ {
+ $str = parent::renderLegacyCustomExtensions();
+
+ if (($alias = $this->vars()->get('alias')) !== null) {
+ $str .= c1::renderKeyValue('alias', $alias->getValue());
+ }
+
+ return $str;
+ }
+
+ /**
+ * @return IcingaService[]
+ */
+ public function fetchServices()
+ {
+ $connection = $this->getConnection();
+ $db = $connection->getDbAdapter();
+
+ /** @var IcingaService[] $services */
+ $services = IcingaService::loadAll(
+ $connection,
+ $db->select()->from('icinga_service')
+ ->where('host_id = ?', $this->get('id'))
+ );
+
+ return $services;
+ }
+
+ /**
+ * @return IcingaServiceSet[]
+ */
+ public function fetchServiceSets()
+ {
+ $connection = $this->getConnection();
+ $db = $connection->getDbAdapter();
+
+ /** @var IcingaServiceSet[] $sets */
+ $sets = IcingaServiceSet::loadAll(
+ $connection,
+ $db->select()->from('icinga_service_set')
+ ->where('host_id = ?', $this->get('id'))
+ );
+
+ return $sets;
+ }
+
+ /**
+ * @return string
+ */
+ public function generateApiKey()
+ {
+ $key = sha1(
+ (string) microtime(false)
+ . $this->getObjectName()
+ . rand(1, 1000000)
+ );
+
+ if ($this->dbHasApiKey($key)) {
+ $key = $this->generateApiKey();
+ }
+
+ $this->set('api_key', $key);
+
+ return $key;
+ }
+
+ protected function dbHasApiKey($key)
+ {
+ $db = $this->getDb();
+ $query = $db->select()->from(
+ ['o' => $this->getTableName()],
+ 'o.api_key'
+ )->where('api_key = ?', $key);
+
+ return $db->fetchOne($query) === $key;
+ }
+
+ public static function loadWithApiKey($key, Db $db)
+ {
+ $query = $db->getDbAdapter()
+ ->select()
+ ->from('icinga_host')
+ ->where('api_key = ?', $key);
+
+ $result = self::loadAll($db, $query);
+ if (count($result) !== 1) {
+ throw new NotFoundError('Got invalid API key "%s"', $key);
+ }
+
+ return current($result);
+ }
+}
diff --git a/library/Director/Objects/IcingaHostField.php b/library/Director/Objects/IcingaHostField.php
new file mode 100644
index 0000000..b68c9d4
--- /dev/null
+++ b/library/Director/Objects/IcingaHostField.php
@@ -0,0 +1,17 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+class IcingaHostField extends IcingaObjectField
+{
+ protected $keyName = array('host_id', 'datafield_id');
+
+ protected $table = 'icinga_host_field';
+
+ protected $defaultProperties = array(
+ 'host_id' => null,
+ 'datafield_id' => null,
+ 'is_required' => null,
+ 'var_filter' => null,
+ );
+}
diff --git a/library/Director/Objects/IcingaHostGroup.php b/library/Director/Objects/IcingaHostGroup.php
new file mode 100644
index 0000000..e11f672
--- /dev/null
+++ b/library/Director/Objects/IcingaHostGroup.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+class IcingaHostGroup extends IcingaObjectGroup
+{
+ protected $table = 'icinga_hostgroup';
+
+ /** @var HostGroupMembershipResolver */
+ protected $hostgroupMembershipResolver;
+
+ public function supportsAssignments()
+ {
+ return true;
+ }
+
+ protected function getHostGroupMembershipResolver()
+ {
+ if ($this->hostgroupMembershipResolver === null) {
+ $this->hostgroupMembershipResolver = new HostGroupMembershipResolver(
+ $this->getConnection()
+ );
+ }
+
+ return $this->hostgroupMembershipResolver;
+ }
+
+ public function setHostGroupMembershipResolver(HostGroupMembershipResolver $resolver)
+ {
+ $this->hostgroupMembershipResolver = $resolver;
+ return $this;
+ }
+
+ protected function notifyResolvers()
+ {
+ $resolver = $this->getHostGroupMembershipResolver();
+ $resolver->addGroup($this);
+ $resolver->refreshDb();
+
+ return $this;
+ }
+}
diff --git a/library/Director/Objects/IcingaHostGroupAssignment.php b/library/Director/Objects/IcingaHostGroupAssignment.php
new file mode 100644
index 0000000..4e0e5a2
--- /dev/null
+++ b/library/Director/Objects/IcingaHostGroupAssignment.php
@@ -0,0 +1,20 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+class IcingaHostGroupAssignment extends IcingaObject
+{
+ protected $table = 'icinga_hostgroup_assignment';
+
+ protected $keyName = 'id';
+
+ protected $defaultProperties = array(
+ 'id' => null,
+ 'service_id' => null,
+ 'filter_string' => null,
+ );
+
+ protected $relations = array(
+ 'service' => 'IcingaHostGroup',
+ );
+}
diff --git a/library/Director/Objects/IcingaHostVar.php b/library/Director/Objects/IcingaHostVar.php
new file mode 100644
index 0000000..45656d5
--- /dev/null
+++ b/library/Director/Objects/IcingaHostVar.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+class IcingaHostVar extends IcingaObject
+{
+ protected $keyName = array('host_id', 'varname');
+
+ protected $table = 'icinga_host_var';
+
+ protected $defaultProperties = array(
+ 'host_id' => null,
+ 'varname' => null,
+ 'varvalue' => null,
+ 'format' => null,
+ );
+
+ public function onInsert()
+ {
+ }
+
+ public function onUpdate()
+ {
+ }
+
+ public function onDelete()
+ {
+ }
+}
diff --git a/library/Director/Objects/IcingaNotification.php b/library/Director/Objects/IcingaNotification.php
new file mode 100644
index 0000000..9c5d08d
--- /dev/null
+++ b/library/Director/Objects/IcingaNotification.php
@@ -0,0 +1,254 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\DirectorObject\Automation\ExportInterface;
+use Icinga\Module\Director\Exception\DuplicateKeyException;
+use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c;
+use RuntimeException;
+
+class IcingaNotification extends IcingaObject implements ExportInterface
+{
+ protected $table = 'icinga_notification';
+
+ protected $defaultProperties = [
+ 'id' => null,
+ 'uuid' => null,
+ 'object_name' => null,
+ 'object_type' => null,
+ 'disabled' => 'n',
+ 'apply_to' => null,
+ 'host_id' => null,
+ 'service_id' => null,
+ // 'users' => null,
+ // 'user_groups' => null,
+ 'times_begin' => null,
+ 'times_end' => null,
+ 'command_id' => null,
+ 'notification_interval' => null,
+ 'period_id' => null,
+ 'zone_id' => null,
+ 'assign_filter' => null,
+ ];
+
+ protected $uuidColumn = 'uuid';
+
+ protected $supportsCustomVars = true;
+
+ protected $supportsFields = true;
+
+ protected $supportsImports = true;
+
+ protected $supportsApplyRules = true;
+
+ protected $relatedSets = [
+ 'states' => 'StateFilterSet',
+ 'types' => 'TypeFilterSet',
+ ];
+
+ protected $multiRelations = [
+ 'users' => 'IcingaUser',
+ 'user_groups' => 'IcingaUserGroup',
+ ];
+
+ protected $relations = [
+ 'zone' => 'IcingaZone',
+ 'host' => 'IcingaHost',
+ 'service' => 'IcingaService',
+ 'command' => 'IcingaCommand',
+ 'period' => 'IcingaTimePeriod',
+ ];
+
+ protected $intervalProperties = [
+ 'notification_interval' => 'interval',
+ 'times_begin' => 'times_begin',
+ 'times_end' => 'times_end',
+ ];
+
+ protected function prefersGlobalZone()
+ {
+ return false;
+ }
+
+ /**
+ * @codingStandardsIgnoreStart
+ * @return string
+ */
+ protected function renderTimes_begin()
+ {
+ // @codingStandardsIgnoreEnd
+ return c::renderKeyValue('times.begin', c::renderInterval($this->get('times_begin')));
+ }
+
+ /**
+ * @codingStandardsIgnoreStart
+ * @return string
+ */
+ protected function renderTimes_end()
+ {
+ // @codingStandardsIgnoreEnd
+ return c::renderKeyValue('times.end', c::renderInterval($this->get('times_end')));
+ }
+
+ public function getUniqueIdentifier()
+ {
+ return $this->getObjectName();
+ }
+
+ /**
+ * @return \stdClass
+ * @deprecated please use \Icinga\Module\Director\Data\Exporter
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function export()
+ {
+ // TODO: ksort in toPlainObject?
+ $props = (array) $this->toPlainObject();
+ $props['fields'] = $this->loadFieldReferences();
+ ksort($props);
+
+ return (object) $props;
+ }
+
+ /**
+ * @param $plain
+ * @param Db $db
+ * @param bool $replace
+ * @return static
+ * @throws DuplicateKeyException
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public static function import($plain, Db $db, $replace = false)
+ {
+ $properties = (array) $plain;
+ $name = $properties['object_name'];
+ $key = $name;
+
+ if ($replace && static::exists($key, $db)) {
+ $object = static::load($key, $db);
+ } elseif (static::exists($key, $db)) {
+ throw new DuplicateKeyException(
+ 'Notification "%s" already exists',
+ $name
+ );
+ } else {
+ $object = static::create([], $db);
+ }
+
+ // $object->newFields = $properties['fields'];
+ unset($properties['fields']);
+ $object->setProperties($properties);
+
+ return $object;
+ }
+
+ /**
+ * @deprecated please use \Icinga\Module\Director\Data\FieldReferenceLoader
+ * @return array
+ */
+ protected function loadFieldReferences()
+ {
+ $db = $this->getDb();
+
+ $res = $db->fetchAll(
+ $db->select()->from([
+ 'nf' => 'icinga_notification_field'
+ ], [
+ 'nf.datafield_id',
+ 'nf.is_required',
+ 'nf.var_filter',
+ ])->join(['df' => 'director_datafield'], 'df.id = nf.datafield_id', [])
+ ->where('notification_id = ?', $this->get('id'))
+ ->order('varname ASC')
+ );
+
+ if (empty($res)) {
+ return [];
+ } else {
+ foreach ($res as $field) {
+ $field->datafield_id = (int) $field->datafield_id;
+ }
+ return $res;
+ }
+ }
+
+ /**
+ * Do not render internal property apply_to
+ *
+ * Avoid complaints for method names with underscore:
+ * @codingStandardsIgnoreStart
+ *
+ * @return string
+ */
+ public function renderApply_to()
+ {
+ // @codingStandardsIgnoreEnd
+ return '';
+ }
+
+ protected function renderObjectHeader()
+ {
+ if ($this->isApplyRule()) {
+ if (($to = $this->get('apply_to')) === null) {
+ throw new RuntimeException(sprintf(
+ 'No "apply_to" object type has been set for Applied notification "%s"',
+ $this->getObjectName()
+ ));
+ }
+
+ return sprintf(
+ "%s %s %s to %s {\n",
+ $this->getObjectTypeName(),
+ $this->getType(),
+ c::renderString($this->getObjectName()),
+ ucfirst($to)
+ );
+ } else {
+ return parent::renderObjectHeader();
+ }
+ }
+
+ /**
+ * Render host_id as host_name
+ *
+ * Avoid complaints for method names with underscore:
+ * @codingStandardsIgnoreStart
+ *
+ * @return string
+ */
+ public function renderHost_id()
+ {
+ // @codingStandardsIgnoreEnd
+ return $this->renderRelationProperty('host', $this->get('host_id'), 'host_name');
+ }
+
+ /**
+ * Render service_id as service_name
+ *
+ * @codingStandardsIgnoreStart
+ * @return string
+ */
+ public function renderService_id()
+ {
+ // @codingStandardsIgnoreEnd
+ return $this->renderRelationProperty('service', $this->get('service_id'), 'service_name');
+ }
+
+ protected function setKey($key)
+ {
+ if (is_int($key)) {
+ $this->id = $key;
+ } elseif (is_array($key)) {
+ foreach (['id', 'host_id', 'service_id', 'object_name'] as $k) {
+ if (array_key_exists($k, $key)) {
+ $this->set($k, $key[$k]);
+ }
+ }
+ } else {
+ return parent::setKey($key);
+ }
+
+ return $this;
+ }
+}
diff --git a/library/Director/Objects/IcingaNotificationField.php b/library/Director/Objects/IcingaNotificationField.php
new file mode 100644
index 0000000..d51f9e6
--- /dev/null
+++ b/library/Director/Objects/IcingaNotificationField.php
@@ -0,0 +1,17 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+class IcingaNotificationField extends IcingaObjectField
+{
+ protected $keyName = array('notification_id', 'datafield_id');
+
+ protected $table = 'icinga_notification_field';
+
+ protected $defaultProperties = array(
+ 'notification_id' => null,
+ 'datafield_id' => null,
+ 'is_required' => null,
+ 'var_filter' => null,
+ );
+}
diff --git a/library/Director/Objects/IcingaObject.php b/library/Director/Objects/IcingaObject.php
new file mode 100644
index 0000000..04ae32b
--- /dev/null
+++ b/library/Director/Objects/IcingaObject.php
@@ -0,0 +1,3258 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Exception;
+use Icinga\Data\Filter\Filter;
+use Icinga\Data\Filter\FilterChain;
+use Icinga\Data\Filter\FilterExpression;
+use Icinga\Exception\NotFoundError;
+use Icinga\Module\Director\CustomVariable\CustomVariables;
+use Icinga\Module\Director\Data\Db\DbDataFormatter;
+use Icinga\Module\Director\Data\Db\DbObjectTypeRegistry;
+use Icinga\Module\Director\IcingaConfig\AssignRenderer;
+use Icinga\Module\Director\Data\Db\DbObject;
+use Icinga\Module\Director\Db\Cache\PrefetchCache;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Exception\NestingError;
+use Icinga\Module\Director\IcingaConfig\ExtensibleSet;
+use Icinga\Module\Director\IcingaConfig\IcingaConfig;
+use Icinga\Module\Director\IcingaConfig\IcingaConfigRenderer;
+use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c;
+use Icinga\Module\Director\IcingaConfig\IcingaLegacyConfigHelper as c1;
+use Icinga\Module\Director\Repository\IcingaTemplateRepository;
+use LogicException;
+use RuntimeException;
+
+abstract class IcingaObject extends DbObject implements IcingaConfigRenderer
+{
+ const RESOLVE_ERROR = '(unable to resolve)';
+
+ protected $keyName = 'object_name';
+
+ protected $autoincKeyName = 'id';
+
+ /** @var bool Whether this Object supports custom variables */
+ protected $supportsCustomVars = false;
+
+ /** @var bool Whether there exist Groups for this object type */
+ protected $supportsGroups = false;
+
+ /** @var bool Whether this Object makes use of (time) ranges */
+ protected $supportsRanges = false;
+
+ /** @var bool Whether inheritance via "imports" property is supported */
+ protected $supportsImports = false;
+
+ /** @var bool Allows controlled custom var access through Fields */
+ protected $supportsFields = false;
+
+ /** @var bool Whether this object can be rendered as 'apply Object' */
+ protected $supportsApplyRules = false;
+
+ /** @var bool Whether Sets of object can be defined */
+ protected $supportsSets = false;
+
+ /** @var bool Whether this Object supports template-based Choices */
+ protected $supportsChoices = false;
+
+ /** @var bool If the object is rendered in legacy config */
+ protected $supportedInLegacy = false;
+
+ protected $rangeClass;
+
+ protected $type;
+
+ /* key/value!! */
+ protected $booleans = [];
+
+ // Property suffixed with _id must exist
+ protected $relations = [
+ // property => PropertyClass
+ ];
+
+ protected $relatedSets = [
+ // property => ExtensibleSetClass
+ ];
+
+ protected $multiRelations = [
+ // property => IcingaObjectClass
+ ];
+
+ /** @var IcingaObjectMultiRelations[] */
+ protected $loadedMultiRelations = [];
+
+ /**
+ * Allows to set properties pointing to related objects by name without
+ * loading the related object.
+ *
+ * @var array
+ */
+ protected $unresolvedRelatedProperties = [];
+
+ protected $loadedRelatedSets = [];
+
+ // Will be rendered first, before imports
+ protected $prioritizedProperties = [];
+
+ protected $propertiesNotForRendering = [
+ 'id',
+ 'object_name',
+ 'object_type',
+ ];
+
+ /**
+ * Array of interval property names
+ *
+ * Those will be automagically munged to integers (seconds) and rendered
+ * as durations (e.g. 2m 10s). Array expects (propertyName => renderedKey)
+ *
+ * @var array
+ */
+ protected $intervalProperties = [];
+
+ /** @var Db */
+ protected $connection;
+
+ private $vars;
+
+ /** @var IcingaObjectGroups */
+ private $groups;
+
+ private $imports;
+
+ /** @var IcingaTimePeriodRanges - TODO: generic ranges */
+ private $ranges;
+
+ private $shouldBeRemoved = false;
+
+ private $resolveCache = [];
+
+ private $cachedPlainUnmodified;
+
+ private $templateResolver;
+
+ protected static $tree;
+
+ /**
+ * @return Db
+ */
+ public function getConnection()
+ {
+ return $this->connection;
+ }
+
+ public function propertyIsBoolean($property)
+ {
+ return array_key_exists($property, $this->booleans);
+ }
+
+ public function propertyIsInterval($property)
+ {
+ return array_key_exists($property, $this->intervalProperties);
+ }
+
+ /**
+ * Whether a property ends with _id and might refer another object
+ *
+ * @param $property string Property name, like zone_id
+ *
+ * @return bool
+ */
+ public function propertyIsRelation($property)
+ {
+ if ($key = $this->stripIdSuffix($property)) {
+ return $this->hasRelation($key);
+ }
+
+ return false;
+ }
+
+ protected function stripIdSuffix($key)
+ {
+ $end = substr($key, -3);
+
+ if ('_id' === $end) {
+ return substr($key, 0, -3);
+ }
+
+ return false;
+ }
+
+ public function propertyIsRelatedSet($property)
+ {
+ return array_key_exists($property, $this->relatedSets);
+ }
+
+ public function propertyIsMultiRelation($property)
+ {
+ return array_key_exists($property, $this->multiRelations);
+ }
+
+ public function listMultiRelations()
+ {
+ return array_keys($this->multiRelations);
+ }
+
+ public function getMultiRelation($property)
+ {
+ if (! $this->hasLoadedMultiRelation($property)) {
+ $this->loadMultiRelation($property);
+ }
+
+ return $this->loadedMultiRelations[$property];
+ }
+
+ public function setMultiRelation($property, $values)
+ {
+ $this->getMultiRelation($property)->set($values);
+ return $this;
+ }
+
+ private function loadMultiRelation($property)
+ {
+ if ($this->hasBeenLoadedFromDb()) {
+ $rel = IcingaObjectMultiRelations::loadForStoredObject(
+ $this,
+ $property,
+ $this->multiRelations[$property]
+ );
+ } else {
+ $rel = new IcingaObjectMultiRelations(
+ $this,
+ $property,
+ $this->multiRelations[$property]
+ );
+ }
+
+ $this->loadedMultiRelations[$property] = $rel;
+ }
+
+ private function hasLoadedMultiRelation($property)
+ {
+ return array_key_exists($property, $this->loadedMultiRelations);
+ }
+
+ private function loadAllMultiRelations()
+ {
+ foreach (array_keys($this->multiRelations) as $key) {
+ if (! $this->hasLoadedMultiRelation($key)) {
+ $this->loadMultiRelation($key);
+ }
+ }
+
+ ksort($this->loadedMultiRelations);
+ return $this->loadedMultiRelations;
+ }
+
+ protected function getRelatedSetClass($property)
+ {
+ $prefix = '\\Icinga\\Module\\Director\\IcingaConfig\\';
+ return $prefix . $this->relatedSets[$property];
+ }
+
+ /**
+ * @param $property
+ * @return ExtensibleSet
+ */
+ protected function getRelatedSet($property)
+ {
+ if (! array_key_exists($property, $this->loadedRelatedSets)) {
+ /** @var ExtensibleSet $class */
+ $class = $this->getRelatedSetClass($property);
+ $this->loadedRelatedSets[$property]
+ = $class::forIcingaObject($this, $property);
+ }
+
+ return $this->loadedRelatedSets[$property];
+ }
+
+ /**
+ * @return ExtensibleSet[]
+ */
+ protected function relatedSets()
+ {
+ $sets = [];
+ foreach ($this->relatedSets as $key => $class) {
+ $sets[$key] = $this->getRelatedSet($key);
+ }
+
+ return $sets;
+ }
+
+ /**
+ * Whether the given property name is a short name for a relation
+ *
+ * This might be 'zone' for 'zone_id'
+ *
+ * @param string $property Property name
+ *
+ * @return bool
+ */
+ public function hasRelation($property)
+ {
+ return array_key_exists($property, $this->relations);
+ }
+
+ protected function getRelationClass($property)
+ {
+ return __NAMESPACE__ . '\\' . $this->relations[$property];
+ }
+
+ protected function getRelationObjectClass($property)
+ {
+ return $this->relations[$property];
+ }
+
+ /**
+ * @param $property
+ * @return IcingaObject
+ */
+ public function getRelated($property)
+ {
+ return $this->getRelatedObject($property, $this->{$property . '_id'});
+ }
+
+ /**
+ * @param $property
+ * @param $id
+ * @return string
+ */
+ public function getRelatedObjectName($property, $id)
+ {
+ return $this->getRelatedObject($property, $id)->getObjectName();
+ }
+
+ /**
+ * @param $property
+ * @param $id
+ * @return IcingaObject
+ */
+ protected function getRelatedObject($property, $id)
+ {
+ /** @var IcingaObject $class */
+ $class = $this->getRelationClass($property);
+ try {
+ $object = $class::loadWithAutoIncId($id, $this->connection);
+ } catch (NotFoundError $e) {
+ throw new RuntimeException($e->getMessage(), 0, $e);
+ }
+
+ return $object;
+ }
+
+ /**
+ * @param $property
+ * @return IcingaObject|null
+ */
+ public function getResolvedRelated($property)
+ {
+ $id = $this->getSingleResolvedProperty($property . '_id');
+
+ if ($id) {
+ return $this->getRelatedObject($property, $id);
+ }
+
+ return null;
+ }
+
+ public function prefetchAllRelatedTypes()
+ {
+ foreach (array_unique(array_values($this->relations)) as $relClass) {
+ /** @var static $class */
+ $class = __NAMESPACE__ . '\\' . $relClass;
+ $class::prefetchAll($this->getConnection());
+ }
+ }
+
+ public static function prefetchAllRelationsByType($type, Db $db)
+ {
+ /** @var static $class */
+ $class = DbObjectTypeRegistry::classByType($type);
+ /** @var static $dummy */
+ $dummy = $class::create([], $db);
+ $dummy->prefetchAllRelatedTypes();
+ }
+
+ /**
+ * Whether this Object supports custom variables
+ *
+ * @return bool
+ */
+ public function supportsCustomVars()
+ {
+ return $this->supportsCustomVars;
+ }
+
+ /**
+ * Whether there exist Groups for this object type
+ *
+ * @return bool
+ */
+ public function supportsGroups()
+ {
+ return $this->supportsGroups;
+ }
+
+ /**
+ * Whether this Object makes use of (time) ranges
+ *
+ * @return bool
+ */
+ public function supportsRanges()
+ {
+ return $this->supportsRanges;
+ }
+
+ /**
+ * Whether this object supports (command) Arguments
+ *
+ * @return bool
+ */
+ public function supportsArguments()
+ {
+ return $this instanceof ObjectWithArguments;
+ }
+
+ /**
+ * Whether this object supports inheritance through the "imports" property
+ *
+ * @return bool
+ */
+ public function supportsImports()
+ {
+ return $this->supportsImports;
+ }
+
+ /**
+ * Whether this object allows controlled custom var access through fields
+ *
+ * @return bool
+ */
+ public function supportsFields()
+ {
+ return $this->supportsFields;
+ }
+
+ /**
+ * Whether this object can be rendered as 'apply Object'
+ *
+ * @return bool
+ */
+ public function supportsApplyRules()
+ {
+ return $this->supportsApplyRules;
+ }
+
+ /**
+ * Whether this object supports 'assign' properties
+ *
+ * @return bool
+ */
+ public function supportsAssignments()
+ {
+ return $this->isApplyRule();
+ }
+
+ /**
+ * Whether this object can be part of a 'set'
+ *
+ * @return bool
+ */
+ public function supportsSets()
+ {
+ return $this->supportsSets;
+ }
+
+ /**
+ * Whether this object supports template-based Choices
+ *
+ * @return bool
+ */
+ public function supportsChoices()
+ {
+ return $this->supportsChoices;
+ }
+
+ public function setAssignments($value)
+ {
+ return IcingaObjectLegacyAssignments::applyToObject($this, $value);
+ }
+
+ /**
+ * @codingStandardsIgnoreStart
+ *
+ * @param Filter|string $filter
+ *
+ * @throws LogicException
+ *
+ * @return self
+ */
+ public function setAssign_filter($filter)
+ {
+ if (! $this->supportsAssignments() && $filter !== null) {
+ if ($this->hasProperty('object_type')) {
+ $type = $this->get('object_type');
+ } else {
+ $type = get_class($this);
+ }
+
+ if ($type === null) {
+ throw new LogicException(
+ 'Cannot set assign_filter unless object_type has been set'
+ );
+ }
+ throw new LogicException(sprintf(
+ 'I can only assign for applied objects or objects with native'
+ . ' support for assignments, got %s',
+ $type
+ ));
+ }
+
+ // @codingStandardsIgnoreEnd
+ if ($filter instanceof Filter) {
+ $filter = $filter->toQueryString();
+ }
+
+ return $this->reallySet('assign_filter', $filter);
+ }
+
+ /**
+ * It sometimes makes sense to defer lookups for related properties. This
+ * kind of lazy-loading allows us to for example set host = 'localhost' and
+ * render an object even when no such host exists. Think of the activity log,
+ * one might want to visualize a history host or service template even when
+ * the related command has been deleted in the meantime.
+ *
+ * @return self
+ */
+ public function resolveUnresolvedRelatedProperties()
+ {
+ foreach ($this->unresolvedRelatedProperties as $name => $p) {
+ $this->resolveUnresolvedRelatedProperty($name);
+ }
+
+ return $this;
+ }
+
+ public function getUnresolvedRelated($property)
+ {
+ if ($this->hasRelation($property)) {
+ $property .= '_id';
+ if (isset($this->unresolvedRelatedProperties[$property])) {
+ return $this->unresolvedRelatedProperties[$property];
+ }
+
+ return null;
+ }
+
+ throw new RuntimeException(sprintf(
+ '%s "%s" has no %s reference',
+ $this->getShortTableName(),
+ $this->getObjectName(),
+ $property
+ ));
+ }
+
+ /**
+ * @param $name
+ */
+ protected function resolveUnresolvedRelatedProperty($name)
+ {
+ $short = substr($name, 0, -3);
+ /** @var IcingaObject $class */
+ $class = $this->getRelationClass($short);
+ try {
+ $object = $class::load(
+ $this->unresolvedRelatedProperties[$name],
+ $this->connection
+ );
+ } catch (NotFoundError $e) {
+ // Hint: eventually a NotFoundError would be better
+ throw new RuntimeException(sprintf(
+ 'Unable to load object (%s: %s) referenced from %s "%s", %s',
+ $short,
+ $this->unresolvedRelatedProperties[$name],
+ $this->getShortTableName(),
+ $this->getObjectName(),
+ lcfirst($e->getMessage())
+ ), $e->getCode(), $e);
+ }
+
+ $id = $object->get('id');
+ // Happens when load() get's a branched object, created in the branch
+ if ($id !== null) {
+ $this->reallySet($name, $id);
+ unset($this->unresolvedRelatedProperties[$name]);
+ }
+ }
+
+ /**
+ * @return bool
+ */
+ public function hasBeenModified()
+ {
+ if (parent::hasBeenModified()) {
+ return true;
+ }
+
+ if ($this->hasUnresolvedRelatedProperties()) {
+ $this->resolveUnresolvedRelatedProperties();
+
+ // Duplicates above code, but this makes it faster:
+ if (parent::hasBeenModified()) {
+ return true;
+ }
+ }
+
+ if ($this->supportsCustomVars() && $this->vars !== null && $this->vars()->hasBeenModified()) {
+ return true;
+ }
+
+ if ($this->supportsGroups() && $this->groups !== null && $this->groups()->hasBeenModified()) {
+ return true;
+ }
+
+ if ($this->supportsImports() && $this->imports !== null && $this->imports()->hasBeenModified()) {
+ return true;
+ }
+
+ if ($this->supportsRanges() && $this->ranges !== null && $this->ranges()->hasBeenModified()) {
+ return true;
+ }
+
+ if ($this instanceof ObjectWithArguments
+ && $this->gotArguments()
+ && $this->arguments()->hasBeenModified()
+ ) {
+ return true;
+ }
+
+ foreach ($this->loadedRelatedSets as $set) {
+ if ($set->hasBeenModified()) {
+ return true;
+ }
+ }
+
+ foreach ($this->loadedMultiRelations as $rel) {
+ if ($rel->hasBeenModified()) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ protected function hasUnresolvedRelatedProperties()
+ {
+ return ! empty($this->unresolvedRelatedProperties);
+ }
+
+ protected function hasUnresolvedRelatedProperty($name)
+ {
+ return array_key_exists($name, $this->unresolvedRelatedProperties);
+ }
+
+ /**
+ * @param $key
+ * @return mixed
+ */
+ protected function getRelationId($key)
+ {
+ if ($this->hasUnresolvedRelatedProperty($key)) {
+ $this->resolveUnresolvedRelatedProperty($key);
+ }
+
+ return parent::get($key);
+ }
+
+ /**
+ * @param $key
+ * @return string|null
+ */
+ protected function getRelatedProperty($key)
+ {
+ $idKey = $key . '_id';
+ if ($this->hasUnresolvedRelatedProperty($idKey)) {
+ return $this->unresolvedRelatedProperties[$idKey];
+ }
+
+ if ($id = $this->get($idKey)) {
+ /** @var IcingaObject $class */
+ $class = $this->getRelationClass($key);
+ try {
+ $object = $class::loadWithAutoIncId($id, $this->connection);
+ } catch (NotFoundError $e) {
+ throw new RuntimeException($e->getMessage(), 0, $e);
+ }
+
+ return $object->getObjectName();
+ }
+
+ return null;
+ }
+
+ /**
+ * @param string $key
+ * @return \Icinga\Module\Director\CustomVariable\CustomVariable|mixed|null
+ */
+ public function get($key)
+ {
+ if (substr($key, 0, 5) === 'vars.') {
+ $var = $this->vars()->get(substr($key, 5));
+ if ($var === null) {
+ return $var;
+ }
+
+ return $var->getValue();
+ }
+
+ // e.g. zone_id
+ if ($this->propertyIsRelation($key)) {
+ return $this->getRelationId($key);
+ }
+
+ // e.g. zone
+ if ($this->hasRelation($key)) {
+ return $this->getRelatedProperty($key);
+ }
+
+ if ($this->propertyIsRelatedSet($key)) {
+ return $this->getRelatedSet($key)->toPlainObject();
+ }
+
+ if ($this->propertyIsMultiRelation($key)) {
+ return $this->getMultiRelation($key)->listRelatedNames();
+ }
+
+ return parent::get($key);
+ }
+
+ public function setProperties($props)
+ {
+ if (is_array($props)) {
+ if (array_key_exists('object_type', $props) && key($props) !== 'object_type') {
+ $type = $props['object_type'];
+ unset($props['object_type']);
+ $props = ['object_type' => $type] + $props;
+ }
+ }
+ return parent::setProperties($props);
+ }
+
+ public function set($key, $value)
+ {
+ if ($key === 'vars') {
+ $value = (array) $value;
+ $unset = [];
+ foreach ($this->vars() as $k => $f) {
+ if (! array_key_exists($k, $value)) {
+ $unset[] = $k;
+ }
+ }
+ foreach ($unset as $k) {
+ unset($this->vars()->$k);
+ }
+ foreach ($value as $k => $v) {
+ $this->vars()->set($k, $v);
+ }
+ return $this;
+ }
+
+ if (substr($key, 0, 5) === 'vars.') {
+ //TODO: allow for deep keys
+ $this->vars()->set(substr($key, 5), $value);
+ return $this;
+ }
+
+ if ($this instanceof ObjectWithArguments
+ && substr($key, 0, 10) === 'arguments.') {
+ $this->arguments()->set(substr($key, 10), $value);
+ return $this;
+ }
+
+ if ($this->propertyIsBoolean($key)) {
+ return parent::set($key, DbDataFormatter::normalizeBoolean($value));
+ }
+
+ // e.g. zone_id
+ if ($this->propertyIsRelation($key)) {
+ return $this->setRelation($key, $value);
+ }
+
+ // e.g. zone
+ if ($this->hasRelation($key)) {
+ return $this->setUnresolvedRelation($key, $value);
+ }
+
+ if ($this->propertyIsMultiRelation($key)) {
+ $this->setMultiRelation($key, $value);
+ return $this;
+ }
+
+ if ($this->propertyIsRelatedSet($key)) {
+ $this->getRelatedSet($key)->set($value);
+ return $this;
+ }
+
+ if ($this->propertyIsInterval($key)) {
+ return parent::set($key, c::parseInterval($value));
+ }
+
+ return parent::set($key, $value);
+ }
+
+ private function setRelation($key, $value)
+ {
+ if ((int) $key !== (int) $this->$key) {
+ unset($this->unresolvedRelatedProperties[$key]);
+ }
+ return parent::set($key, $value);
+ }
+
+ private function setUnresolvedRelation($key, $value)
+ {
+ if ($value === null || strlen($value) === 0) {
+ unset($this->unresolvedRelatedProperties[$key . '_id']);
+ return parent::set($key . '_id', null);
+ }
+
+ $this->unresolvedRelatedProperties[$key . '_id'] = $value;
+ return $this;
+ }
+
+ protected function setRanges($ranges)
+ {
+ $this->ranges()->set((array) $ranges);
+ return $this;
+ }
+
+ protected function getRanges()
+ {
+ return $this->ranges()->getValues();
+ }
+
+ protected function setDisabled($disabled)
+ {
+ return $this->reallySet('disabled', DbDataFormatter::normalizeBoolean($disabled));
+ }
+
+ public function isDisabled()
+ {
+ return $this->get('disabled') === 'y';
+ }
+
+ public function markForRemoval($remove = true)
+ {
+ $this->shouldBeRemoved = $remove;
+ return $this;
+ }
+
+ public function shouldBeRemoved()
+ {
+ return $this->shouldBeRemoved;
+ }
+
+ public function shouldBeRenamed()
+ {
+ return $this->hasBeenLoadedFromDb()
+ && $this->getOriginalProperty('object_name') !== $this->getObjectName();
+ }
+
+ /**
+ * @return IcingaObjectGroups
+ */
+ public function groups()
+ {
+ $this->assertGroupsSupport();
+ if ($this->groups === null) {
+ if ($this->hasBeenLoadedFromDb() && $this->get('id')) {
+ $this->groups = IcingaObjectGroups::loadForStoredObject($this);
+ } else {
+ $this->groups = new IcingaObjectGroups($this);
+ }
+ }
+
+ return $this->groups;
+ }
+
+ public function hasModifiedGroups()
+ {
+ $this->assertGroupsSupport();
+ if ($this->groups === null) {
+ return false;
+ }
+
+ return $this->groups->hasBeenModified();
+ }
+
+ public function getAppliedGroups()
+ {
+ $this->assertGroupsSupport();
+ if (! $this instanceof IcingaHost) {
+ throw new RuntimeException('getAppliedGroups is only available for hosts currently!');
+ }
+ if (! $this->hasBeenLoadedFromDb()) {
+ // There are no stored related/resolved groups. We'll also not resolve
+ // them here on demand.
+ return [];
+ }
+ $id = $this->get('id');
+ if ($id === null) {
+ // Do not fail for branches. Should be handled otherwise
+ // TODO: throw an Exception, once we are able to deal with this
+ return [];
+ }
+
+ $type = strtolower($this->getType());
+ $query = $this->db->select()->from(
+ ['gr' => "icinga_${type}group_${type}_resolved"],
+ ['g.object_name']
+ )->join(
+ ['g' => "icinga_${type}group"],
+ "g.id = gr.${type}group_id",
+ []
+ )->joinLeft(
+ ['go' => "icinga_${type}group_${type}"],
+ "go.${type}group_id = gr.${type}group_id AND go.${type}_id = " . (int) $id,
+ []
+ )->where(
+ "gr.${type}_id = ?",
+ (int) $id
+ )->where("go.${type}_id IS NULL")->order('g.object_name');
+
+ return $this->db->fetchCol($query);
+ }
+
+ /**
+ * @return IcingaTimePeriodRanges
+ */
+ public function ranges()
+ {
+ $this->assertRangesSupport();
+ if ($this->ranges === null) {
+ /** @var IcingaTimePeriodRanges $class */
+ $class = $this->getRangeClass();
+ if ($this->hasBeenLoadedFromDb()) {
+ $this->ranges = $class::loadForStoredObject($this);
+ } else {
+ $this->ranges = new $class($this);
+ }
+ }
+
+ return $this->ranges;
+ }
+
+ protected function getRangeClass()
+ {
+ if ($this->rangeClass === null) {
+ $this->rangeClass = get_class($this) . 'Ranges';
+ }
+
+ return $this->rangeClass;
+ }
+
+ /**
+ * @return IcingaObjectImports
+ */
+ public function imports()
+ {
+ $this->assertImportsSupport();
+ if ($this->imports === null) {
+ // can not use hasBeenLoadedFromDb() when in onStore()
+ if ($this->getProperty('id') !== null) {
+ $this->imports = IcingaObjectImports::loadForStoredObject($this);
+ } else {
+ $this->imports = new IcingaObjectImports($this);
+ }
+ }
+
+ return $this->imports;
+ }
+
+ public function gotImports()
+ {
+ return $this->imports !== null;
+ }
+
+ public function setImports($imports)
+ {
+ if (! is_array($imports) && $imports !== null) {
+ $imports = [$imports];
+ }
+
+ try {
+ $this->imports()->set($imports);
+ } catch (NestingError $e) {
+ $this->imports = new IcingaObjectImports($this);
+ // Force modification, otherwise it won't be stored when empty
+ $this->imports->setModified()->set($imports);
+ }
+
+ if ($this->imports()->hasBeenModified()) {
+ $this->invalidateResolveCache();
+ }
+ }
+
+ public function getImports()
+ {
+ return $this->listImportNames();
+ }
+
+ /**
+ * @deprecated This should no longer be in use
+ * @return IcingaTemplateResolver
+ */
+ public function templateResolver()
+ {
+ if ($this->templateResolver === null) {
+ $this->templateResolver = new IcingaTemplateResolver($this);
+ }
+
+ return $this->templateResolver;
+ }
+
+ public function getResolvedProperty($key, $default = null)
+ {
+ if (array_key_exists($key, $this->unresolvedRelatedProperties)) {
+ $this->resolveUnresolvedRelatedProperty($key);
+ $this->invalidateResolveCache();
+ }
+
+ $properties = $this->getResolvedProperties();
+ if (property_exists($properties, $key)) {
+ return $properties->$key;
+ }
+
+ return $default;
+ }
+
+ public function getInheritedProperty($key, $default = null)
+ {
+ if (array_key_exists($key, $this->unresolvedRelatedProperties)) {
+ $this->resolveUnresolvedRelatedProperty($key);
+ $this->invalidateResolveCache();
+ }
+
+ $properties = $this->getInheritedProperties();
+ if (property_exists($properties, $key)) {
+ return $properties->$key;
+ }
+
+ return $default;
+ }
+
+ public function getInheritedVar($varname)
+ {
+ try {
+ $vars = $this->getInheritedVars();
+ } catch (NestingError $e) {
+ return null;
+ }
+
+ if (property_exists($vars, $varname)) {
+ return $vars->$varname;
+ }
+
+ return null;
+ }
+
+ public function getResolvedVar($varName)
+ {
+ try {
+ $vars = $this->getResolvedVars();
+ } catch (NestingError $e) {
+ return null;
+ }
+
+ if (property_exists($vars, $varName)) {
+ return $vars->$varName;
+ }
+
+ return null;
+ }
+
+ public function getOriginForVar($varName)
+ {
+ try {
+ $origins = $this->getOriginsVars();
+ } catch (NestingError $e) {
+ return null;
+ }
+
+ if (property_exists($origins, $varName)) {
+ return $origins->$varName;
+ }
+
+ return null;
+ }
+
+ public function getResolvedProperties()
+ {
+ return $this->getResolved('Properties');
+ }
+
+ public function getInheritedProperties()
+ {
+ return $this->getInherited('Properties');
+ }
+
+ public function getOriginsProperties()
+ {
+ return $this->getOrigins('Properties');
+ }
+
+ public function resolveProperties()
+ {
+ return $this->resolve('Properties');
+ }
+
+ public function getResolvedVars()
+ {
+ return $this->getResolved('Vars');
+ }
+
+ public function getInheritedVars()
+ {
+ return $this->getInherited('Vars');
+ }
+
+ public function resolveVars()
+ {
+ return $this->resolve('Vars');
+ }
+
+ public function getOriginsVars()
+ {
+ return $this->getOrigins('Vars');
+ }
+
+ public function getVars()
+ {
+ $vars = [];
+ foreach ($this->vars() as $key => $var) {
+ if ($var->hasBeenDeleted()) {
+ continue;
+ }
+
+ $vars[$key] = $var->getValue();
+ }
+ ksort($vars);
+
+ return (object) $vars;
+ }
+
+ /**
+ * This is mostly for magic getters
+ * @return array
+ */
+ public function getGroups()
+ {
+ return $this->groups()->listGroupNames();
+ }
+
+ /**
+ * @return array
+ * @throws NotFoundError
+ */
+ public function listInheritedGroupNames()
+ {
+ $parents = $this->imports()->getObjects();
+ /** @var IcingaObject $parent */
+ foreach (array_reverse($parents) as $parent) {
+ $inherited = $parent->getGroups();
+ if (! empty($inherited)) {
+ return $inherited;
+ }
+ }
+
+ return [];
+ }
+
+ public function setGroups($groups)
+ {
+ $this->groups()->set($groups);
+ return $this;
+ }
+
+ /**
+ * @return array
+ * @throws NotFoundError
+ */
+ public function listResolvedGroupNames()
+ {
+ $groups = $this->groups()->listGroupNames();
+ if (empty($groups)) {
+ return $this->listInheritedGroupNames();
+ }
+
+ return $groups;
+ }
+
+ /**
+ * @param $group
+ * @return bool
+ * @throws NotFoundError
+ */
+ public function hasGroup($group)
+ {
+ if ($group instanceof static) {
+ $group = $group->getObjectName();
+ }
+
+ return in_array($group, $this->listResolvedGroupNames());
+ }
+
+ protected function getResolved($what)
+ {
+ $func = 'resolve' . $what;
+ $res = $this->$func();
+ return $res['_MERGED_'];
+ }
+
+ protected function getInherited($what)
+ {
+ $func = 'resolve' . $what;
+ $res = $this->$func();
+ return $res['_INHERITED_'];
+ }
+
+ protected function getOrigins($what)
+ {
+ $func = 'resolve' . $what;
+ $res = $this->$func();
+ return $res['_ORIGINS_'];
+ }
+
+ protected function hasResolveCached($what)
+ {
+ return array_key_exists($what, $this->resolveCache);
+ }
+
+ protected function & getResolveCached($what)
+ {
+ return $this->resolveCache[$what];
+ }
+
+ protected function storeResolvedCache($what, $vals)
+ {
+ $this->resolveCache[$what] = $vals;
+ }
+
+ public function invalidateResolveCache()
+ {
+ $this->resolveCache = [];
+ return $this;
+ }
+
+ public function countDirectDescendants()
+ {
+ $db = $this->getDb();
+ $table = $this->getTableName();
+ $type = $this->getShortTableName();
+
+ $query = $db->select()->from(
+ ['oi' => $table . '_inheritance'],
+ ['cnt' => 'COUNT(*)']
+ )->where('oi.parent_' . $type . '_id = ?', (int) $this->get('id'));
+
+ return $db->fetchOne($query);
+ }
+
+ protected function triggerLoopDetection()
+ {
+ // $this->templateResolver()->listResolvedParentIds();
+ }
+
+ public function getSingleResolvedProperty($key, $default = null)
+ {
+ if (array_key_exists($key, $this->unresolvedRelatedProperties)) {
+ $this->resolveUnresolvedRelatedProperty($key);
+ $this->invalidateResolveCache();
+ }
+
+ if ($my = $this->get($key)) {
+ if ($my !== null) {
+ return $my;
+ }
+ }
+
+ /** @var IcingaObject[] $imports */
+ try {
+ $imports = array_reverse($this->imports()->getObjects());
+ } catch (NotFoundError $e) {
+ throw new RuntimeException($e->getMessage(), 0, $e);
+ }
+
+ // Eventually trigger loop detection
+ $this->listAncestorIds();
+
+ foreach ($imports as $object) {
+ $v = $object->getSingleResolvedProperty($key);
+ if (null !== $v) {
+ return $v;
+ }
+ }
+
+ return $default;
+ }
+
+ protected function resolve($what)
+ {
+ if ($this->hasResolveCached($what)) {
+ return $this->getResolveCached($what);
+ }
+
+ // Force exception
+ if ($this->hasBeenLoadedFromDb()) {
+ $this->triggerLoopDetection();
+ }
+
+ $vals = [];
+ $vals['_MERGED_'] = (object) [];
+ $vals['_INHERITED_'] = (object) [];
+ $vals['_ORIGINS_'] = (object) [];
+ // $objects = $this->imports()->getObjects();
+ $objects = IcingaTemplateRepository::instanceByObject($this)
+ ->getTemplatesIndexedByNameFor($this, true);
+
+ $get = 'get' . $what;
+ $getInherited = 'getInherited' . $what;
+ $getOrigins = 'getOrigins' . $what;
+
+ $blacklist = ['id', 'uuid', 'object_type', 'object_name', 'disabled'];
+ foreach ($objects as $name => $object) {
+ $origins = $object->$getOrigins();
+
+ foreach ($object->$getInherited() as $key => $value) {
+ if (in_array($key, $blacklist)) {
+ continue;
+ }
+
+ if (! property_exists($origins, $key)) {
+ // TODO: Introduced with group membership resolver or
+ // choices - this should not be required. Check this!
+ continue;
+ }
+
+ // $vals[$name]->$key = $value;
+ $vals['_MERGED_']->$key = $value;
+ $vals['_INHERITED_']->$key = $value;
+ $vals['_ORIGINS_']->$key = $origins->$key;
+ }
+
+ foreach ($object->$get() as $key => $value) {
+ // TODO: skip if default value?
+ if ($value === null) {
+ continue;
+ }
+ if (in_array($key, $blacklist)) {
+ continue;
+ }
+ $vals['_MERGED_']->$key = $value;
+ $vals['_INHERITED_']->$key = $value;
+ $vals['_ORIGINS_']->$key = $name;
+ }
+ }
+
+ foreach ($this->$get() as $key => $value) {
+ if ($value === null) {
+ continue;
+ }
+
+ $vals['_MERGED_']->$key = $value;
+ }
+
+ $this->storeResolvedCache($what, $vals);
+
+ return $vals;
+ }
+
+ public function matches(Filter $filter)
+ {
+ // TODO: speed up by passing only desired properties (filter columns) to
+ // toPlainObject method
+ /** @var FilterChain|FilterExpression $filter */
+ return $filter->matches($this->toPlainObject());
+ }
+
+ protected function assertCustomVarsSupport()
+ {
+ if (! $this->supportsCustomVars()) {
+ throw new LogicException(sprintf(
+ 'Objects of type "%s" have no custom vars',
+ $this->getType()
+ ));
+ }
+
+ return $this;
+ }
+
+ protected function assertGroupsSupport()
+ {
+ if (! $this->supportsGroups()) {
+ throw new LogicException(sprintf(
+ 'Objects of type "%s" have no groups',
+ $this->getType()
+ ));
+ }
+
+ return $this;
+ }
+
+ protected function assertRangesSupport()
+ {
+ if (! $this->supportsRanges()) {
+ throw new LogicException(sprintf(
+ 'Objects of type "%s" have no ranges',
+ $this->getType()
+ ));
+ }
+
+ return $this;
+ }
+
+ protected function assertImportsSupport()
+ {
+ if (! $this->supportsImports()) {
+ throw new LogicException(sprintf(
+ 'Objects of type "%s" have no imports',
+ $this->getType()
+ ));
+ }
+
+ return $this;
+ }
+
+ /**
+ * @return CustomVariables
+ */
+ public function vars()
+ {
+ $this->assertCustomVarsSupport();
+ if ($this->vars === null) {
+ if ($this->hasBeenLoadedFromDb()) {
+ if (PrefetchCache::shouldBeUsed()) {
+ $this->vars = PrefetchCache::instance()->vars($this);
+ } else {
+ if ($this->get('id')) {
+ $this->vars = CustomVariables::loadForStoredObject($this);
+ } else {
+ $this->vars = new CustomVariables();
+ }
+ }
+
+ if ($this->getShortTableName() === 'host') {
+ $this->vars->setOverrideKeyName(
+ $this->getConnection()->settings()->override_services_varname
+ );
+ }
+ } else {
+ $this->vars = new CustomVariables();
+ }
+ }
+
+ return $this->vars;
+ }
+
+ /**
+ * @return bool
+ */
+ public function hasInitializedVars()
+ {
+ $this->assertCustomVarsSupport();
+
+ return $this->vars !== null;
+ }
+
+ public function getVarsTableName()
+ {
+ return $this->getTableName() . '_var';
+ }
+
+ public function getShortTableName()
+ {
+ // strlen('icinga_') = 7
+ return substr($this->getTableName(), 7);
+ }
+
+ public function getVarsIdColumn()
+ {
+ return $this->getShortTableName() . '_id';
+ }
+
+ public function hasProperty($key)
+ {
+ if ($this->propertyIsRelatedSet($key)) {
+ return true;
+ }
+
+ if ($this->propertyIsMultiRelation($key)) {
+ return true;
+ }
+
+ return parent::hasProperty($key);
+ }
+
+ public function isObject()
+ {
+ return $this->hasProperty('object_type')
+ && $this->get('object_type') === 'object';
+ }
+
+ public function isTemplate()
+ {
+ return $this->hasProperty('object_type')
+ && $this->get('object_type') === 'template';
+ }
+
+ public function isExternal()
+ {
+ return $this->hasProperty('object_type')
+ && $this->get('object_type') === 'external_object';
+ }
+
+ public function isApplyRule()
+ {
+ return $this->hasProperty('object_type')
+ && $this->get('object_type') === 'apply';
+ }
+
+ public function setBeingLoadedFromDb()
+ {
+ if ($this instanceof ObjectWithArguments && $this->gotArguments()) {
+ $this->arguments()->setBeingLoadedFromDb();
+ }
+ if ($this->supportsImports() && $this->gotImports()) {
+ $this->imports()->setBeingLoadedFromDb();
+ }
+ if ($this->supportsCustomVars() && $this->vars !== null) {
+ $this->vars()->setBeingLoadedFromDb();
+ }
+ if ($this->supportsGroups() && $this->groups !== null) {
+ $this->groups()->setBeingLoadedFromDb();
+ }
+ if ($this->supportsRanges() && $this->ranges !== null) {
+ $this->ranges()->setBeingLoadedFromDb();
+ }
+
+ foreach ($this->loadedRelatedSets as $set) {
+ $set->setBeingLoadedFromDb();
+ }
+
+ foreach ($this->loadedMultiRelations as $multiRelation) {
+ $multiRelation->setBeingLoadedFromDb();
+ }
+ // This might trigger DB requests and 404's. We might want to defer this, but a call to
+ // hasBeenModified triggers anyway:
+ $this->resolveUnresolvedRelatedProperties();
+
+ parent::setBeingLoadedFromDb();
+ }
+
+ /**
+ * @throws NotFoundError
+ * @throws \Icinga\Module\Director\Exception\DuplicateKeyException
+ * @throws \Zend_Db_Adapter_Exception
+ */
+ protected function storeRelatedObjects()
+ {
+ $this
+ ->storeCustomVars()
+ ->storeGroups()
+ ->storeMultiRelations()
+ ->storeImports()
+ ->storeRanges()
+ ->storeRelatedSets()
+ ->storeArguments();
+ }
+
+ /**
+ * @throws NotFoundError
+ */
+ protected function beforeStore()
+ {
+ $this->resolveUnresolvedRelatedProperties();
+ if ($this->gotImports()) {
+ $this->imports()->getObjects();
+ }
+ }
+
+ /**
+ * @throws NotFoundError
+ * @throws \Icinga\Module\Director\Exception\DuplicateKeyException
+ * @throws \Zend_Db_Adapter_Exception
+ */
+ public function onInsert()
+ {
+ DirectorActivityLog::logCreation($this, $this->connection);
+ $this->storeRelatedObjects();
+ }
+
+ /**
+ * @throws NotFoundError
+ * @throws \Icinga\Module\Director\Exception\DuplicateKeyException
+ * @throws \Zend_Db_Adapter_Exception
+ */
+ public function onUpdate()
+ {
+ DirectorActivityLog::logModification($this, $this->connection);
+ $this->storeRelatedObjects();
+ }
+
+ public function onStore()
+ {
+ $this->notifyResolvers();
+ }
+
+ /**
+ * @return self
+ */
+ protected function storeCustomVars()
+ {
+ if ($this->supportsCustomVars()) {
+ $this->vars !== null && $this->vars()->storeToDb($this);
+ }
+
+ return $this;
+ }
+
+ /**
+ * @return self
+ */
+ protected function storeGroups()
+ {
+ if ($this->supportsGroups()) {
+ $this->groups !== null && $this->groups()->store();
+ }
+
+ return $this;
+ }
+
+ /**
+ * @return self
+ */
+ protected function storeMultiRelations()
+ {
+ foreach ($this->loadedMultiRelations as $rel) {
+ $rel->store();
+ }
+
+ return $this;
+ }
+
+ /**
+ * @return self
+ */
+ protected function storeRanges()
+ {
+ if ($this->supportsRanges()) {
+ $this->ranges !== null && $this->ranges()->store();
+ }
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ * @throws \Icinga\Module\Director\Exception\DuplicateKeyException
+ */
+ protected function storeArguments()
+ {
+ if ($this instanceof ObjectWithArguments) {
+ $this->gotArguments() && $this->arguments()->store();
+ }
+
+ return $this;
+ }
+
+ protected function notifyResolvers()
+ {
+ }
+
+ /**
+ * @return $this
+ */
+ protected function storeRelatedSets()
+ {
+ foreach ($this->loadedRelatedSets as $set) {
+ if ($set->hasBeenModified()) {
+ $set->store();
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ * @throws NotFoundError
+ * @throws \Zend_Db_Adapter_Exception
+ */
+ protected function storeImports()
+ {
+ if ($this->supportsImports()) {
+ $this->imports !== null && $this->imports()->store();
+ }
+
+ return $this;
+ }
+
+ public function beforeDelete()
+ {
+ $this->cachedPlainUnmodified = $this->getPlainUnmodifiedObject();
+ }
+
+ public function getCachedUnmodifiedObject()
+ {
+ return $this->cachedPlainUnmodified;
+ }
+
+ public function onDelete()
+ {
+ DirectorActivityLog::logRemoval($this, $this->connection);
+ }
+
+ public function toSingleIcingaConfig()
+ {
+ $config = new IcingaConfig($this->connection);
+ $object = $this;
+ if ($object->isExternal()) {
+ $object->set('object_type', 'object');
+ $wasExternal = true;
+ } else {
+ $wasExternal = false;
+ }
+
+ try {
+ $object->renderToConfig($config);
+ } catch (Exception $e) {
+ $message = $e->getMessage();
+ $showTrace = false;
+ if ($showTrace) {
+ $message .= "\n" . $e->getTraceAsString();
+ }
+ $config->configFile(
+ 'failed-to-render'
+ )->prepend(
+ "/** Failed to render this object **/\n"
+ . '/* ' . $message . ' */'
+ );
+ }
+ if ($wasExternal) {
+ $object->set('object_type', 'external_object');
+ }
+
+ return $config;
+ }
+
+ public function isSupportedInLegacy()
+ {
+ return $this->supportedInLegacy;
+ }
+
+ public function renderToLegacyConfig(IcingaConfig $config)
+ {
+ if ($this->isExternal()) {
+ return;
+ }
+
+ if (! $this->isSupportedInLegacy()) {
+ $config->configFile(
+ 'director/ignored-objects',
+ '.cfg'
+ )->prepend(
+ sprintf(
+ "# Not supported for legacy config: %s object_name=%s\n",
+ get_class($this),
+ $this->getObjectName()
+ )
+ );
+ return;
+ }
+
+ $filename = $this->getRenderingFilename();
+
+ $deploymentMode = $config->getDeploymentMode();
+ if ($deploymentMode === 'active-passive') {
+ if ($this->getSingleResolvedProperty('zone_id')
+ && array_key_exists('enable_active_checks', $this->defaultProperties)
+ ) {
+ $passive = clone($this);
+ $passive->set('enable_active_checks', false);
+
+ $config->configFile(
+ 'director/master/' . $filename,
+ '.cfg'
+ )->addLegacyObject($passive);
+ }
+ } elseif ($deploymentMode === 'masterless') {
+ // no additional config
+ } else {
+ throw new LogicException(sprintf(
+ 'Unsupported deployment mode: %s',
+ $deploymentMode
+ ));
+ }
+
+ $config->configFile(
+ 'director/' . $this->getRenderingZone($config) . '/' . $filename,
+ '.cfg'
+ )->addLegacyObject($this);
+ }
+
+ public function renderToConfig(IcingaConfig $config)
+ {
+ if ($config->isLegacy()) {
+ $this->renderToLegacyConfig($config);
+ return;
+ }
+
+ if ($this->isExternal()) {
+ return;
+ }
+
+ $config->configFile(
+ 'zones.d/' . $this->getRenderingZone($config) . '/' . $this->getRenderingFilename()
+ )->addObject($this);
+ }
+
+ public function getRenderingFilename()
+ {
+ $type = $this->getShortTableName();
+
+ if ($this->isTemplate()) {
+ $filename = strtolower($type) . '_templates';
+ } elseif ($this->isApplyRule()) {
+ $filename = strtolower($type) . '_apply';
+ } else {
+ $filename = strtolower($type) . 's';
+ }
+
+ return $filename;
+ }
+
+ /**
+ * @param $zoneId
+ * @param IcingaConfig|null $config
+ * @return string
+ * @throws NotFoundError
+ */
+ protected function getNameForZoneId($zoneId, IcingaConfig $config = null)
+ {
+ // TODO: this is still ugly.
+ if ($config === null) {
+ return IcingaZone::loadWithAutoIncId(
+ $zoneId,
+ $this->getConnection()
+ )->getObjectName();
+ }
+
+ // Config has a lookup cache, is faster:
+ return $config->getZoneName($zoneId);
+ }
+
+ public function getRenderingZone(IcingaConfig $config = null)
+ {
+ if ($this->hasUnresolvedRelatedProperty('zone_id')) {
+ return $this->get('zone');
+ }
+
+ if ($this->hasProperty('zone_id')) {
+ try {
+ if (! $this->supportsImports()) {
+ if ($zoneId = $this->get('zone_id')) {
+ return $this->getNameForZoneId($zoneId, $config);
+ }
+ }
+
+ if ($zoneId = $this->getSingleResolvedProperty('zone_id')) {
+ return $this->getNameForZoneId($zoneId, $config);
+ }
+ } catch (NestingError $e) {
+ throw $e;
+ } catch (Exception $e) {
+ return self::RESOLVE_ERROR;
+ }
+ }
+
+ return $this->getDefaultZone($config);
+ }
+
+ protected function getDefaultZone(IcingaConfig $config = null)
+ {
+ if ($this->prefersGlobalZone()) {
+ return $this->connection->getDefaultGlobalZoneName();
+ }
+
+ return $this->connection->getMasterZoneName();
+ }
+
+ protected function prefersGlobalZone()
+ {
+ return $this->isTemplate() || $this->isApplyRule();
+ }
+
+ protected function renderImports()
+ {
+ if (! $this->supportsImports()) {
+ return '';
+ }
+
+ $ret = '';
+ foreach ($this->getImports() as $name) {
+ $ret .= ' import ' . c::renderString($name) . "\n";
+ }
+
+ if ($ret !== '') {
+ $ret .= "\n";
+ }
+
+ return $ret;
+ }
+
+ protected function renderLegacyImports()
+ {
+ if ($this->supportsImports()) {
+ return $this->imports()->toLegacyConfigString();
+ }
+
+ return '';
+ }
+
+ protected function renderLegacyRelationProperty($propertyName, $id, $renderKey = null)
+ {
+ return $this->renderLegacyObjectProperty(
+ $renderKey ?: $propertyName,
+ c1::renderString($this->getRelatedObjectName($propertyName, $id))
+ );
+ }
+
+ // Disabled is a virtual property
+ protected function renderDisabled()
+ {
+ return '';
+ }
+
+ /**
+ * @codingStandardsIgnoreStart
+ */
+ protected function renderLegacyHost_id($value)
+ {
+ if (is_array($value)) {
+ return c1::renderKeyValue('host_name', c1::renderArray($value));
+ }
+
+ return $this->renderLegacyRelationProperty(
+ 'host',
+ $this->get('host_id'),
+ 'host_name'
+ );
+ }
+
+ /**
+ * Display Name only exists for host/service in Icinga 1
+ *
+ * Render it as alias for everything by default.
+ *
+ * Alias does not exist in Icinga 2 currently!
+ *
+ * @return string
+ */
+ protected function renderLegacyDisplay_Name()
+ {
+ return c1::renderKeyValue('alias', $this->display_name);
+ }
+
+ protected function renderLegacyTimeout()
+ {
+ return '';
+ }
+
+ protected function renderLegacyEnable_active_checks()
+ {
+ return $this->renderLegacyBooleanProperty(
+ 'enable_active_checks',
+ 'active_checks_enabled'
+ );
+ }
+
+ protected function renderLegacyEnable_passive_checks()
+ {
+ return $this->renderLegacyBooleanProperty(
+ 'enable_passive_checks',
+ 'passive_checks_enabled'
+ );
+ }
+
+ protected function renderLegacyEnable_event_handler()
+ {
+ return $this->renderLegacyBooleanProperty(
+ 'enable_active_checks',
+ 'event_handler_enabled'
+ );
+ }
+
+ protected function renderLegacyEnable_notifications()
+ {
+ return $this->renderLegacyBooleanProperty(
+ 'enable_notifications',
+ 'notifications_enabled'
+ );
+ }
+
+ protected function renderLegacyEnable_perfdata()
+ {
+ return $this->renderLegacyBooleanProperty(
+ 'enable_perfdata',
+ 'process_perf_data'
+ );
+ }
+
+ protected function renderLegacyVolatile()
+ {
+ // @codingStandardsIgnoreEnd
+ return $this->renderLegacyBooleanProperty(
+ 'volatile',
+ 'is_volatile'
+ );
+ }
+
+ protected function renderLegacyBooleanProperty($property, $legacyKey)
+ {
+ return c1::renderKeyValue(
+ $legacyKey,
+ c1::renderBoolean($this->get($property))
+ );
+ }
+
+ protected function renderProperties()
+ {
+ $out = '';
+ $blacklist = array_merge(
+ $this->propertiesNotForRendering,
+ $this->prioritizedProperties
+ );
+
+ foreach ($this->properties as $key => $value) {
+ if (in_array($key, $blacklist)) {
+ continue;
+ }
+
+ $out .= $this->renderObjectProperty($key, $value);
+ }
+
+ return $out;
+ }
+
+ protected function renderLegacyProperties()
+ {
+ $out = '';
+ $blacklist = array_merge(
+ $this->propertiesNotForRendering,
+ [] /* $this->prioritizedProperties */
+ );
+
+ foreach ($this->properties as $key => $value) {
+ if (in_array($key, $blacklist)) {
+ continue;
+ }
+
+ $out .= $this->renderLegacyObjectProperty($key, $value);
+ }
+
+ return $out;
+ }
+
+ protected function renderPrioritizedProperties()
+ {
+ $out = '';
+
+ foreach ($this->prioritizedProperties as $key) {
+ $out .= $this->renderObjectProperty($key, $this->properties[$key]);
+ }
+
+ return $out;
+ }
+
+ protected function renderObjectProperty($key, $value)
+ {
+ if (substr($key, -3) === '_id') {
+ $short = substr($key, 0, -3);
+ if ($this->hasUnresolvedRelatedProperty($key)) {
+ return c::renderKeyValue(
+ $short, // NOT
+ c::renderString($this->$short)
+ );
+ }
+ }
+
+ if ($value === null) {
+ return '';
+ }
+
+ $method = 'render' . ucfirst($key);
+ if (method_exists($this, $method)) {
+ return $this->$method($value);
+ }
+
+ if ($this->propertyIsBoolean($key)) {
+ if ($value === $this->defaultProperties[$key]) {
+ return '';
+ }
+
+ return c::renderKeyValue(
+ $this->booleans[$key],
+ c::renderBoolean($value)
+ );
+ }
+
+ if ($this->propertyIsInterval($key)) {
+ return c::renderKeyValue(
+ $this->intervalProperties[$key],
+ c::renderInterval($value)
+ );
+ }
+
+ if (substr($key, -3) === '_id'
+ && $this->hasRelation($relKey = substr($key, 0, -3))
+ ) {
+ return $this->renderRelationProperty($relKey, $value);
+ }
+
+ return c::renderKeyValue(
+ $key,
+ $this->isApplyRule() ?
+ c::renderStringWithVariables($value) :
+ c::renderString($value)
+ );
+ }
+
+ protected function renderLegacyObjectProperty($key, $value)
+ {
+ if (substr($key, -3) === '_id') {
+ $short = substr($key, 0, -3);
+ if ($this->hasUnresolvedRelatedProperty($key)) {
+ return c1::renderKeyValue(
+ $short, // NOT
+ c1::renderString($this->$short)
+ );
+ }
+ }
+
+ if ($value === null) {
+ return '';
+ }
+
+ $method = 'renderLegacy' . ucfirst($key);
+ if (method_exists($this, $method)) {
+ return $this->$method($value);
+ }
+
+ $method = 'render' . ucfirst($key);
+ if (method_exists($this, $method)) {
+ return $this->$method($value);
+ }
+
+ if ($this->propertyIsBoolean($key)) {
+ if ($value === $this->defaultProperties[$key]) {
+ return '';
+ }
+
+ return c1::renderKeyValue(
+ $this->booleans[$key],
+ c1::renderBoolean($value)
+ );
+ }
+
+ if ($this->propertyIsInterval($key)) {
+ return c1::renderKeyValue(
+ $this->intervalProperties[$key],
+ c1::renderInterval($value)
+ );
+ }
+
+ if (substr($key, -3) === '_id'
+ && $this->hasRelation($relKey = substr($key, 0, -3))
+ ) {
+ return $this->renderLegacyRelationProperty($relKey, $value);
+ }
+
+ return c1::renderKeyValue($key, c1::renderString($value));
+ }
+
+ protected function renderBooleanProperty($key)
+ {
+ return c::renderKeyValue($key, c::renderBoolean($this->get($key)));
+ }
+
+ protected function renderPropertyAsSeconds($key)
+ {
+ return c::renderKeyValue($key, c::renderInterval($this->get($key)));
+ }
+
+ protected function renderSuffix()
+ {
+ return "}\n\n";
+ }
+
+ protected function renderLegacySuffix()
+ {
+ return "}\n\n";
+ }
+
+ /**
+ * @return string
+ */
+ protected function renderCustomVars()
+ {
+ if ($this->supportsCustomVars()) {
+ return $this->vars()->toConfigString($this->isApplyRule());
+ }
+
+ return '';
+ }
+
+ /**
+ * @return string
+ */
+ protected function renderLegacyCustomVars()
+ {
+ if ($this->supportsCustomVars()) {
+ return $this->vars()->toLegacyConfigString();
+ }
+
+ return '';
+ }
+
+ public function renderUuid()
+ {
+ return '';
+ }
+
+ /**
+ * @return string
+ */
+ protected function renderGroups()
+ {
+ if ($this->supportsGroups()) {
+ return $this->groups()->toConfigString();
+ }
+
+ return '';
+ }
+
+ /**
+ * @return string
+ */
+ protected function renderLegacyGroups()
+ {
+ if ($this->supportsGroups() && $this->hasBeenLoadedFromDb()) {
+ $applied = [];
+ if ($this instanceof IcingaHost) {
+ $applied = $this->getAppliedGroups();
+ }
+ return $this->groups()->toLegacyConfigString($applied);
+ }
+
+ return '';
+ }
+
+ /**
+ * @return string
+ */
+ protected function renderMultiRelations()
+ {
+ $out = '';
+ foreach ($this->loadAllMultiRelations() as $rel) {
+ $out .= $rel->toConfigString();
+ }
+
+ return $out;
+ }
+
+ /**
+ * @return string
+ */
+ protected function renderLegacyMultiRelations()
+ {
+ $out = '';
+ foreach ($this->loadAllMultiRelations() as $rel) {
+ $out .= $rel->toLegacyConfigString();
+ }
+
+ return $out;
+ }
+
+ /**
+ * @return string
+ */
+ protected function renderRanges()
+ {
+ if ($this->supportsRanges()) {
+ return $this->ranges()->toConfigString();
+ }
+
+ return '';
+ }
+
+ /**
+ * @return string
+ */
+ protected function renderLegacyRanges()
+ {
+ if ($this->supportsRanges()) {
+ return $this->ranges()->toLegacyConfigString();
+ }
+
+ return '';
+ }
+
+ /**
+ * @return string
+ */
+ protected function renderArguments()
+ {
+ return '';
+ }
+
+ protected function renderRelatedSets()
+ {
+ $config = '';
+ foreach ($this->relatedSets as $property => $class) {
+ $config .= $this->getRelatedSet($property)->renderAs($property);
+ }
+ return $config;
+ }
+
+ protected function renderRelationProperty($propertyName, $id, $renderKey = null)
+ {
+ return c::renderKeyValue(
+ $renderKey ?: $propertyName,
+ c::renderString($this->getRelatedObjectName($propertyName, $id))
+ );
+ }
+
+ protected function renderCommandProperty($commandId, $propertyName = 'check_command')
+ {
+ return c::renderKeyValue(
+ $propertyName,
+ c::renderString($this->connection->getCommandName($commandId))
+ );
+ }
+
+ /**
+ * @param $value
+ * @return string
+ * @codingStandardsIgnoreStart
+ */
+ protected function renderLegacyCheck_command($value)
+ {
+ // @codingStandardsIgnoreEnd
+ $args = [];
+ foreach ($this->vars() as $k => $v) {
+ if (substr($k, 0, 3) === 'ARG') {
+ $args[] = $v->getValue();
+ }
+ }
+ array_unshift($args, $value);
+
+ return c1::renderKeyValue('check_command', implode('!', $args));
+ }
+
+ /**
+ * @param $value
+ * @return string
+ * @codingStandardsIgnoreStart
+ */
+ protected function renderLegacyEvent_command($value)
+ {
+ // @codingStandardsIgnoreEnd
+ return c1::renderKeyValue('event_handler', $value);
+ }
+
+ /**
+ * We do not render zone properties, objects are stored to zone dirs
+ *
+ * Avoid complaints for method names with underscore:
+ * @codingStandardsIgnoreStart
+ *
+ * @return string
+ */
+ protected function renderZone_id()
+ {
+ // @codingStandardsIgnoreEnd
+ return '';
+ }
+
+ protected function renderCustomExtensions()
+ {
+ return '';
+ }
+
+ protected function renderLegacyCustomExtensions()
+ {
+ $str = '';
+
+ // Set notification settings for the object to suppress warnings
+ if (array_key_exists('enable_notifications', $this->defaultProperties)
+ && $this->isTemplate()
+ ) {
+ $str .= c1::renderKeyValue('notification_period', 'notification_none');
+ $str .= c1::renderKeyValue('notification_interval', '0');
+ $str .= c1::renderKeyValue('contact_groups', 'icingaadmins');
+ }
+
+ // force rendering of check_command when ARG1 is set
+ if ($this->supportsCustomVars() && array_key_exists('check_command_id', $this->defaultProperties)) {
+ if ($this->get('check_command') === null
+ && $this->vars()->get('ARG1') !== null
+ ) {
+ $command = $this->getResolvedRelated('check_command');
+ $str .= $this->renderLegacyCheck_command($command->getObjectName());
+ }
+ }
+
+ return $str;
+ }
+
+ protected function renderObjectHeader()
+ {
+ return sprintf(
+ "%s %s %s {\n",
+ $this->getObjectTypeName(),
+ $this->getType(),
+ c::renderString($this->getObjectName())
+ );
+ }
+
+ public function getLegacyObjectType()
+ {
+ return strtolower($this->getType());
+ }
+
+ protected function renderLegacyObjectHeader()
+ {
+ $type = $this->getLegacyObjectType();
+
+ if ($this->isTemplate()) {
+ $name = c1::renderKeyValue(
+ $this->getLegacyObjectKeyName(),
+ c1::renderString($this->getObjectName())
+ );
+ } else {
+ $name = c1::renderKeyValue(
+ $this->getLegacyObjectKeyName(),
+ c1::renderString($this->getObjectName())
+ );
+ }
+
+ $str = "define $type {\n$name";
+ if ($this->isTemplate()) {
+ $str .= c1::renderKeyValue('register', '0');
+ }
+
+ return $str;
+ }
+
+ protected function getLegacyObjectKeyName()
+ {
+ if ($this->isTemplate()) {
+ return 'name';
+ }
+
+ return $this->getLegacyObjectType() . '_name';
+ }
+
+ /**
+ * @codingStandardsIgnoreStart
+ */
+ public function renderAssign_Filter()
+ {
+ return ' ' . AssignRenderer::forFilter(
+ Filter::fromQueryString($this->get('assign_filter'))
+ )->renderAssign() . "\n";
+ }
+
+ public function renderLegacyAssign_Filter()
+ {
+ // @codingStandardsIgnoreEnd
+ if ($this instanceof IcingaHostGroup) {
+ $c = " # resolved memberships are set via the individual object\n";
+ } elseif ($this instanceof IcingaService) {
+ $c = " # resolved objects are listed here\n";
+ } else {
+ $c = " # assign is not supported for " . $this->type . "\n";
+ }
+ $c .= ' #' . AssignRenderer::forFilter(
+ Filter::fromQueryString($this->get('assign_filter'))
+ )->renderAssign() . "\n";
+ return $c;
+ }
+
+ public function toLegacyConfigString()
+ {
+ $str = implode([
+ $this->renderLegacyObjectHeader(),
+ $this->renderLegacyImports(),
+ $this->renderLegacyProperties(),
+ //$this->renderArguments(),
+ //$this->renderRelatedSets(),
+ $this->renderLegacyGroups(),
+ $this->renderLegacyMultiRelations(),
+ $this->renderLegacyRanges(),
+ $this->renderLegacyCustomExtensions(),
+ $this->renderLegacyCustomVars(),
+ $this->renderLegacySuffix()
+ ]);
+
+ $str = $this->alignLegacyProperties($str);
+
+ if ($this->isDisabled()) {
+ return
+ "# --- This object has been disabled ---\n"
+ . preg_replace('~^~m', '# ', trim($str))
+ . "\n\n";
+ }
+
+ return $str;
+ }
+
+ protected function alignLegacyProperties($configString)
+ {
+ $lines = explode("\n", $configString);
+ $len = 24;
+
+ foreach ($lines as &$line) {
+ if (preg_match('/^\s{4}([^\t]+)\t+(.+)$/', $line, $m)) {
+ if ($len - strlen($m[1]) < 0) {
+ $fill = ' ';
+ } else {
+ $fill = str_repeat(' ', $len - strlen($m[1]));
+ }
+
+ $line = ' ' . $m[1] . $fill . $m[2];
+ }
+ }
+
+ return implode("\n", $lines);
+ }
+
+ public function toConfigString()
+ {
+ $str = implode([
+ $this->renderObjectHeader(),
+ $this->renderPrioritizedProperties(),
+ $this->renderImports(),
+ $this->renderProperties(),
+ $this->renderArguments(),
+ $this->renderRelatedSets(),
+ $this->renderGroups(),
+ $this->renderMultiRelations(),
+ $this->renderRanges(),
+ $this->renderCustomExtensions(),
+ $this->renderCustomVars(),
+ $this->renderSuffix()
+ ]);
+
+ if ($this->isDisabled()) {
+ return "/* --- This object has been disabled ---\n"
+ // Do not allow strings to break our comment
+ . str_replace('*/', "* /", $str) . "*/\n";
+ }
+
+ return $str;
+ }
+
+ public function isGroup()
+ {
+ return substr($this->getType(), -5) === 'Group';
+ }
+
+ public function hasCheckCommand()
+ {
+ return false;
+ }
+
+ protected function getType()
+ {
+ if ($this->type === null) {
+ $parts = explode('\\', get_class($this));
+ // 6 = strlen('Icinga');
+ $this->type = substr(end($parts), 6);
+ }
+
+ return $this->type;
+ }
+
+ protected function getObjectTypeName()
+ {
+ if ($this->isTemplate()) {
+ return 'template';
+ }
+ if ($this->isApplyRule()) {
+ return 'apply';
+ }
+
+ return 'object';
+ }
+
+ public function getObjectName()
+ {
+ $property = static::getKeyColumnName();
+ if ($this->hasProperty($property)) {
+ return $this->get($property);
+ }
+
+ throw new LogicException(sprintf(
+ 'Trying to access "%s" for an instance of "%s"',
+ $property,
+ get_class($this)
+ ));
+ }
+
+ /**
+ * @deprecated use DbObjectTypeRegistry::classByType()
+ * @param $type
+ * @return string
+ */
+ public static function classByType($type)
+ {
+ return DbObjectTypeRegistry::classByType($type);
+ }
+
+ /**
+ * @param $type
+ * @param array $properties
+ * @param Db|null $db
+ *
+ * @return IcingaObject
+ */
+ public static function createByType($type, $properties = [], Db $db = null)
+ {
+ /** @var IcingaObject $class */
+ $class = DbObjectTypeRegistry::classByType($type);
+ return $class::create($properties, $db);
+ }
+
+ /**
+ * @param $type
+ * @param $id
+ * @param Db $db
+ *
+ * @return IcingaObject
+ * @throws NotFoundError
+ */
+ public static function loadByType($type, $id, Db $db)
+ {
+ /** @var IcingaObject $class */
+ $class = DbObjectTypeRegistry::classByType($type);
+ return $class::load($id, $db);
+ }
+
+ /**
+ * @param $type
+ * @param $id
+ * @param Db $db
+ *
+ * @return bool
+ */
+ public static function existsByType($type, $id, Db $db)
+ {
+ /** @var IcingaObject $class */
+ $class = DbObjectTypeRegistry::classByType($type);
+ return $class::exists($id, $db);
+ }
+
+ public static function getKeyColumnName()
+ {
+ return 'object_name';
+ }
+
+ public static function loadAllByType($type, Db $db, $query = null, $keyColumn = null)
+ {
+ /** @var DbObject $class */
+ $class = DbObjectTypeRegistry::classByType($type);
+
+ if ($keyColumn === null) {
+ if (method_exists($class, 'getKeyColumnName')) {
+ $keyColumn = $class::getKeyColumnName();
+ }
+ }
+
+ if (is_array($class::create()->getKeyName())) {
+ return $class::loadAll($db, $query);
+ }
+
+ if (PrefetchCache::shouldBeUsed()
+ && $query === null
+ && $keyColumn === static::getKeyColumnName()
+ ) {
+ $result = [];
+ foreach ($class::prefetchAll($db) as $row) {
+ $result[$row->$keyColumn] = $row;
+ }
+
+ return $result;
+ }
+
+ return $class::loadAll($db, $query, $keyColumn);
+ }
+
+ /**
+ * @param $type
+ * @param Db $db
+ * @return IcingaObject[]
+ */
+ public static function loadAllExternalObjectsByType($type, Db $db)
+ {
+ /** @var IcingaObject $class */
+ $class = DbObjectTypeRegistry::classByType($type);
+ $dummy = $class::create();
+
+ if (is_array($dummy->getKeyName())) {
+ throw new LogicException(sprintf(
+ 'There is no support for loading external objects of type "%s"',
+ $type
+ ));
+ }
+
+ $query = $db->getDbAdapter()
+ ->select()
+ ->from($dummy->getTableName())
+ ->where('object_type = ?', 'external_object');
+
+ return $class::loadAll($db, $query, 'object_name');
+ }
+
+ public static function fromJson($json, Db $connection = null)
+ {
+ return static::fromPlainObject(json_decode($json), $connection);
+ }
+
+ public static function fromPlainObject($plain, Db $connection = null)
+ {
+ return static::create((array) $plain, $connection);
+ }
+
+ /**
+ * @param IcingaObject $object
+ * @param null $preserve
+ * @return $this
+ * @throws NotFoundError
+ */
+ public function replaceWith(IcingaObject $object, $preserve = [])
+ {
+ return $this->replaceWithProperties($object->toPlainObject(), $preserve);
+ }
+
+ /**
+ * @param array|object $properties
+ * @param array $preserve
+ * @return $this
+ * @throws NotFoundError
+ */
+ public function replaceWithProperties($properties, $preserve = [])
+ {
+ $properties = (array) $properties;
+ foreach ($preserve as $k) {
+ $v = $this->get($k);
+ if ($v !== null) {
+ $properties[$k] = $v;
+ }
+ }
+ $this->setProperties($properties);
+
+ return $this;
+ }
+
+ /**
+ * TODO: with rules? What if I want to override vars? Drop in favour of vars.x?
+ *
+ * @param IcingaObject $object
+ * @param bool $replaceVars
+ * @return $this
+ * @throws NotFoundError
+ */
+ public function merge(IcingaObject $object, $replaceVars = false)
+ {
+ $object = clone($object);
+
+ if ($object->supportsCustomVars()) {
+ $vars = $object->getVars();
+ $object->set('vars', []);
+ }
+
+ if ($object->supportsGroups()) {
+ $groups = $object->getGroups();
+ $object->set('groups', []);
+ }
+
+ if ($object->supportsImports()) {
+ $imports = $object->listImportNames();
+ $object->set('imports', []);
+ }
+
+ $plain = (array) $object->toPlainObject(false, false);
+ unset($plain['vars'], $plain['groups'], $plain['imports']);
+ foreach ($plain as $p => $v) {
+ if ($v === null) {
+ // We want default values, but no null values
+ continue;
+ }
+
+ $this->set($p, $v);
+ }
+
+ if ($object->supportsCustomVars()) {
+ $myVars = $this->vars();
+ if ($replaceVars) {
+ $this->set('vars', $vars);
+ } else {
+ /** @var CustomVariables $vars */
+ foreach ($vars as $key => $var) {
+ $myVars->set($key, $var);
+ }
+ }
+ }
+
+ if ($object->supportsGroups()) {
+ if (! empty($groups)) {
+ $this->set('groups', $groups);
+ }
+ }
+
+ if ($object->supportsImports()) {
+ if (! empty($imports)) {
+ $this->set('imports', $imports);
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * @param bool $resolved
+ * @param bool $skipDefaults
+ * @param array|null $chosenProperties
+ * @param bool $resolveIds
+ * @param bool $keepId
+ * @return object
+ * @throws NotFoundError
+ */
+ public function toPlainObject(
+ $resolved = false,
+ $skipDefaults = false,
+ array $chosenProperties = null,
+ $resolveIds = true,
+ $keepId = false
+ ) {
+ $props = [];
+
+ if ($resolved) {
+ $p = $this->getInheritedProperties();
+ foreach ($this->properties as $k => $v) {
+ if ($v === null && property_exists($p, $k)) {
+ continue;
+ }
+ $p->$k = $v;
+ }
+ } else {
+ $p = $this->properties;
+ }
+
+ foreach ($p as $k => $v) {
+ // Do not ship ids for IcingaObjects:
+ if ($k === $this->getUuidColumn()) {
+ continue;
+ }
+ if ($resolveIds) {
+ if ($k === 'id' && $keepId === false && $this->hasProperty('object_name')) {
+ continue;
+ }
+
+ if ('_id' === substr($k, -3)) {
+ $relKey = substr($k, 0, -3);
+
+ if ($this->hasRelation($relKey)) {
+ if ($this->hasUnresolvedRelatedProperty($k)) {
+ $v = $this->$relKey;
+ } elseif ($v !== null) {
+ $v = $this->getRelatedObjectName($relKey, $v);
+ }
+
+ $k = $relKey;
+ } else {
+ throw new LogicException(sprintf(
+ 'No such relation: %s',
+ $relKey
+ ));
+ }
+ }
+ }
+
+ // TODO: Do not ship null properties based on flag?
+ if (!$skipDefaults || $this->differsFromDefaultValue($k, $v)) {
+ if ($k === 'disabled' || $this->propertyIsBoolean($k)) {
+ $props[$k] = $this->booleanForDbValue($v);
+ } else {
+ $props[$k] = $v;
+ }
+ }
+ }
+
+ if ($this->supportsGroups()) {
+ // TODO: resolve
+ $groups = $this->groups()->listGroupNames();
+ if ($resolved && empty($groups)) {
+ $groups = $this->listInheritedGroupNames();
+ }
+
+ $props['groups'] = $groups;
+ }
+
+ foreach ($this->loadAllMultiRelations() as $key => $rel) {
+ if (count($rel) || !$skipDefaults) {
+ $props[$key] = $rel->listRelatedNames();
+ }
+ }
+
+ if ($this instanceof ObjectWithArguments) {
+ $props['arguments'] = $this->arguments()->toPlainObject(
+ false,
+ $skipDefaults
+ );
+ }
+
+ if ($this->supportsCustomVars()) {
+ if ($resolved) {
+ $props['vars'] = $this->getResolvedVars();
+ } else {
+ $props['vars'] = $this->getVars();
+ }
+ }
+
+ if ($this->supportsImports()) {
+ if ($resolved) {
+ $props['imports'] = [];
+ } else {
+ $props['imports'] = $this->listImportNames();
+ }
+ }
+
+ if ($this->supportsRanges()) {
+ // TODO: resolve
+ $props['ranges'] = $this->get('ranges');
+ }
+
+ if ($skipDefaults) {
+ foreach (['imports', 'ranges', 'arguments'] as $key) {
+ if (empty($props[$key])) {
+ unset($props[$key]);
+ }
+ }
+
+ if (array_key_exists('vars', $props)) {
+ if (count((array) $props['vars']) === 0) {
+ unset($props['vars']);
+ }
+ }
+ if (empty($props['groups'])) {
+ unset($props['groups']);
+ }
+ }
+
+ foreach ($this->relatedSets() as $property => $set) {
+ if ($resolved) {
+ if ($this->supportsImports()) {
+ $set = clone($set);
+ foreach ($this->imports()->getObjects() as $parent) {
+ $set->inheritFrom($parent->getRelatedSet($property));
+ }
+ }
+
+ $values = $set->getResolvedValues();
+ if (empty($values)) {
+ if (!$skipDefaults) {
+ $props[$property] = null;
+ }
+ } else {
+ $props[$property] = $values;
+ }
+ } else {
+ if ($set->isEmpty()) {
+ if (!$skipDefaults) {
+ $props[$property] = null;
+ }
+ } else {
+ $props[$property] = $set->toPlainObject();
+ }
+ }
+ }
+
+ if ($chosenProperties !== null) {
+ $chosen = [];
+ foreach ($chosenProperties as $k) {
+ if (array_key_exists($k, $props)) {
+ $chosen[$k] = $props[$k];
+ }
+ }
+
+ $props = $chosen;
+ }
+ ksort($props);
+
+ return (object) $props;
+ }
+
+ protected function booleanForDbValue($value)
+ {
+ if ($value === 'y') {
+ return true;
+ }
+ if ($value === 'n') {
+ return false;
+ }
+
+ return $value; // let this fail elsewhere, if not null
+ }
+
+ public function listImportNames()
+ {
+ if ($this->gotImports()) {
+ return $this->imports()->listImportNames();
+ }
+
+ return $this->templateTree()->listParentNamesFor($this);
+ }
+
+ public function listFlatResolvedImportNames()
+ {
+ return $this->templateTree()->getAncestorsFor($this);
+ }
+
+ public function listImportIds()
+ {
+ return $this->templateTree()->listParentIdsFor($this);
+ }
+
+ public function listAncestorIds()
+ {
+ return $this->templateTree()->listAncestorIdsFor($this);
+ }
+
+ protected function templateTree()
+ {
+ return $this->templates()->tree();
+ }
+
+ protected function templates()
+ {
+ return IcingaTemplateRepository::instanceByObject($this, $this->getConnection());
+ }
+
+ protected function differsFromDefaultValue($key, $value)
+ {
+ if (array_key_exists($key, $this->defaultProperties)) {
+ return $value !== $this->defaultProperties[$key];
+ }
+
+ return $value !== null;
+ }
+
+ protected function mapHostsToZones($names)
+ {
+ $map = [];
+
+ foreach ($names as $hostname) {
+ /** @var IcingaHost $host */
+ $host = IcingaHost::load($hostname, $this->connection);
+
+ $zone = $host->getRenderingZone();
+ if (! array_key_exists($zone, $map)) {
+ $map[$zone] = [];
+ }
+
+ $map[$zone][] = $hostname;
+ }
+
+ ksort($map);
+
+ return $map;
+ }
+
+ public function getUrlParams()
+ {
+ $params = [];
+ if ($column = $this->getUuidColumn()) {
+ return [$column => $this->getUniqueId()->toString()];
+ }
+
+ if ($this->isApplyRule() && ! $this instanceof IcingaScheduledDowntime) {
+ $params['id'] = $this->get('id');
+ } else {
+ $params = ['name' => $this->getObjectName()];
+
+ if ($this->hasProperty('host_id') && $this->get('host_id')) {
+ $params['host'] = $this->get('host');
+ }
+
+ if ($this->hasProperty('service_id') && $this->get('service_id')) {
+ $params['service'] = $this->get('service');
+ }
+
+ if ($this->hasProperty('service_set_id') && $this->get('service_set_id')) {
+ $params['set'] = $this->get('service_set');
+ }
+ }
+
+ return $params;
+ }
+
+ public function getOnDeleteUrl()
+ {
+ $plural= preg_replace('/cys$/', 'cies', strtolower($this->getShortTableName()) . 's');
+ return 'director/' . $plural;
+ }
+
+ /**
+ * @param bool $resolved
+ * @param bool $skipDefaults
+ * @param array|null $chosenProperties
+ * @return string
+ * @throws NotFoundError
+ */
+ public function toJson(
+ $resolved = false,
+ $skipDefaults = false,
+ array $chosenProperties = null
+ ) {
+
+ return json_encode($this->toPlainObject($resolved, $skipDefaults, $chosenProperties));
+ }
+
+ public function getPlainUnmodifiedObject()
+ {
+ $props = [];
+
+ foreach ($this->getOriginalProperties() as $k => $v) {
+ // Do not ship ids for IcingaObjects:
+ if ($k === 'id' && $this->hasProperty('object_name')) {
+ continue;
+ }
+ if ($k === $this->getUuidColumn()) {
+ continue;
+ }
+ if ($k === 'disabled' && $v === null) {
+ continue;
+ }
+
+ if ('_id' === substr($k, -3)) {
+ $relKey = substr($k, 0, -3);
+
+ if ($this->hasRelation($relKey)) {
+ if ($v !== null) {
+ $v = $this->getRelatedObjectName($relKey, $v);
+ }
+
+ $k = $relKey;
+ }
+ }
+
+ if ($this->differsFromDefaultValue($k, $v)) {
+ if ($k === 'disabled' || $this->propertyIsBoolean($k)) {
+ $props[$k] = $this->booleanForDbValue($v);
+ } else {
+ $props[$k] = $v;
+ }
+ }
+ }
+
+ if ($this->supportsCustomVars()) {
+ $originalVars = $this->vars()->getOriginalVars();
+ if (! empty($originalVars)) {
+ $props['vars'] = (object) [];
+ foreach ($originalVars as $name => $var) {
+ $props['vars']->$name = $var->getValue();
+ }
+ }
+ }
+ if ($this->supportsGroups()) {
+ $groups = $this->groups()->listOriginalGroupNames();
+ if (! empty($groups)) {
+ $props['groups'] = $groups;
+ }
+ }
+ if ($this->supportsImports()) {
+ $imports = $this->imports()->listOriginalImportNames();
+ if (! empty($imports)) {
+ $props['imports'] = $imports;
+ }
+ }
+
+ if ($this instanceof ObjectWithArguments) {
+ $args = $this->arguments()->toUnmodifiedPlainObject();
+ if (! empty($args)) {
+ $props['arguments'] = $args;
+ }
+ }
+
+ if ($this->supportsRanges()) {
+ $ranges = $this->ranges()->getOriginalValues();
+ if (!empty($ranges)) {
+ $props['ranges'] = $ranges;
+ }
+ }
+
+ foreach ($this->relatedSets() as $property => $set) {
+ if ($set->isEmpty()) {
+ continue;
+ }
+
+ $props[$property] = $set->getPlainUnmodifiedObject();
+ }
+
+ foreach ($this->loadAllMultiRelations() as $key => $rel) {
+ $old = $rel->listOriginalNames();
+ if (! empty($old)) {
+ $props[$key] = $old;
+ }
+ }
+
+ return (object) $props;
+ }
+
+ public function __toString()
+ {
+ try {
+ return $this->toConfigString();
+ } catch (Exception $e) {
+ trigger_error($e);
+ $previousHandler = set_exception_handler(
+ function () {
+ }
+ );
+ restore_error_handler();
+ if ($previousHandler !== null) {
+ call_user_func($previousHandler, $e);
+ die();
+ }
+
+ die($e->getMessage());
+ }
+ }
+
+ public function __destruct()
+ {
+ unset($this->resolveCache);
+ unset($this->vars);
+ unset($this->groups);
+ unset($this->imports);
+ unset($this->ranges);
+ if ($this instanceof ObjectWithArguments) {
+ $this->unsetArguments();
+ }
+
+ parent::__destruct();
+ }
+}
diff --git a/library/Director/Objects/IcingaObjectField.php b/library/Director/Objects/IcingaObjectField.php
new file mode 100644
index 0000000..e18965b
--- /dev/null
+++ b/library/Director/Objects/IcingaObjectField.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Icinga\Data\Filter\Filter;
+use Icinga\Module\Director\Data\Db\DbObject;
+
+abstract class IcingaObjectField extends DbObject
+{
+ /**
+ *
+ * @param Filter|string $filter
+ *
+ * @return $this
+ * @codingStandardsIgnoreStart
+ */
+ protected function setVar_filter($value)
+ {
+ // @codingStandardsIgnoreEnd
+ if ($value instanceof Filter) {
+ $value = $value->toQueryString();
+ }
+
+ return $this->reallySet('var_filter', $value);
+ }
+}
diff --git a/library/Director/Objects/IcingaObjectGroup.php b/library/Director/Objects/IcingaObjectGroup.php
new file mode 100644
index 0000000..c0bec54
--- /dev/null
+++ b/library/Director/Objects/IcingaObjectGroup.php
@@ -0,0 +1,76 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\DirectorObject\Automation\ExportInterface;
+use Icinga\Module\Director\Exception\DuplicateKeyException;
+
+abstract class IcingaObjectGroup extends IcingaObject implements ExportInterface
+{
+ protected $supportsImports = true;
+
+ protected $supportedInLegacy = true;
+
+ protected $uuidColumn = 'uuid';
+
+ protected $defaultProperties = [
+ 'id' => null,
+ 'uuid' => null,
+ 'object_name' => null,
+ 'object_type' => null,
+ 'disabled' => 'n',
+ 'display_name' => null,
+ 'assign_filter' => null,
+ ];
+
+ public function getUniqueIdentifier()
+ {
+ return $this->getObjectName();
+ }
+
+ /**
+ * @return object
+ * @deprecated please use \Icinga\Module\Director\Data\Exporter
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function export()
+ {
+ return $this->toPlainObject();
+ }
+
+ /**
+ * @param $plain
+ * @param Db $db
+ * @param bool $replace
+ * @return IcingaObjectGroup
+ * @throws DuplicateKeyException
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public static function import($plain, Db $db, $replace = false)
+ {
+ $properties = (array) $plain;
+ $name = $properties['object_name'];
+ $key = $name;
+
+ if ($replace && static::exists($key, $db)) {
+ $object = static::load($key, $db);
+ } elseif (static::exists($key, $db)) {
+ throw new DuplicateKeyException(
+ 'Group "%s" already exists',
+ $name
+ );
+ } else {
+ $object = static::create([], $db);
+ }
+
+ $object->setProperties($properties);
+
+ return $object;
+ }
+
+ protected function prefersGlobalZone()
+ {
+ return true;
+ }
+}
diff --git a/library/Director/Objects/IcingaObjectGroups.php b/library/Director/Objects/IcingaObjectGroups.php
new file mode 100644
index 0000000..8bef1b1
--- /dev/null
+++ b/library/Director/Objects/IcingaObjectGroups.php
@@ -0,0 +1,408 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Countable;
+use Exception;
+use Icinga\Exception\NotFoundError;
+use Icinga\Module\Director\Db\Cache\PrefetchCache;
+use Icinga\Module\Director\IcingaConfig\IcingaConfigRenderer;
+use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c;
+use Icinga\Module\Director\IcingaConfig\IcingaLegacyConfigHelper as c1;
+use Iterator;
+use RuntimeException;
+
+class IcingaObjectGroups implements Iterator, Countable, IcingaConfigRenderer
+{
+ protected $storedGroups = array();
+
+ protected $groups = array();
+
+ protected $modified = false;
+
+ protected $object;
+
+ private $position = 0;
+
+ protected $idx = array();
+
+ public function __construct(IcingaObject $object)
+ {
+ $this->object = $object;
+
+ if (! $object->hasBeenLoadedFromDb() && PrefetchCache::shouldBeUsed()) {
+ /** @var IcingaObjectGroup $class */
+ $class = $this->getGroupClass();
+ $class::prefetchAll($this->object->getConnection());
+ }
+ }
+
+ #[\ReturnTypeWillChange]
+ public function count()
+ {
+ return count($this->groups);
+ }
+
+ #[\ReturnTypeWillChange]
+ public function rewind()
+ {
+ $this->position = 0;
+ }
+
+ public function hasBeenModified()
+ {
+ return $this->modified;
+ }
+
+ #[\ReturnTypeWillChange]
+ public function current()
+ {
+ if (! $this->valid()) {
+ return null;
+ }
+
+ return $this->groups[$this->idx[$this->position]];
+ }
+
+ #[\ReturnTypeWillChange]
+ public function key()
+ {
+ return $this->idx[$this->position];
+ }
+
+ #[\ReturnTypeWillChange]
+ public function next()
+ {
+ ++$this->position;
+ }
+
+ #[\ReturnTypeWillChange]
+ public function valid()
+ {
+ return array_key_exists($this->position, $this->idx);
+ }
+
+ public function get($key)
+ {
+ if (array_key_exists($key, $this->groups)) {
+ return $this->groups[$key];
+ }
+
+ return null;
+ }
+
+ /**
+ * @param $group
+ * @return $this
+ * @throws NotFoundError
+ */
+ public function set($group)
+ {
+ if (! is_array($group)) {
+ $group = array($group);
+ }
+
+ $existing = array_keys($this->groups);
+ $new = array();
+ $class = $this->getGroupClass();
+ $unset = array();
+
+ foreach ($group as $k => $g) {
+ if ($g instanceof $class) {
+ $new[] = $g->object_name;
+ } else {
+ if (empty($g)) {
+ $unset[] = $k;
+ continue;
+ }
+
+ $new[] = $g;
+ }
+ }
+
+ foreach ($unset as $k) {
+ unset($group[$k]);
+ }
+
+ sort($existing);
+ sort($new);
+ if ($existing === $new) {
+ return $this;
+ }
+
+ $this->groups = array();
+ if (empty($group)) {
+ $this->modified = true;
+ $this->refreshIndex();
+ return $this;
+ }
+
+ return $this->add($group);
+ }
+
+ /**
+ * Magic isset check
+ *
+ * @return boolean
+ */
+ public function __isset($group)
+ {
+ return array_key_exists($group, $this->groups);
+ }
+
+ public function remove($group)
+ {
+ if (array_key_exists($group, $this->groups)) {
+ unset($this->groups[$group]);
+ }
+
+ $this->modified = true;
+ $this->refreshIndex();
+ }
+
+ protected function refreshIndex()
+ {
+ ksort($this->groups);
+ $this->idx = array_keys($this->groups);
+ }
+
+ /**
+ * @param $group
+ * @param string $onError
+ * @return $this
+ * @throws NotFoundError
+ * @throws \Icinga\Module\Director\Exception\DuplicateKeyException
+ */
+ public function add($group, $onError = 'fail')
+ {
+ // TODO: only one query when adding array
+ if (is_array($group)) {
+ foreach ($group as $g) {
+ $this->add($g, $onError);
+ }
+ return $this;
+ }
+
+ /** @var IcingaObjectGroup $class */
+ $class = $this->getGroupClass();
+
+ if ($group instanceof $class) {
+ if (array_key_exists($group->getObjectName(), $this->groups)) {
+ return $this;
+ }
+
+ $this->groups[$group->object_name] = $group;
+ } elseif (is_string($group)) {
+ if (array_key_exists($group, $this->groups)) {
+ return $this;
+ }
+
+ $connection = $this->object->getConnection();
+
+ try {
+ $this->groups[$group] = $class::load($group, $connection);
+ } catch (NotFoundError $e) {
+ switch ($onError) {
+ case 'autocreate':
+ $newGroup = $class::create(array(
+ 'object_type' => 'object',
+ 'object_name' => $group
+ ));
+ $newGroup->store($connection);
+ $this->groups[$group] = $newGroup;
+ break;
+ case 'fail':
+ throw new NotFoundError(
+ 'The group "%s" doesn\'t exist.',
+ $group
+ );
+ break;
+ case 'ignore':
+ return $this;
+ }
+ }
+ } else {
+ throw new RuntimeException(
+ 'Invalid group object: %s',
+ var_export($group, 1)
+ );
+ }
+
+ $this->modified = true;
+ $this->refreshIndex();
+
+ return $this;
+ }
+
+ protected function getGroupTableName()
+ {
+ return $this->object->getTableName() . 'group';
+ }
+
+
+ protected function getGroupMemberTableName()
+ {
+ return $this->object->getTableName() . 'group_' . $this->getType();
+ }
+
+ public function listGroupNames()
+ {
+ return array_keys($this->groups);
+ }
+
+ public function listOriginalGroupNames()
+ {
+ return array_keys($this->storedGroups);
+ }
+
+ public function getType()
+ {
+ return $this->object->getShortTableName();
+ }
+
+ protected function loadFromDb()
+ {
+ $db = $this->object->getDb();
+ $connection = $this->object->getConnection();
+
+ $type = $this->getType();
+
+ $table = $this->object->getTableName();
+ $query = $db->select()->from(
+ array('go' => $table . 'group_' . $type),
+ array()
+ )->join(
+ array('g' => $table . 'group'),
+ 'go.' . $type . 'group_id = g.id',
+ '*'
+ )->where('go.' . $type . '_id = ?', $this->object->id)
+ ->order('g.object_name');
+
+ $class = $this->getGroupClass();
+ $this->groups = $class::loadAll($connection, $query, 'object_name');
+ $this->setBeingLoadedFromDb();
+
+ return $this;
+ }
+
+ public function store()
+ {
+ $storedGroups = array_keys($this->storedGroups);
+ $groups = array_keys($this->groups);
+
+ $objectId = $this->object->id;
+ $type = $this->getType();
+
+ $objectCol = $type . '_id';
+ $groupCol = $type . 'group_id';
+
+ $toDelete = array_diff($storedGroups, $groups);
+ foreach ($toDelete as $group) {
+ $where = sprintf(
+ $objectCol . ' = %d AND ' . $groupCol . ' = %d',
+ $objectId,
+ $this->storedGroups[$group]->id
+ );
+
+ $this->object->db->delete(
+ $this->getGroupMemberTableName(),
+ $where
+ );
+ }
+
+ $toAdd = array_diff($groups, $storedGroups);
+ foreach ($toAdd as $group) {
+ $this->object->db->insert(
+ $this->getGroupMemberTableName(),
+ array(
+ $objectCol => $objectId,
+ $groupCol => $this->groups[$group]->id
+ )
+ );
+ }
+ $this->setBeingLoadedFromDb();
+
+ return true;
+ }
+
+ public function setBeingLoadedFromDb()
+ {
+ $this->storedGroups = array();
+ foreach ($this->groups as $k => $v) {
+ $this->storedGroups[$k] = clone($v);
+ $this->storedGroups[$k]->id = $v->id;
+ }
+
+ $this->modified = false;
+ }
+
+ protected function getGroupClass()
+ {
+ return __NAMESPACE__ . '\\Icinga' .ucfirst($this->object->getShortTableName()) . 'Group';
+ }
+
+ public static function loadForStoredObject(IcingaObject $object)
+ {
+ $groups = new static($object);
+
+ if (PrefetchCache::shouldBeUsed()) {
+ $groups->groups = PrefetchCache::instance()->groups($object);
+ $groups->setBeingLoadedFromDb();
+ } else {
+ $groups->loadFromDb();
+ }
+
+ return $groups;
+ }
+
+ public function toConfigString()
+ {
+ $groups = array_keys($this->groups);
+
+ if (empty($groups)) {
+ return '';
+ }
+
+ return c::renderKeyValue('groups', c::renderArray($groups));
+ }
+
+ public function toLegacyConfigString($additionalGroups = array())
+ {
+ $groups = array_merge(array_keys($this->groups), $additionalGroups);
+ $groups = array_unique($groups);
+
+ if (empty($groups)) {
+ return '';
+ }
+
+ $type = $this->object->getLegacyObjectType();
+ return c1::renderKeyValue($type.'groups', c1::renderArray($groups));
+ }
+
+ public function __toString()
+ {
+ try {
+ return $this->toConfigString();
+ } catch (Exception $e) {
+ trigger_error($e);
+ $previousHandler = set_exception_handler(
+ function () {
+ }
+ );
+ restore_error_handler();
+ if ($previousHandler !== null) {
+ call_user_func($previousHandler, $e);
+ die();
+ } else {
+ die($e->getMessage());
+ }
+ }
+ }
+
+ public function __destruct()
+ {
+ unset($this->storedGroups);
+ unset($this->groups);
+ unset($this->object);
+ }
+}
diff --git a/library/Director/Objects/IcingaObjectImports.php b/library/Director/Objects/IcingaObjectImports.php
new file mode 100644
index 0000000..384fa1c
--- /dev/null
+++ b/library/Director/Objects/IcingaObjectImports.php
@@ -0,0 +1,439 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Countable;
+use Exception;
+use Icinga\Exception\NotFoundError;
+use Iterator;
+use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c;
+use Icinga\Module\Director\IcingaConfig\IcingaConfigRenderer;
+use Icinga\Module\Director\IcingaConfig\IcingaLegacyConfigHelper as c1;
+use Icinga\Module\Director\Repository\IcingaTemplateRepository;
+use RuntimeException;
+
+class IcingaObjectImports implements Iterator, Countable, IcingaConfigRenderer
+{
+ protected $storedNames = [];
+
+ /** @var array A list of our imports, key and value are the import name */
+ protected $imports = [];
+
+ /** @var IcingaObject[] A list of all objects we have seen, referred by name */
+ protected $objects = [];
+
+ protected $modified = false;
+
+ /** @var IcingaObject The parent object */
+ protected $object;
+
+ private $position = 0;
+
+ protected $idx = [];
+
+ public function __construct(IcingaObject $object)
+ {
+ $this->object = $object;
+ }
+
+ #[\ReturnTypeWillChange]
+ public function count()
+ {
+ return count($this->imports);
+ }
+
+ #[\ReturnTypeWillChange]
+ public function rewind()
+ {
+ $this->position = 0;
+ }
+
+ public function setModified()
+ {
+ $this->modified = true;
+ return $this;
+ }
+
+ public function hasBeenModified()
+ {
+ return $this->modified;
+ }
+
+ /**
+ * @return IcingaObject|null
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ #[\ReturnTypeWillChange]
+ public function current()
+ {
+ if (! $this->valid()) {
+ return null;
+ }
+
+ return $this->getObject(
+ $this->imports[$this->idx[$this->position]]
+ );
+ }
+
+ #[\ReturnTypeWillChange]
+ public function key()
+ {
+ return $this->idx[$this->position];
+ }
+
+ #[\ReturnTypeWillChange]
+ public function next()
+ {
+ ++$this->position;
+ }
+
+ #[\ReturnTypeWillChange]
+ public function valid()
+ {
+ return array_key_exists($this->position, $this->idx);
+ }
+
+ /**
+ * @param $key
+ * @return IcingaObject|null
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function get($key)
+ {
+ if (array_key_exists($key, $this->imports)) {
+ return $this->getObject($this->imports[$key]);
+ }
+
+ return null;
+ }
+
+ public function set($import)
+ {
+ if (empty($import)) {
+ if (empty($this->imports)) {
+ return $this;
+ } else {
+ return $this->clear();
+ }
+ }
+
+ if (! is_array($import)) {
+ $import = [$import];
+ }
+
+ $existing = $this->listImportNames();
+ $new = $this->listNamesForGivenImports($import);
+
+ if ($existing === $new) {
+ return $this;
+ }
+
+ $this->imports = [];
+ return $this->add($import);
+ }
+
+ protected function listNamesForGivenImports($imports)
+ {
+ $list = [];
+ $class = $this->getImportClass();
+
+ foreach ($imports as $i) {
+ if ($i instanceof $class) {
+ $list[] = $i->object_name;
+ } else {
+ $list[] = $i;
+ }
+ }
+
+ return $list;
+ }
+
+ /**
+ * Magic isset check
+ *
+ * @param string $import
+ *
+ * @return boolean
+ */
+ public function __isset($import)
+ {
+ return array_key_exists($import, $this->imports);
+ }
+
+ public function clear()
+ {
+ if ($this->imports === []) {
+ return $this;
+ }
+
+ $this->imports = [];
+ $this->modified = true;
+
+ return $this->refreshIndex();
+ }
+
+ public function remove($import)
+ {
+ if (array_key_exists($import, $this->imports)) {
+ unset($this->imports[$import]);
+ }
+
+ $this->modified = true;
+
+ return $this->refreshIndex();
+ }
+
+ protected function refreshIndex()
+ {
+ $this->idx = array_keys($this->imports);
+ // $this->object->templateResolver()->refreshObject($this->object);
+
+ return $this;
+ }
+
+ public function add($import)
+ {
+ $class = $this->getImportClass();
+
+ if (is_array($import)) {
+ foreach ($import as $i) {
+ // Gracefully ignore null members or empty strings
+ if (! $i instanceof $class && ($i === null || strlen($i) === 0)) {
+ continue;
+ }
+
+ $this->add($i);
+ }
+
+ return $this;
+ }
+
+ if ($import instanceof $class) {
+ $name = $import->object_name;
+ if (array_key_exists($name, $this->imports)) {
+ return $this;
+ }
+
+ $this->imports[$name] = $name;
+ $this->objects[$name] = $import;
+ } elseif (is_string($import)) {
+ if (array_key_exists($import, $this->imports)) {
+ return $this;
+ }
+
+ $this->imports[$import] = $import;
+ }
+
+ $this->modified = true;
+ $this->refreshIndex();
+
+ return $this;
+ }
+
+ /**
+ * @return IcingaObject[]
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function getObjects()
+ {
+ $list = [];
+ foreach ($this->listImportNames() as $name) {
+ $name = (string) $name;
+ $list[$name] = $this->getObject($name);
+ }
+
+ return $list;
+ }
+
+ /**
+ * @param $name
+ * @return IcingaObject
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ protected function getObject($name)
+ {
+ if (array_key_exists($name, $this->objects)) {
+ return $this->objects[$name];
+ }
+
+ $connection = $this->object->getConnection();
+ /** @var IcingaObject $class */
+ $class = $this->getImportClass();
+ try {
+ if (is_array($this->object->getKeyName())) {
+ // Services only
+ $import = $class::load([
+ 'object_name' => $name,
+ 'object_type' => 'template'
+ ], $connection);
+ } else {
+ $import = $class::load($name, $connection);
+ }
+ } catch (NotFoundError $e) {
+ throw new NotFoundError(sprintf(
+ 'Unable to load parent referenced from %s "%s", %s',
+ $this->object->getShortTableName(),
+ $this->object->getObjectName(),
+ lcfirst($e->getMessage())
+ ), $e->getCode(), $e);
+ }
+
+ return $this->objects[$import->getObjectName()] = $import;
+ }
+
+ protected function getImportTableName()
+ {
+ return $this->object->getTableName() . '_inheritance';
+ }
+
+ public function listImportNames()
+ {
+ return array_keys($this->imports);
+ }
+
+ public function listOriginalImportNames()
+ {
+ return $this->storedNames;
+ }
+
+ public function getType()
+ {
+ return $this->object->getShortTableName();
+ }
+
+ protected function loadFromDb()
+ {
+ // $resolver = $this->object->templateResolver();
+ // $this->objects = $resolver->fetchParents();
+ $this->objects = IcingaTemplateRepository::instanceByObject($this->object)
+ ->getTemplatesIndexedByNameFor($this->object);
+ if (empty($this->objects)) {
+ $this->imports = [];
+ } else {
+ $keys = array_keys($this->objects);
+ $this->imports = array_combine($keys, $keys);
+ }
+
+ $this->setBeingLoadedFromDb();
+ return $this;
+ }
+
+ /**
+ * @return bool
+ * @throws \Zend_Db_Adapter_Exception
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function store()
+ {
+ if (! $this->hasBeenModified()) {
+ return true;
+ }
+
+ $objectId = $this->object->get('id');
+ if ($objectId === null) {
+ throw new RuntimeException(
+ 'Cannot store imports for unstored object with no ID'
+ );
+ } else {
+ $objectId = (int) $objectId;
+ }
+
+ $type = $this->getType();
+
+ $objectCol = $type . '_id';
+ $importCol = 'parent_' . $type . '_id';
+ $table = $this->getImportTableName();
+ $db = $this->object->getDb();
+
+ if ($this->object->hasBeenLoadedFromDb()) {
+ $db->delete(
+ $table,
+ $objectCol . ' = ' . $objectId
+ );
+ }
+
+ $weight = 1;
+ foreach ($this->getObjects() as $import) {
+ $db->insert($table, [
+ $objectCol => $objectId,
+ $importCol => $import->get('id'),
+ 'weight' => $weight++
+ ]);
+ }
+
+ $this->setBeingLoadedFromDb();
+
+ return true;
+ }
+
+ public function setBeingLoadedFromDb()
+ {
+ $this->storedNames = $this->listImportNames();
+ $this->modified = false;
+ }
+
+ protected function getImportClass()
+ {
+ return get_class($this->object);
+ }
+
+ public static function loadForStoredObject(IcingaObject $object)
+ {
+ $obj = new static($object);
+ return $obj->loadFromDb();
+ }
+
+ public function toConfigString()
+ {
+ $ret = '';
+
+ foreach ($this->listImportNames() as $name) {
+ $ret .= ' import ' . c::renderString($name) . "\n";
+ }
+
+ if ($ret !== '') {
+ $ret .= "\n";
+ }
+ return $ret;
+ }
+
+ public function toLegacyConfigString()
+ {
+ $ret = '';
+
+ foreach ($this->listImportNames() as $name) {
+ $ret .= c1::renderKeyValue('use', c1::renderString($name)) . "\n";
+ }
+
+ if ($ret !== '') {
+ $ret .= "\n";
+ }
+ return $ret;
+ }
+
+ public function __toString()
+ {
+ try {
+ return $this->toConfigString();
+ } catch (Exception $e) {
+ trigger_error($e);
+ $previousHandler = set_exception_handler(
+ function () {
+ }
+ );
+ restore_error_handler();
+ if ($previousHandler !== null) {
+ call_user_func($previousHandler, $e);
+ die();
+ } else {
+ die($e->getMessage());
+ }
+ }
+ }
+
+ public function __destruct()
+ {
+ unset($this->object);
+ unset($this->objects);
+ }
+}
diff --git a/library/Director/Objects/IcingaObjectLegacyAssignments.php b/library/Director/Objects/IcingaObjectLegacyAssignments.php
new file mode 100644
index 0000000..6ab75c8
--- /dev/null
+++ b/library/Director/Objects/IcingaObjectLegacyAssignments.php
@@ -0,0 +1,79 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use LogicException;
+
+/**
+ * This class is required for historical reasons
+ *
+ * Objects with assignments in your activity log would otherwise not be able
+ * to render themselves
+ */
+class IcingaObjectLegacyAssignments
+{
+ public static function applyToObject(IcingaObject $object, $values)
+ {
+ if (! $object->supportsAssignments()) {
+ throw new LogicException(sprintf(
+ 'I can only assign for applied objects, got %s',
+ $object->object_type
+ ));
+ }
+
+ if ($values === null) {
+ return $object;
+ }
+
+ if (! is_array($values)) {
+ static::throwCompatError();
+ }
+
+ if (empty($values)) {
+ return $object;
+ }
+
+ $assigns = array();
+ $ignores = array();
+ foreach ($values as $type => $value) {
+ if (strpos($value, '|') !== false || strpos($value, '&' !== false)) {
+ $value = '(' . $value . ')';
+ }
+
+ if ($type === 'assign') {
+ $assigns[] = $value;
+ } elseif ($type === 'ignore') {
+ $ignores[] = $value;
+ } else {
+ static::throwCompatError();
+ }
+ }
+
+ $assign = implode('|', $assigns);
+ $ignore = implode('&', $ignores);
+ if (empty($assign)) {
+ $filter = $ignore;
+ } elseif (empty($ignore)) {
+ $filter = $assign;
+ } else {
+ if (count($assigns) === 1) {
+ $filter = $assign . '&' . $ignore;
+ } else {
+ $filter = '(' . $assign . ')&(' . $ignore . ')';
+ }
+ }
+
+ $object->assign_filter = $filter;
+
+ return $object;
+ }
+
+ protected static function throwCompatError()
+ {
+ throw new LogicException(
+ 'You ran into an unexpected compatibility issue. Please report'
+ . ' this with details helping us to reproduce this to the'
+ . ' Icinga project'
+ );
+ }
+}
diff --git a/library/Director/Objects/IcingaObjectMultiRelations.php b/library/Director/Objects/IcingaObjectMultiRelations.php
new file mode 100644
index 0000000..a1ec9a2
--- /dev/null
+++ b/library/Director/Objects/IcingaObjectMultiRelations.php
@@ -0,0 +1,454 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Exception;
+use Icinga\Exception\ProgrammingError;
+use Iterator;
+use Countable;
+use Icinga\Module\Director\IcingaConfig\IcingaConfigRenderer;
+use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c;
+use Icinga\Module\Director\IcingaConfig\IcingaLegacyConfigHelper as c1;
+
+class IcingaObjectMultiRelations implements Iterator, Countable, IcingaConfigRenderer
+{
+ protected $stored = array();
+
+ protected $relations = array();
+
+ protected $modified = false;
+
+ protected $object;
+
+ protected $propertyName;
+
+ protected $relatedObjectClass;
+
+ protected $relatedTableName;
+
+ protected $relationIdColumn;
+
+ protected $relatedShortName;
+
+ protected $legacyPropertyName;
+
+ private $position = 0;
+
+ private $db;
+
+ protected $idx = array();
+
+ public function __construct(IcingaObject $object, $propertyName, $config)
+ {
+ $this->object = $object;
+ $this->propertyName = $propertyName;
+
+ if (is_object($config) || is_array($config)) {
+ foreach ($config as $k => $v) {
+ $this->$k = $v;
+ }
+ } else {
+ $this->relatedObjectClass = $config;
+ }
+ }
+
+ public function getObjects()
+ {
+ return $this->relations;
+ }
+
+ #[\ReturnTypeWillChange]
+ public function count()
+ {
+ return count($this->relations);
+ }
+
+ #[\ReturnTypeWillChange]
+ public function rewind()
+ {
+ $this->position = 0;
+ }
+
+ public function hasBeenModified()
+ {
+ return $this->modified;
+ }
+
+ #[\ReturnTypeWillChange]
+ public function current()
+ {
+ if (! $this->valid()) {
+ return null;
+ }
+
+ return $this->relations[$this->idx[$this->position]];
+ }
+
+ #[\ReturnTypeWillChange]
+ public function key()
+ {
+ return $this->idx[$this->position];
+ }
+
+ #[\ReturnTypeWillChange]
+ public function next()
+ {
+ ++$this->position;
+ }
+
+ #[\ReturnTypeWillChange]
+ public function valid()
+ {
+ return array_key_exists($this->position, $this->idx);
+ }
+
+ public function get($key)
+ {
+ if (array_key_exists($key, $this->relations)) {
+ return $this->relations[$key];
+ }
+
+ return null;
+ }
+
+ public function set($relation)
+ {
+ if (! is_array($relation)) {
+ if ($relation === null) {
+ $relation = array();
+ } else {
+ $relation = array($relation);
+ }
+ }
+
+ $existing = array_keys($this->relations);
+ $new = array();
+ $class = $this->getRelatedClassName();
+ $unset = array();
+
+ foreach ($relation as $k => $ro) {
+ if ($ro instanceof $class) {
+ $new[] = $ro->object_name;
+ } else {
+ if (empty($ro)) {
+ $unset[] = $k;
+ continue;
+ }
+
+ $new[] = $ro;
+ }
+ }
+
+ foreach ($unset as $k) {
+ unset($relation[$k]);
+ }
+
+ sort($existing);
+ sort($new);
+ if ($existing === $new) {
+ return $this;
+ }
+
+ $this->relations = array();
+ if (empty($relation)) {
+ $this->modified = true;
+ $this->refreshIndex();
+ return $this;
+ }
+
+ return $this->add($relation);
+ }
+
+ /**
+ * Magic isset check
+ *
+ * @return boolean
+ */
+ public function __isset($relation)
+ {
+ return array_key_exists($relation, $this->relations);
+ }
+
+ public function remove($relation)
+ {
+ if (array_key_exists($relation, $this->relations)) {
+ unset($this->relations[$relation]);
+ }
+
+ $this->modified = true;
+ $this->refreshIndex();
+ }
+
+ protected function refreshIndex()
+ {
+ ksort($this->relations);
+ $this->idx = array_keys($this->relations);
+ }
+
+ public function add($relation, $onError = 'fail')
+ {
+ // TODO: only one query when adding array
+ if (is_array($relation)) {
+ foreach ($relation as $r) {
+ $this->add($r, $onError);
+ }
+ return $this;
+ }
+
+ if (array_key_exists($relation, $this->relations)) {
+ return $this;
+ }
+
+ $class = $this->getRelatedClassName();
+
+ if ($relation instanceof $class) {
+ $this->relations[$relation->object_name] = $relation;
+ } elseif (is_string($relation)) {
+ $connection = $this->object->getConnection();
+ try {
+ // Related services can only be objects, used by ServiceSets
+ if ($class === 'Icinga\\Module\\Director\\Objects\\IcingaService') {
+ $relation = $class::load(array(
+ 'object_name' => $relation,
+ 'object_type' => 'template'
+ ), $connection);
+ } else {
+ $relation = $class::load($relation, $connection);
+ }
+ } catch (Exception $e) {
+ switch ($onError) {
+ case 'autocreate':
+ $relation = $class::create(array(
+ 'object_type' => 'object',
+ 'object_name' => $relation
+ ));
+ $relation->store($connection);
+ // TODO
+ case 'fail':
+ throw new ProgrammingError(
+ 'The related %s "%s" doesn\'t exists: %s',
+ $this->getRelatedTableName(),
+ $relation,
+ $e->getMessage()
+ );
+ break;
+ case 'ignore':
+ return $this;
+ }
+ }
+ } else {
+ throw new ProgrammingError(
+ 'Invalid related object: %s',
+ var_export($relation, 1)
+ );
+ }
+
+ $this->relations[$relation->object_name] = $relation;
+ $this->modified = true;
+ $this->refreshIndex();
+
+ return $this;
+ }
+
+ protected function getPropertyName()
+ {
+ return $this->propertyName;
+ }
+
+ protected function getRelatedShortName()
+ {
+ if ($this->relatedShortName === null) {
+ /** @var IcingaObject $class */
+ $class = $this->getRelatedClassName();
+ $this->relatedShortName = $class::create()->getShortTableName();
+ }
+
+ return $this->relatedShortName;
+ }
+
+ protected function getTableName()
+ {
+ return $this->object->getTableName() . '_' . $this->getRelatedShortName();
+ }
+
+ protected function getRelatedTableName()
+ {
+ if ($this->relatedTableName === null) {
+ /** @var IcingaObject $class */
+ $class = $this->getRelatedClassName();
+ $this->relatedTableName = $class::create()->getTableName();
+ }
+
+ return $this->relatedTableName;
+ }
+
+ protected function getRelationIdColumn()
+ {
+ if ($this->relationIdColumn === null) {
+ $this->relationIdColumn = $this->getRelatedShortName();
+ }
+
+ return $this->relationIdColumn;
+ }
+
+ public function listRelatedNames()
+ {
+ return array_keys($this->relations);
+ }
+
+ public function listOriginalNames()
+ {
+ return array_keys($this->stored);
+ }
+
+ public function getType()
+ {
+ return $this->object->getShortTableName();
+ }
+
+ protected function loadFromDb()
+ {
+ $db = $this->getDb();
+ $connection = $this->object->getConnection();
+
+ $type = $this->getType();
+ $objectIdCol = $type . '_id';
+ $relationIdCol = $this->getRelationIdColumn() . '_id';
+
+ $query = $db->select()->from(
+ array('r' => $this->getTableName()),
+ array()
+ )->join(
+ array('ro' => $this->getRelatedTableName()),
+ sprintf('r.%s = ro.id', $relationIdCol),
+ '*'
+ )->where(
+ sprintf('r.%s = ?', $objectIdCol),
+ (int) $this->object->id
+ )->order('ro.object_name');
+
+ $class = $this->getRelatedClassName();
+ $this->relations = $class::loadAll($connection, $query, 'object_name');
+ $this->setBeingLoadedFromDb();
+
+ return $this;
+ }
+
+ public function store()
+ {
+ $db = $this->getDb();
+ $stored = array_keys($this->stored);
+ $relations = array_keys($this->relations);
+
+ $objectId = $this->object->id;
+ $type = $this->getType();
+ $objectCol = $type . '_id';
+ $relationCol = $this->getRelationIdColumn() . '_id';
+
+ $toDelete = array_diff($stored, $relations);
+ foreach ($toDelete as $relation) {
+ // We work with cloned objects. (why?)
+ // As __clone drops the id, we need to access original properties
+ $orig = $this->stored[$relation]->getOriginalProperties();
+ $where = sprintf(
+ $objectCol . ' = %d AND ' . $relationCol . ' = %d',
+ $objectId,
+ $orig['id']
+ );
+
+ $db->delete(
+ $this->getTableName(),
+ $where
+ );
+ }
+
+ $toAdd = array_diff($relations, $stored);
+ foreach ($toAdd as $related) {
+ $db->insert(
+ $this->getTableName(),
+ array(
+ $objectCol => $objectId,
+ $relationCol => $this->relations[$related]->id
+ )
+ );
+ }
+ $this->setBeingLoadedFromDb();
+
+ return true;
+ }
+
+ public function setBeingLoadedFromDb()
+ {
+ $this->stored = array();
+ foreach ($this->relations as $k => $v) {
+ $this->stored[$k] = clone($v);
+ }
+ }
+
+ protected function getRelatedClassName()
+ {
+ return __NAMESPACE__ . '\\' . $this->relatedObjectClass;
+ }
+
+ protected function getDb()
+ {
+ if ($this->db === null) {
+ $this->db = $this->object->getDb();
+ }
+
+ return $this->db;
+ }
+
+ public static function loadForStoredObject(IcingaObject $object, $propertyName, $relatedObjectClass)
+ {
+ $relations = new static($object, $propertyName, $relatedObjectClass);
+ return $relations->loadFromDb();
+ }
+
+ public function toConfigString()
+ {
+ $relations = array_keys($this->relations);
+
+ if (empty($relations)) {
+ return '';
+ }
+
+ return c::renderKeyValue($this->propertyName, c::renderArray($relations));
+ }
+
+ public function __toString()
+ {
+ try {
+ return $this->toConfigString();
+ } catch (Exception $e) {
+ trigger_error($e);
+ $previousHandler = set_exception_handler(
+ function () {
+ }
+ );
+ restore_error_handler();
+ if ($previousHandler !== null) {
+ call_user_func($previousHandler, $e);
+ die();
+ } else {
+ die($e->getMessage());
+ }
+ }
+ }
+
+ public function toLegacyConfigString()
+ {
+ $relations = array_keys($this->relations);
+
+ if (empty($relations)) {
+ return '';
+ }
+
+ if ($this->legacyPropertyName === null) {
+ return ' # not supported in legacy: ' .
+ c1::renderKeyValue($this->propertyName, c1::renderArray($relations), '');
+ }
+
+ return c1::renderKeyValue($this->legacyPropertyName, c1::renderArray($relations));
+ }
+}
diff --git a/library/Director/Objects/IcingaRanges.php b/library/Director/Objects/IcingaRanges.php
new file mode 100644
index 0000000..c14c588
--- /dev/null
+++ b/library/Director/Objects/IcingaRanges.php
@@ -0,0 +1,321 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Exception;
+use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c;
+
+abstract class IcingaRanges
+{
+ /** @var IcingaTimePeriodRange[]|IcingaScheduledDowntimeRange[] */
+ protected $storedRanges = [];
+
+ /** @var IcingaTimePeriodRange[]|IcingaScheduledDowntimeRange[] */
+ protected $ranges = [];
+
+ protected $modified = false;
+
+ protected $object;
+
+ private $position = 0;
+
+ protected $idx = array();
+
+ public function __construct(IcingaObject $object)
+ {
+ $this->object = $object;
+ }
+
+ #[\ReturnTypeWillChange]
+ public function count()
+ {
+ return count($this->ranges);
+ }
+
+ #[\ReturnTypeWillChange]
+ public function rewind()
+ {
+ $this->position = 0;
+ }
+
+ public function hasBeenModified()
+ {
+ return $this->modified;
+ }
+
+ #[\ReturnTypeWillChange]
+ public function current()
+ {
+ if (! $this->valid()) {
+ return null;
+ }
+
+ return $this->ranges[$this->idx[$this->position]];
+ }
+
+ #[\ReturnTypeWillChange]
+ public function key()
+ {
+ return $this->idx[$this->position];
+ }
+
+ #[\ReturnTypeWillChange]
+ public function next()
+ {
+ ++$this->position;
+ }
+
+ #[\ReturnTypeWillChange]
+ public function valid()
+ {
+ return array_key_exists($this->position, $this->idx);
+ }
+
+ public function get($key)
+ {
+ if (array_key_exists($key, $this->ranges)) {
+ return $this->ranges[$key];
+ }
+
+ return null;
+ }
+
+ public function getValues()
+ {
+ $res = array();
+ foreach ($this->ranges as $key => $range) {
+ $res[$key] = $range->range_value;
+ }
+
+ return (object) $res;
+ }
+
+ public function getOriginalValues()
+ {
+ $res = array();
+ foreach ($this->storedRanges as $key => $range) {
+ $res[$key] = $range->range_value;
+ }
+
+ return (object) $res;
+ }
+
+ public function getRanges()
+ {
+ return $this->ranges;
+ }
+
+ protected function modify($range, $value)
+ {
+ $this->ranges[$range]->range_key = $value;
+ }
+
+ public function set($ranges)
+ {
+ foreach ($ranges as $range => $value) {
+ $this->setRange($range, $value);
+ }
+
+ $toDelete = array_diff(array_keys($this->ranges), array_keys($ranges));
+ foreach ($toDelete as $range) {
+ $this->remove($range);
+ }
+
+ return $this;
+ }
+
+ public function setRange($range, $value)
+ {
+ if ($value === null && array_key_exists($range, $this->ranges)) {
+ $this->remove($range);
+ return $this;
+ }
+
+ if (array_key_exists($range, $this->ranges)) {
+ if ($this->ranges[$range]->range_value === $value) {
+ return $this;
+ } else {
+ $this->ranges[$range]->range_value = $value;
+ $this->modified = true;
+ }
+ } else {
+ $class = $this->getRangeClass();
+ $this->ranges[$range] = $class::create([
+ $this->objectIdColumn => $this->object->get('id'),
+ 'range_key' => $range,
+ 'range_value' => $value,
+ ]);
+ $this->modified = true;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Magic isset check
+ *
+ * @return boolean
+ */
+ public function __isset($range)
+ {
+ return array_key_exists($range, $this->ranges);
+ }
+
+ public function remove($range)
+ {
+ if (array_key_exists($range, $this->ranges)) {
+ unset($this->ranges[$range]);
+ }
+
+ $this->modified = true;
+ $this->refreshIndex();
+ }
+
+ public function clear()
+ {
+ $this->ranges = [];
+ $this->modified = true;
+ $this->refreshIndex();
+ }
+
+ protected function refreshIndex()
+ {
+ ksort($this->ranges);
+ $this->idx = array_keys($this->ranges);
+ }
+
+ public function listRangesNames()
+ {
+ return array_keys($this->ranges);
+ }
+
+ public function getType()
+ {
+ return $this->object->getShortTableName();
+ }
+
+ public function getRangeTableName()
+ {
+ return $this->object->getTableName() . '_range';
+ }
+
+ protected function loadFromDb()
+ {
+ $db = $this->object->getDb();
+ $connection = $this->object->getConnection();
+
+ $table = $this->getRangeTableName();
+
+ $query = $db->select()
+ ->from(['o' => $table])
+ ->where('o.' . $this->objectIdColumn . ' = ?', (int) $this->object->get('id'))
+ ->order('o.range_key');
+
+ $class = $this->getRangeClass();
+ $this->ranges = $class::loadAll($connection, $query, 'range_key');
+ $this->setBeingLoadedFromDb();
+
+ return $this;
+ }
+
+ public function setBeingLoadedFromDb()
+ {
+ $this->storedRanges = [];
+
+ foreach ($this->ranges as $key => $range) {
+ $range->setBeingLoadedFromDb();
+ $this->storedRanges[$key] = clone($range);
+ }
+ $this->refreshIndex();
+ $this->modified = false;
+ }
+
+ public function store()
+ {
+ $db = $this->object->getConnection();
+ if (! $this->hasBeenModified()) {
+ return false;
+ }
+
+ $table = $this->getRangeTableName();
+ $objectId = (int) $this->object->get('id');
+ $idColumn = $this->objectIdColumn;
+ foreach ($this->ranges as $range) {
+ if ($range->hasBeenModified()) {
+ $range->setConnection($db);
+ if ((int) $range->get($idColumn) !== $objectId) {
+ $range->set($idColumn, $objectId);
+ }
+ }
+ }
+
+ foreach (array_diff(array_keys($this->storedRanges), array_keys($this->ranges)) as $delete) {
+ $range = $this->storedRanges[$delete];
+ $range->setConnection($db);
+ $range->set($idColumn, $objectId);
+ $db->getDbAdapter()->delete($table, $range->createWhere());
+ unset($this->ranges[$delete]);
+ }
+ foreach ($this->ranges as $range) {
+ $range->store();
+ }
+ $this->setBeingLoadedFromDb();
+
+ return true;
+ }
+
+ /**
+ * @return IcingaTimePeriodRange|IcingaScheduledDowntimeRange|string IDE hint
+ */
+ protected function getRangeClass()
+ {
+ return $this->rangeClass;
+ }
+
+ public static function loadForStoredObject(IcingaObject $object)
+ {
+ $ranges = new static($object);
+ return $ranges->loadFromDb();
+ }
+
+ public function toConfigString()
+ {
+ if (empty($this->ranges) && $this->object->object_type === 'template') {
+ return '';
+ }
+
+ $string = " ranges = {\n";
+
+ foreach ($this->ranges as $range) {
+ $string .= sprintf(
+ " %s\t= %s\n",
+ c::renderString($range->range_key),
+ c::renderString($range->range_value)
+ );
+ }
+
+ return $string . " }\n";
+ }
+
+ abstract public function toLegacyConfigString();
+
+ public function __toString()
+ {
+ try {
+ return $this->toConfigString();
+ } catch (Exception $e) {
+ trigger_error($e);
+ $previousHandler = set_exception_handler(
+ function () {
+ }
+ );
+ restore_error_handler();
+ if ($previousHandler !== null) {
+ call_user_func($previousHandler, $e);
+ die();
+ } else {
+ die($e->getMessage());
+ }
+ }
+ }
+}
diff --git a/library/Director/Objects/IcingaRelatedObject.php b/library/Director/Objects/IcingaRelatedObject.php
new file mode 100644
index 0000000..d35bcb0
--- /dev/null
+++ b/library/Director/Objects/IcingaRelatedObject.php
@@ -0,0 +1,211 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Icinga\Exception\ProgrammingError;
+use Icinga\Module\Director\Objects\IcingaObject;
+
+/**
+ * Related Object
+ *
+ * This class comes in handy when working with simple foreign key references. In
+ * contrast to an ORM it helps to deal with lazy-loaded objects in a way allowing
+ * us to render objects with references which to no longer (or not yet) exist
+ */
+class IcingaRelatedObject
+{
+ /** @var IcingaObject Main object with (optional) relation */
+ protected $owner;
+
+ /** @var int Related object id */
+ protected $id;
+
+ /** @var int Related object name */
+ protected $name;
+
+ /** @var int Relation property name, e.g. 'host' */
+ protected $key;
+
+ /** @var int Relation property, e.g. 'host_id' */
+ protected $idKey;
+
+ /** @var IcingaObject Related object once loaded */
+ protected $object;
+
+ /** @var string Related class name */
+ protected $className;
+
+ /**
+ * IcingaRelatedObject constructor
+ *
+ * @param IcingaObject $owner Main object referring a related one
+ * @param string $key Main objects (short) property name for this
+ */
+ public function __construct(IcingaObject $owner, $key)
+ {
+ $this->owner = $owner;
+ $this->key = $key;
+ $this->idKey = $key . '_id';
+ }
+
+ /**
+ * Set a specific id
+ *
+ * @param $id int
+ *
+ * @return self
+ */
+ public function setId($id)
+ {
+ if (! is_int($id)) {
+ throw new ProgrammingError(
+ 'An id must be an integer'
+ );
+ }
+
+ if ($this->object !== null) {
+ if ($this->object->id === $id) {
+ return $this;
+ } else {
+ $this->object = null;
+ }
+ }
+
+ if ($this->object === null) {
+ $this->name = null;
+ }
+
+ $this->id = $id;
+ $this->owner->set($this->getRealPropertyName(), $id);
+
+ return $this;
+ }
+
+ /**
+ * Return the related objects id
+ *
+ * @return int
+ */
+ public function getId()
+ {
+ if ($this->id === null) {
+ $this->id = $this->getObject()->id;
+ }
+
+ return $this->id;
+ }
+
+ /**
+ * Lazy-load the related object
+ *
+ * @return IcingaObject
+ */
+ public function getObject()
+ {
+ // TODO: This is unfinished
+
+ if ($this->object === null) {
+ $class = $this->getClassName();
+
+ if ($this->name === null) {
+ if ($id = $this->getId()) {
+ }
+ } else {
+ $this->object = $class::load($this->name, $this->owner->getConnection());
+ }
+ }
+ return $this->object;
+ }
+
+ /**
+ * The real property name pointing to this relation, e.g. 'host_id'
+ *
+ * @return string
+ */
+ public function getRealPropertyName()
+ {
+ return $this->key . '_id';
+ }
+
+ /**
+ * Full related class name
+ *
+ * @return string
+ */
+ public function getClassName()
+ {
+ if ($this->className === null) {
+ $this->className = __NAMESPACE__ . '\\' . $this->getShortClassName();
+ }
+
+ return $this->className;
+ }
+
+ /**
+ * Related class name relative to Icinga\Module\Director\Objects
+ *
+ * @return string
+ */
+ public function getShortClassName()
+ {
+ return $this->owner->getRelationObjectClass($this->key);
+ }
+
+ /**
+ * Set a related property
+ *
+ * This might be a string or an object
+ * @param $related string|IcingaObject
+ * @throws ProgrammingError
+ *
+ * return self
+ */
+ public function set($related)
+ {
+ if (is_string($related)) {
+ $this->name = $related;
+ } elseif (is_object($related)) {
+ $className = $this->getClassName();
+ if ($related instanceof $className) {
+ $this->object = $related;
+ $this->name = $object->object_name;
+ $this->id = $object->id;
+ } else {
+ throw new ProgrammingError(
+ 'Trying to set a related "%s" while expecting "%s"',
+ get_class($related),
+ $this->getShortClassName()
+ );
+ }
+ } else {
+ throw new ProgrammingError(
+ 'Related object can be name or object, got: %s',
+ var_export($related, 1)
+ );
+ }
+
+ return $this;
+ }
+
+ /**
+ * Get the name of the related object
+ *
+ * @return string
+ */
+ public function getName()
+ {
+ if ($this->name === null) {
+ return $this->owner->{$this->key};
+ } else {
+ return $this->name;
+ }
+ }
+
+ /**
+ * Conservative constructor to avoid issued with PHP GC
+ */
+ public function __destruct()
+ {
+ unset($this->owner);
+ }
+}
diff --git a/library/Director/Objects/IcingaScheduledDowntime.php b/library/Director/Objects/IcingaScheduledDowntime.php
new file mode 100644
index 0000000..7fc3f78
--- /dev/null
+++ b/library/Director/Objects/IcingaScheduledDowntime.php
@@ -0,0 +1,135 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c;
+use RuntimeException;
+
+class IcingaScheduledDowntime extends IcingaObject
+{
+ protected $table = 'icinga_scheduled_downtime';
+
+ protected $defaultProperties = [
+ 'id' => null,
+ 'uuid' => null,
+ 'zone_id' => null,
+ 'object_name' => null,
+ 'object_type' => null,
+ 'disabled' => 'n',
+ 'author' => null,
+ 'comment' => null,
+ 'fixed' => null,
+ 'duration' => null,
+ 'apply_to' => null,
+ 'assign_filter' => null,
+ 'with_services' => null,
+ ];
+
+ protected $uuidColumn = 'uuid';
+
+ protected $supportsImports = true;
+
+ protected $supportsRanges = true;
+
+ protected $supportsApplyRules = true;
+
+ protected $relations = [
+ 'zone' => 'IcingaZone',
+ ];
+
+ protected $booleans = [
+ 'fixed' => 'fixed',
+ ];
+
+ protected $intervalProperties = [
+ 'duration' => 'duration',
+ ];
+
+ protected $propertiesNotForRendering = [
+ 'id',
+ 'apply_to',
+ 'object_name',
+ 'object_type',
+ 'with_services',
+ ];
+
+ /**
+ * @return string
+ */
+ protected function renderObjectHeader()
+ {
+ if ($this->isApplyRule()) {
+ if (($to = $this->get('apply_to')) === null) {
+ throw new RuntimeException(sprintf(
+ 'Applied notification "%s" has no valid object type',
+ $this->getObjectName()
+ ));
+ }
+
+ return sprintf(
+ "%s %s %s to %s {\n",
+ $this->getObjectTypeName(),
+ $this->getType(),
+ c::renderString($this->getObjectName()),
+ ucfirst($to)
+ );
+ } else {
+ return parent::renderObjectHeader();
+ }
+ }
+
+ public function getOnDeleteUrl()
+ {
+ if ($this->isApplyRule()) {
+ return 'director/scheduled-downtimes/applyrules';
+ } elseif ($this->isTemplate()) {
+ return 'director/scheduled-downtimes/templates';
+ } else {
+ return 'director/scheduled-downtimes';
+ }
+ }
+
+ public function isActive($now = null)
+ {
+ if ($now === null) {
+ $now = time();
+ }
+
+ foreach ($this->ranges()->getRanges() as $range) {
+ if ($range->isActive($now)) {
+ return true;
+ }
+ }
+
+ // TODO: no range currently means (and renders) "never", Icinga behaves
+ // different. Figure out whether and how we should support this
+ return false;
+ }
+
+ /**
+ * @return string
+ */
+ protected function renderSuffix()
+ {
+ if ($this->get('with_services') === 'y' && $this->get('apply_to') === 'host') {
+ return parent::renderSuffix() . $this->renderCloneForServices();
+ } else {
+ return parent::renderSuffix();
+ }
+ }
+
+ protected function prefersGlobalZone()
+ {
+ return false;
+ }
+
+ protected function renderCloneForServices()
+ {
+ $services = clone($this);
+ $services
+ ->set('with_services', 'n')
+ ->set('apply_to', 'service');
+
+ return $services->toConfigString();
+ }
+}
diff --git a/library/Director/Objects/IcingaScheduledDowntimeRange.php b/library/Director/Objects/IcingaScheduledDowntimeRange.php
new file mode 100644
index 0000000..6280990
--- /dev/null
+++ b/library/Director/Objects/IcingaScheduledDowntimeRange.php
@@ -0,0 +1,88 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Icinga\Module\Director\Data\Db\DbObject;
+
+class IcingaScheduledDowntimeRange extends DbObject
+{
+ protected $keyName = ['scheduled_downtime_id', 'range_key', 'range_type'];
+
+ protected $table = 'icinga_scheduled_downtime_range';
+
+ protected $defaultProperties = [
+ 'scheduled_downtime_id' => null,
+ 'range_key' => null,
+ 'range_value' => null,
+ 'range_type' => 'include',
+ 'merge_behaviour' => 'set',
+ ];
+
+ public function isActive($now = null)
+ {
+ if ($now === null) {
+ $now = time();
+ }
+
+ if (false === ($weekDay = $this->getWeekDay($this->get('range_key')))) {
+ // TODO, dates are not yet supported
+ return false;
+ }
+
+ if ((int) date('w', $now) !== $weekDay) {
+ return false;
+ }
+
+ $timeRanges = preg_split('/\s*,\s*/', $this->get('range_value'), -1, PREG_SPLIT_NO_EMPTY);
+ foreach ($timeRanges as $timeRange) {
+ if ($this->timeRangeIsActive($timeRange, $now)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ protected function timeRangeIsActive($rangeString, $now)
+ {
+ $hBegin = $mBegin = $hEnd = $mEnd = null;
+ if (sscanf($rangeString, '%2d:%2d-%2d:%2d', $hBegin, $mBegin, $hEnd, $mEnd) === 4) {
+ if ($this->timeFromHourMin($hBegin, $mBegin, $now) <= $now
+ && $this->timeFromHourMin($hEnd, $mEnd, $now) >= $now
+ ) {
+ return true;
+ }
+ } else {
+ // TODO: throw exception?
+ }
+
+ return false;
+ }
+
+ protected function timeFromHourMin($hour, $min, $now)
+ {
+ return strtotime(sprintf('%s %02d:%02d:00', date('Y-m-d', $now), $hour, $min));
+ }
+
+ protected function getWeekDay($day)
+ {
+ switch ($day) {
+ case 'sunday':
+ return 0;
+ case 'monday':
+ return 1;
+ case 'tuesday':
+ return 2;
+ case 'wednesday':
+ return 3;
+ case 'thursday':
+ return 4;
+ case 'friday':
+ return 5;
+ case 'saturday':
+ return 6;
+ }
+
+ return false;
+ }
+}
diff --git a/library/Director/Objects/IcingaScheduledDowntimeRanges.php b/library/Director/Objects/IcingaScheduledDowntimeRanges.php
new file mode 100644
index 0000000..ac8483e
--- /dev/null
+++ b/library/Director/Objects/IcingaScheduledDowntimeRanges.php
@@ -0,0 +1,18 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Countable;
+use Iterator;
+use Icinga\Module\Director\IcingaConfig\IcingaConfigRenderer;
+
+class IcingaScheduledDowntimeRanges extends IcingaRanges implements Iterator, Countable, IcingaConfigRenderer
+{
+ protected $rangeClass = IcingaScheduledDowntimeRange::class;
+ protected $objectIdColumn = 'scheduled_downtime_id';
+
+ public function toLegacyConfigString()
+ {
+ return '';
+ }
+}
diff --git a/library/Director/Objects/IcingaService.php b/library/Director/Objects/IcingaService.php
new file mode 100644
index 0000000..9479ef7
--- /dev/null
+++ b/library/Director/Objects/IcingaService.php
@@ -0,0 +1,828 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Icinga\Data\Filter\Filter;
+use Icinga\Exception\IcingaException;
+use Icinga\Module\Director\Data\PropertiesFilter;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Db\Cache\PrefetchCache;
+use Icinga\Module\Director\DirectorObject\Automation\ExportInterface;
+use Icinga\Module\Director\Exception\DuplicateKeyException;
+use Icinga\Module\Director\IcingaConfig\IcingaConfig;
+use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c;
+use Icinga\Module\Director\IcingaConfig\IcingaLegacyConfigHelper as c1;
+use Icinga\Module\Director\Objects\Extension\FlappingSupport;
+use Icinga\Module\Director\Resolver\HostServiceBlacklist;
+use InvalidArgumentException;
+use RuntimeException;
+
+class IcingaService extends IcingaObject implements ExportInterface
+{
+ use FlappingSupport;
+
+ protected $table = 'icinga_service';
+
+ protected $uuidColumn = 'uuid';
+
+ protected $defaultProperties = [
+ 'id' => null,
+ 'uuid' => null,
+ 'object_name' => null,
+ 'object_type' => null,
+ 'disabled' => 'n',
+ 'display_name' => null,
+ 'host_id' => null,
+ 'service_set_id' => null,
+ 'check_command_id' => null,
+ 'max_check_attempts' => null,
+ 'check_period_id' => null,
+ 'check_interval' => null,
+ 'retry_interval' => null,
+ 'check_timeout' => null,
+ 'enable_notifications' => null,
+ 'enable_active_checks' => null,
+ 'enable_passive_checks' => null,
+ 'enable_event_handler' => null,
+ 'enable_flapping' => null,
+ 'enable_perfdata' => null,
+ 'event_command_id' => null,
+ 'flapping_threshold_high' => null,
+ 'flapping_threshold_low' => null,
+ 'volatile' => null,
+ 'zone_id' => null,
+ 'command_endpoint_id' => null,
+ 'notes' => null,
+ 'notes_url' => null,
+ 'action_url' => null,
+ 'icon_image' => null,
+ 'icon_image_alt' => null,
+ 'use_agent' => null,
+ 'apply_for' => null,
+ 'use_var_overrides' => null,
+ 'assign_filter' => null,
+ 'template_choice_id' => null,
+ ];
+
+ protected $relations = [
+ 'host' => 'IcingaHost',
+ 'service_set' => 'IcingaServiceSet',
+ 'check_command' => 'IcingaCommand',
+ 'event_command' => 'IcingaCommand',
+ 'check_period' => 'IcingaTimePeriod',
+ 'command_endpoint' => 'IcingaEndpoint',
+ 'zone' => 'IcingaZone',
+ 'template_choice' => 'IcingaTemplateChoiceService',
+ ];
+
+ protected $booleans = [
+ 'enable_notifications' => 'enable_notifications',
+ 'enable_active_checks' => 'enable_active_checks',
+ 'enable_passive_checks' => 'enable_passive_checks',
+ 'enable_event_handler' => 'enable_event_handler',
+ 'enable_flapping' => 'enable_flapping',
+ 'enable_perfdata' => 'enable_perfdata',
+ 'volatile' => 'volatile',
+ 'use_agent' => 'use_agent',
+ 'use_var_overrides' => 'use_var_overrides',
+ ];
+
+ protected $intervalProperties = [
+ 'check_interval' => 'check_interval',
+ 'check_timeout' => 'check_timeout',
+ 'retry_interval' => 'retry_interval',
+ ];
+
+ protected $supportsGroups = true;
+
+ protected $supportsCustomVars = true;
+
+ protected $supportsFields = true;
+
+ protected $supportsImports = true;
+
+ protected $supportsApplyRules = true;
+
+ protected $supportsSets = true;
+
+ protected $supportsChoices = true;
+
+ protected $supportedInLegacy = true;
+
+ protected $keyName = ['host_id', 'service_set_id', 'object_name'];
+
+ protected $prioritizedProperties = ['host_id'];
+
+ protected $propertiesNotForRendering = [
+ 'id',
+ 'object_name',
+ 'object_type',
+ 'apply_for'
+ ];
+
+ /** @var ServiceGroupMembershipResolver */
+ protected $servicegroupMembershipResolver;
+
+ /**
+ * @return IcingaCommand
+ * @throws IcingaException
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function getCheckCommand()
+ {
+ $id = $this->getSingleResolvedProperty('check_command_id');
+ return IcingaCommand::loadWithAutoIncId(
+ $id,
+ $this->getConnection()
+ );
+ }
+
+ /**
+ * @return bool
+ */
+ public function isApplyRule()
+ {
+ if ($this->hasBeenAssignedToHostTemplate()) {
+ return true;
+ }
+
+ return $this->hasProperty('object_type')
+ && $this->get('object_type') === 'apply';
+ }
+
+ /**
+ * @return bool
+ */
+ public function usesVarOverrides()
+ {
+ return $this->get('use_var_overrides') === 'y';
+ }
+
+ public function getUniqueIdentifier()
+ {
+ if ($this->isTemplate()) {
+ return $this->getObjectName();
+ } else {
+ throw new RuntimeException(
+ 'getUniqueIdentifier() is supported by Service Templates only'
+ );
+ }
+ }
+
+ /**
+ * @return object
+ * @deprecated please use \Icinga\Module\Director\Data\Exporter
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function export()
+ {
+ // TODO: ksort in toPlainObject?
+ $props = (array) $this->toPlainObject();
+ $props['fields'] = $this->loadFieldReferences();
+ ksort($props);
+
+ return (object) $props;
+ }
+
+ /**
+ * @param $plain
+ * @param Db $db
+ * @param bool $replace
+ * @return IcingaService
+ * @throws DuplicateKeyException
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public static function import($plain, Db $db, $replace = false)
+ {
+ $properties = (array) $plain;
+ $name = $properties['object_name'];
+ if ($properties['object_type'] !== 'template') {
+ throw new InvalidArgumentException(sprintf(
+ 'Can import only Templates, got "%s" for "%s"',
+ $properties['object_type'],
+ $name
+ ));
+ }
+ $key = [
+ 'object_type' => 'template',
+ 'object_name' => $name
+ ];
+
+ if ($replace && static::exists($key, $db)) {
+ $object = static::load($key, $db);
+ } elseif (static::exists($key, $db)) {
+ throw new DuplicateKeyException(
+ 'Service Template "%s" already exists',
+ $name
+ );
+ } else {
+ $object = static::create([], $db);
+ }
+
+ // $object->newFields = $properties['fields'];
+ unset($properties['fields']);
+ $object->setProperties($properties);
+
+ return $object;
+ }
+
+ /**
+ * @deprecated please use \Icinga\Module\Director\Data\FieldReferenceLoader
+ * @return array
+ */
+ protected function loadFieldReferences()
+ {
+ $db = $this->getDb();
+
+ $res = $db->fetchAll(
+ $db->select()->from([
+ 'sf' => 'icinga_service_field'
+ ], [
+ 'sf.datafield_id',
+ 'sf.is_required',
+ 'sf.var_filter',
+ ])->join(['df' => 'director_datafield'], 'df.id = sf.datafield_id', [])
+ ->where('service_id = ?', $this->get('id'))
+ ->order('varname ASC')
+ );
+
+ if (empty($res)) {
+ return [];
+ } else {
+ foreach ($res as $field) {
+ $field->datafield_id = (int) $field->datafield_id;
+ }
+
+ return $res;
+ }
+ }
+
+ /**
+ * @param string $key
+ * @return $this
+ */
+ protected function setKey($key)
+ {
+ if (is_int($key)) {
+ $this->set('id', $key);
+ } elseif (is_array($key)) {
+ foreach (['id', 'host_id', 'service_set_id', 'object_name'] as $k) {
+ if (array_key_exists($k, $key)) {
+ $this->set($k, $key[$k]);
+ }
+ }
+ } else {
+ parent::setKey($key);
+ }
+
+ return $this;
+ }
+
+ /**
+ * @param $name
+ * @return $this
+ * @codingStandardsIgnoreStart
+ */
+ protected function setObject_Name($name)
+ {
+ // @codingStandardsIgnoreEnd
+
+ if ($name === null && $this->isApplyRule()) {
+ $name = '';
+ }
+
+ return $this->reallySet('object_name', $name);
+ }
+
+ /**
+ * Render host_id as host_name
+ *
+ * Avoid complaints for method names with underscore:
+ * @codingStandardsIgnoreStart
+ *
+ * @return string
+ */
+ public function renderHost_id()
+ {
+ if ($this->hasBeenAssignedToHostTemplate()) {
+ return '';
+ }
+
+ return $this->renderRelationProperty('host', $this->get('host_id'), 'host_name');
+ }
+
+ /**
+ * @codingStandardsIgnoreStart
+ */
+ protected function renderLegacyHost_id($value)
+ {
+ // @codingStandardsIgnoreEnd
+ if (is_array($value)) {
+ $blacklisted = $this->getBlacklistedHostnames();
+ $c = c1::renderKeyValue('host_name', c1::renderArray(array_diff($value, $blacklisted)));
+
+ // blacklisted in this (zoned) scope?
+ $bl = array_intersect($blacklisted, $value);
+ if (! empty($bl)) {
+ $c .= c1::renderKeyValue('# ignored on', c1::renderArray($bl));
+ }
+
+ return $c;
+ } else {
+ return parent::renderLegacyHost_id($value);
+ }
+ }
+
+ /**
+ * @param IcingaConfig $config
+ * @throws IcingaException
+ */
+ public function renderToLegacyConfig(IcingaConfig $config)
+ {
+ if ($this->get('service_set_id') !== null) {
+ return;
+ } elseif ($this->isApplyRule()) {
+ $this->renderLegacyApplyToConfig($config);
+ } else {
+ parent::renderToLegacyConfig($config);
+ }
+ }
+
+ /**
+ * @param IcingaConfig $config
+ * @throws IcingaException
+ */
+ protected function renderLegacyApplyToConfig(IcingaConfig $config)
+ {
+ $conn = $this->getConnection();
+
+ $assign_filter = $this->get('assign_filter');
+ $filter = Filter::fromQueryString($assign_filter);
+ $hostnames = HostApplyMatches::forFilter($filter, $conn);
+
+ $this->set('object_type', 'object');
+
+ foreach ($this->mapHostsToZones($hostnames) as $zone => $names) {
+ $blacklisted = $this->getBlacklistedHostnames();
+ $zoneNames = array_diff($names, $blacklisted);
+
+ $disabled = [];
+ foreach ($zoneNames as $name) {
+ if (IcingaHost::load($name, $this->getConnection())->isDisabled()) {
+ $disabled[] = $name;
+ }
+ }
+ $zoneNames = array_diff($zoneNames, $disabled);
+
+ if (empty($zoneNames)) {
+ continue;
+ }
+
+ $this->set('host_id', $zoneNames);
+
+ $config->configFile('director/' . $zone . '/service_apply', '.cfg')
+ ->addLegacyObject($this);
+ }
+ }
+
+ /**
+ * @return string
+ */
+ public function toLegacyConfigString()
+ {
+ if ($this->get('service_set_id') !== null) {
+ return '';
+ }
+
+ $str = parent::toLegacyConfigString();
+
+ if (! $this->isDisabled()
+ && $this->get('host_id')
+ && $this->getRelated('host')->isDisabled()
+ ) {
+ return "# --- This services host has been disabled ---\n"
+ . preg_replace('~^~m', '# ', trim($str))
+ . "\n\n";
+ } else {
+ return $str;
+ }
+ }
+
+ /**
+ * @return string
+ */
+ public function toConfigString()
+ {
+ if ($this->get('service_set_id')) {
+ return '';
+ }
+ $str = parent::toConfigString();
+
+ if (! $this->isDisabled()
+ && $this->get('host_id')
+ && $this->getRelated('host')->isDisabled()
+ ) {
+ return "/* --- This services host has been disabled ---\n"
+ // Do not allow strings to break our comment
+ . str_replace('*/', "* /", $str) . "*/\n";
+ } else {
+ return $str;
+ }
+ }
+
+ /**
+ * @return string
+ */
+ protected function renderObjectHeader()
+ {
+ if ($this->isApplyRule()
+ && !$this->hasBeenAssignedToHostTemplate()
+ && $this->get('apply_for') !== null
+ ) {
+ $name = $this->getObjectName();
+ $extraName = '';
+
+ if (c::stringHasMacro($name)) {
+ $extraName = c::renderKeyValue('name', c::renderStringWithVariables($name));
+ $name = '';
+ } elseif ($name !== '') {
+ $name = ' ' . c::renderString($name);
+ }
+
+ return sprintf(
+ "%s %s%s for (config in %s) {\n",
+ $this->getObjectTypeName(),
+ $this->getType(),
+ $name,
+ $this->get('apply_for')
+ ) . $extraName;
+ }
+
+ return parent::renderObjectHeader();
+ }
+
+ /**
+ * @return string
+ */
+ protected function getLegacyObjectKeyName()
+ {
+ if ($this->isTemplate()) {
+ return 'name';
+ } else {
+ return 'service_description';
+ }
+ }
+
+ /**
+ * @return bool
+ */
+ public function hasBeenAssignedToHostTemplate()
+ {
+ // Branches would fail
+ if ($this->properties['host_id'] === null) {
+ return null;
+ }
+ $hostId = $this->get('host_id');
+
+ return $hostId && $this->getRelatedObject(
+ 'host',
+ $hostId
+ )->isTemplate();
+ }
+
+ /**
+ * @return string
+ * @throws \Icinga\Exception\NotFoundError
+ * @throws \Icinga\Module\Director\Exception\NestingError
+ */
+ protected function renderSuffix()
+ {
+ $suffix = '';
+ if ($this->isApplyRule()) {
+ $zoneName = $this->getRenderingZone();
+ if (!IcingaZone::zoneNameIsGlobal($zoneName, $this->connection)) {
+ $suffix .= c::renderKeyValue('zone', c::renderString($zoneName));
+ }
+ }
+
+ if ($this->isApplyRule() || $this->usesVarOverrides()) {
+ $suffix .= $this->renderImportHostVarOverrides();
+ }
+
+ return $suffix . parent::renderSuffix();
+ }
+
+ /**
+ * @return string
+ */
+ protected function renderImportHostVarOverrides()
+ {
+ if (! $this->connection) {
+ throw new RuntimeException(
+ 'Cannot render services without an assigned DB connection'
+ );
+ }
+
+ return "\n import DirectorOverrideTemplate\n";
+ }
+
+ /**
+ * @return string
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ protected function renderCustomExtensions()
+ {
+ $output = '';
+
+ if ($this->hasBeenAssignedToHostTemplate()) {
+ // TODO: use assignment renderer?
+ $filter = sprintf(
+ 'assign where %s in host.templates',
+ c::renderString($this->get('host'))
+ );
+
+ $output .= "\n " . $filter . "\n";
+ }
+
+ $blacklist = $this->getBlacklistedHostnames();
+ $blacklistedTemplates = [];
+ $blacklistedHosts = [];
+ foreach ($blacklist as $hostname) {
+ if (IcingaHost::load($hostname, $this->connection)->isTemplate()) {
+ $blacklistedTemplates[] = $hostname;
+ } else {
+ $blacklistedHosts[] = $hostname;
+ }
+ }
+ foreach ($blacklistedTemplates as $template) {
+ $output .= sprintf(
+ " ignore where %s in host.templates\n",
+ c::renderString($template)
+ );
+ }
+ if (! empty($blacklistedHosts)) {
+ if (count($blacklistedHosts) === 1) {
+ $output .= sprintf(
+ " ignore where host.name == %s\n",
+ c::renderString($blacklistedHosts[0])
+ );
+ } else {
+ $output .= sprintf(
+ " ignore where host.name in %s\n",
+ c::renderArray($blacklistedHosts)
+ );
+ }
+ }
+
+ // A hand-crafted command endpoint overrides use_agent
+ if ($this->get('command_endpoint_id') !== null) {
+ return $output;
+ }
+
+ if ($this->get('use_agent') === 'y') {
+ // When feature flag feature_custom_endpoint is enabled, render additional code
+ if ($this->connection->settings()->get('feature_custom_endpoint') === 'y') {
+ return $output . "
+ // Set command_endpoint dynamically with Director
+ if (!host) {
+ var host = get_host(host_name)
+ }
+ if (host.vars._director_custom_endpoint_name) {
+ command_endpoint = host.vars._director_custom_endpoint_name
+ } else {
+ command_endpoint = host_name
+ }
+";
+ } else {
+ return $output . c::renderKeyValue('command_endpoint', 'host_name');
+ }
+ } elseif ($this->get('use_agent') === 'n') {
+ return $output . c::renderKeyValue('command_endpoint', c::renderPhpValue(null));
+ } else {
+ return $output;
+ }
+ }
+
+ /**
+ * @return array
+ */
+ public function getBlacklistedHostnames()
+ {
+ // Hint: if ($this->isApplyRule()) would be nice, but apply rules are
+ // not enough, one might want to blacklist single services from Sets
+ // assigned to single Hosts.
+ if (PrefetchCache::shouldBeUsed()) {
+ $lookup = PrefetchCache::instance()->hostServiceBlacklist();
+ } else {
+ $lookup = new HostServiceBlacklist($this->getConnection());
+ }
+
+ return $lookup->getBlacklistedHostnamesForService($this);
+ }
+
+ /**
+ * Do not render internal property
+ *
+ * Avoid complaints for method names with underscore:
+ * @codingStandardsIgnoreStart
+ *
+ * @return string
+ */
+ public function renderUse_agent()
+ {
+ return '';
+ }
+
+ public function renderUse_var_overrides()
+ {
+ return '';
+ }
+
+ protected function renderTemplate_choice_id()
+ {
+ return '';
+ }
+
+ protected function renderLegacyDisplay_Name()
+ {
+ // @codingStandardsIgnoreEnd
+ return c1::renderKeyValue('display_name', $this->get('display_name'));
+ }
+
+ public function hasCheckCommand()
+ {
+ return $this->getSingleResolvedProperty('check_command_id') !== null;
+ }
+
+ public function getOnDeleteUrl()
+ {
+ if ($this->get('host_id')) {
+ return 'director/host/services?name=' . rawurlencode($this->get('host'));
+ } elseif ($this->get('service_set_id')) {
+ return 'director/serviceset/services?name=' . rawurlencode($this->get('service_set'));
+ } else {
+ return parent::getOnDeleteUrl();
+ }
+ }
+
+ protected function getDefaultZone(IcingaConfig $config = null)
+ {
+ if ($this->get('host_id') === null) {
+ return parent::getDefaultZone();
+ } else {
+ return $this->getRelatedObject('host', $this->get('host_id'))
+ ->getRenderingZone($config);
+ }
+ }
+
+ /**
+ * @return string
+ */
+ public function createWhere()
+ {
+ $where = parent::createWhere();
+ if (! $this->hasBeenLoadedFromDb()) {
+ if (null === $this->get('service_set_id')
+ && null === $this->get('host_id')
+ && null === $this->get('id')
+ ) {
+ $where .= " AND object_type = 'template'";
+ }
+ }
+
+ return $where;
+ }
+
+
+ /**
+ * TODO: Duplicate code, clean this up, split it into multiple methods
+ * @param Db|null $connection
+ * @param string $prefix
+ * @param null $filter
+ * @return array
+ */
+ public static function enumProperties(
+ Db $connection = null,
+ $prefix = '',
+ $filter = null
+ ) {
+ $serviceProperties = [];
+ if ($filter === null) {
+ $filter = new PropertiesFilter();
+ }
+ $realProperties = static::create()->listProperties();
+ sort($realProperties);
+
+ if ($filter->match(PropertiesFilter::$SERVICE_PROPERTY, 'name')) {
+ $serviceProperties[$prefix . 'name'] = 'name';
+ }
+ foreach ($realProperties as $prop) {
+ if (!$filter->match(PropertiesFilter::$SERVICE_PROPERTY, $prop)) {
+ continue;
+ }
+
+ if (substr($prop, -3) === '_id') {
+ if ($prop === 'template_choice_id') {
+ continue;
+ }
+ $prop = substr($prop, 0, -3);
+ }
+
+ $serviceProperties[$prefix . $prop] = $prop;
+ }
+
+ $serviceVars = [];
+
+ if ($connection !== null) {
+ foreach ($connection->fetchDistinctServiceVars() as $var) {
+ if ($filter->match(PropertiesFilter::$CUSTOM_PROPERTY, $var->varname, $var)) {
+ if ($var->datatype) {
+ $serviceVars[$prefix . 'vars.' . $var->varname] = sprintf(
+ '%s (%s)',
+ $var->varname,
+ $var->caption
+ );
+ } else {
+ $serviceVars[$prefix . 'vars.' . $var->varname] = $var->varname;
+ }
+ }
+ }
+ }
+
+ //$properties['vars.*'] = 'Other custom variable';
+ ksort($serviceVars);
+
+ $props = mt('director', 'Service properties');
+ $vars = mt('director', 'Custom variables');
+
+ $properties = [];
+ if (!empty($serviceProperties)) {
+ $properties[$props] = $serviceProperties;
+ $properties[$props][$prefix . 'groups'] = 'Groups';
+ }
+
+ if (!empty($serviceVars)) {
+ $properties[$vars] = $serviceVars;
+ }
+
+ $hostProps = mt('director', 'Host properties');
+ $hostVars = mt('director', 'Host Custom variables');
+
+ $hostProperties = IcingaHost::enumProperties($connection, 'host.');
+
+ if (array_key_exists($hostProps, $hostProperties)) {
+ $p = $hostProperties[$hostProps];
+ if (!empty($p)) {
+ $properties[$hostProps] = $p;
+ }
+ }
+
+ if (array_key_exists($vars, $hostProperties)) {
+ $p = $hostProperties[$vars];
+ if (!empty($p)) {
+ $properties[$hostVars] = $p;
+ }
+ }
+
+ return $properties;
+ }
+
+ protected function beforeStore()
+ {
+ parent::beforeStore();
+ if ($this->isObject()
+ && $this->get('service_set_id') === null
+ && $this->get('host_id') === null
+ ) {
+ throw new InvalidArgumentException(
+ 'Cannot store a Service object without a related host or set: ' . $this->getObjectName()
+ );
+ }
+ }
+
+ protected function notifyResolvers()
+ {
+ $resolver = $this->getServiceGroupMembershipResolver();
+ $resolver->addObject($this);
+ $resolver->refreshDb();
+
+ return $this;
+ }
+
+ protected function getServiceGroupMembershipResolver()
+ {
+ if ($this->servicegroupMembershipResolver === null) {
+ $this->servicegroupMembershipResolver = new ServiceGroupMembershipResolver(
+ $this->getConnection()
+ );
+ }
+
+ return $this->servicegroupMembershipResolver;
+ }
+
+ public function setServiceGroupMembershipResolver(ServiceGroupMembershipResolver $resolver)
+ {
+ $this->servicegroupMembershipResolver = $resolver;
+ return $this;
+ }
+}
diff --git a/library/Director/Objects/IcingaServiceAssignment.php b/library/Director/Objects/IcingaServiceAssignment.php
new file mode 100644
index 0000000..9910f7c
--- /dev/null
+++ b/library/Director/Objects/IcingaServiceAssignment.php
@@ -0,0 +1,20 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+class IcingaServiceAssignment extends IcingaObject
+{
+ protected $table = 'icinga_service_assignment';
+
+ protected $keyName = 'id';
+
+ protected $defaultProperties = array(
+ 'id' => null,
+ 'service_id' => null,
+ 'filter_string' => null,
+ );
+
+ protected $relations = array(
+ 'service' => 'IcingaService',
+ );
+}
diff --git a/library/Director/Objects/IcingaServiceField.php b/library/Director/Objects/IcingaServiceField.php
new file mode 100644
index 0000000..c43ec2d
--- /dev/null
+++ b/library/Director/Objects/IcingaServiceField.php
@@ -0,0 +1,17 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+class IcingaServiceField extends IcingaObjectField
+{
+ protected $keyName = array('service_id', 'datafield_id');
+
+ protected $table = 'icinga_service_field';
+
+ protected $defaultProperties = array(
+ 'service_id' => null,
+ 'datafield_id' => null,
+ 'is_required' => null,
+ 'var_filter' => null,
+ );
+}
diff --git a/library/Director/Objects/IcingaServiceGroup.php b/library/Director/Objects/IcingaServiceGroup.php
new file mode 100644
index 0000000..ae43ff3
--- /dev/null
+++ b/library/Director/Objects/IcingaServiceGroup.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+class IcingaServiceGroup extends IcingaObjectGroup
+{
+ protected $table = 'icinga_servicegroup';
+
+ /** @var ServiceGroupMembershipResolver */
+ protected $servicegroupMembershipResolver;
+
+ public function supportsAssignments()
+ {
+ return true;
+ }
+
+ protected function getServiceGroupMembershipResolver()
+ {
+ if ($this->servicegroupMembershipResolver === null) {
+ $this->servicegroupMembershipResolver = new ServiceGroupMembershipResolver(
+ $this->getConnection()
+ );
+ }
+
+ return $this->servicegroupMembershipResolver;
+ }
+
+ public function setServiceGroupMembershipResolver(ServiceGroupMembershipResolver $resolver)
+ {
+ $this->servicegroupMembershipResolver = $resolver;
+ return $this;
+ }
+
+ protected function notifyResolvers()
+ {
+ $resolver = $this->getServiceGroupMembershipResolver();
+ $resolver->addGroup($this);
+ $resolver->refreshDb();
+
+ return $this;
+ }
+}
diff --git a/library/Director/Objects/IcingaServiceSet.php b/library/Director/Objects/IcingaServiceSet.php
new file mode 100644
index 0000000..8217a59
--- /dev/null
+++ b/library/Director/Objects/IcingaServiceSet.php
@@ -0,0 +1,591 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Exception;
+use Icinga\Data\Filter\Filter;
+use Icinga\Module\Director\Data\Db\ServiceSetQueryBuilder;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Db\Cache\PrefetchCache;
+use Icinga\Module\Director\Db\DbUtil;
+use Icinga\Module\Director\DirectorObject\Automation\ExportInterface;
+use Icinga\Module\Director\Exception\DuplicateKeyException;
+use Icinga\Module\Director\IcingaConfig\IcingaConfig;
+use Icinga\Module\Director\Resolver\HostServiceBlacklist;
+use InvalidArgumentException;
+use Ramsey\Uuid\Uuid;
+use RuntimeException;
+
+class IcingaServiceSet extends IcingaObject implements ExportInterface
+{
+ protected $table = 'icinga_service_set';
+
+ protected $defaultProperties = array(
+ 'id' => null,
+ 'uuid' => null,
+ 'host_id' => null,
+ 'object_name' => null,
+ 'object_type' => null,
+ 'description' => null,
+ 'assign_filter' => null,
+ );
+
+ protected $uuidColumn = 'uuid';
+
+ protected $keyName = array('host_id', 'object_name');
+
+ protected $supportsImports = true;
+
+ protected $supportsCustomVars = true;
+
+ protected $supportsApplyRules = true;
+
+ protected $supportedInLegacy = true;
+
+ protected $relations = array(
+ 'host' => 'IcingaHost',
+ );
+
+ public function isDisabled()
+ {
+ return false;
+ }
+
+ public function supportsAssignments()
+ {
+ return true;
+ }
+
+ protected function setKey($key)
+ {
+ if (is_int($key)) {
+ $this->set('id', $key);
+ } elseif (is_string($key)) {
+ $keyComponents = preg_split('~!~', $key);
+ if (count($keyComponents) === 1) {
+ $this->set('object_name', $keyComponents[0]);
+ $this->set('object_type', 'template');
+ } else {
+ throw new InvalidArgumentException(sprintf(
+ 'Can not parse key: %s',
+ $key
+ ));
+ }
+ } else {
+ return parent::setKey($key);
+ }
+
+ return $this;
+ }
+
+ /**
+ * @return IcingaService[]
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function getServiceObjects()
+ {
+ // don't try to resolve services for unstored objects - as in getServiceObjectsForSet()
+ // also for diff in activity log
+ if ($this->get('id') === null) {
+ return [];
+ }
+
+ if ($this->get('host_id')) {
+ $imports = $this->imports()->getObjects();
+ if (empty($imports)) {
+ return array();
+ }
+ $parent = array_shift($imports);
+ assert($parent instanceof IcingaServiceSet);
+ return $this->getServiceObjectsForSet($parent);
+ } else {
+ return $this->getServiceObjectsForSet($this);
+ }
+ }
+
+ /**
+ * @param IcingaServiceSet $set
+ * @return IcingaService[]
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ protected function getServiceObjectsForSet(IcingaServiceSet $set)
+ {
+ $connection = $this->getConnection();
+ if (self::$dbObjectStore !== null) {
+ $branchUuid = self::$dbObjectStore->getBranch()->getUuid();
+ } else {
+ $branchUuid = null;
+ }
+
+ $builder = new ServiceSetQueryBuilder($connection, $branchUuid);
+ return $builder->fetchServicesWithQuery($builder->selectServicesForSet($set));
+ }
+
+ public function getUniqueIdentifier()
+ {
+ return $this->getObjectName();
+ }
+
+ /**
+ * @return object
+ * @deprecated please use \Icinga\Module\Director\Data\Exporter
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function export()
+ {
+ if ($this->get('host_id')) {
+ $result = $this->exportSetOnHost();
+ } else {
+ $result = $this->exportTemplate();
+ }
+
+ unset($result->uuid);
+ return $result;
+ }
+
+ protected function exportSetOnHost()
+ {
+ // TODO.
+ throw new RuntimeException('Not yet');
+ }
+
+ /**
+ * @return object
+ * @deprecated
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ protected function exportTemplate()
+ {
+ $props = $this->getProperties();
+ unset($props['id'], $props['host_id']);
+ $props['services'] = [];
+ foreach ($this->getServiceObjects() as $serviceObject) {
+ $props['services'][$serviceObject->getObjectName()] = $serviceObject->export();
+ }
+ ksort($props);
+
+ return (object) $props;
+ }
+
+ /**
+ * @param $plain
+ * @param Db $db
+ * @param bool $replace
+ * @return IcingaServiceSet
+ * @throws DuplicateKeyException
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public static function import($plain, Db $db, $replace = false)
+ {
+ $properties = (array) $plain;
+ $name = $properties['object_name'];
+ if (isset($properties['services'])) {
+ $services = $properties['services'];
+ unset($properties['services']);
+ } else {
+ $services = [];
+ }
+
+ if ($properties['object_type'] !== 'template') {
+ throw new InvalidArgumentException(sprintf(
+ 'Can import only Templates, got "%s" for "%s"',
+ $properties['object_type'],
+ $name
+ ));
+ }
+ if ($replace && static::exists($name, $db)) {
+ $object = static::load($name, $db);
+ } elseif (static::exists($name, $db)) {
+ throw new DuplicateKeyException(
+ 'Service Set "%s" already exists',
+ $name
+ );
+ } else {
+ $object = static::create([], $db);
+ }
+
+ $object->setProperties($properties);
+
+ // This is not how other imports work, but here we need an ID
+ if (! $object->hasBeenLoadedFromDb()) {
+ $object->store();
+ }
+
+ $setId = $object->get('id');
+ $sQuery = $db->getDbAdapter()->select()->from(
+ ['s' => 'icinga_service'],
+ 's.*'
+ )->where('service_set_id = ?', $setId);
+ $existingServices = IcingaService::loadAll($db, $sQuery, 'object_name');
+ $serviceNames = [];
+ foreach ($services as $service) {
+ if (isset($service->fields)) {
+ unset($service->fields);
+ }
+ $name = $service->object_name;
+ $serviceNames[] = $name;
+ if (isset($existingServices[$name])) {
+ $existing = $existingServices[$name];
+ $existing->setProperties((array) $service);
+ $existing->set('service_set_id', $setId);
+ if ($existing->hasBeenModified()) {
+ $existing->store();
+ }
+ } else {
+ $new = IcingaService::create((array) $service, $db);
+ $new->set('service_set_id', $setId);
+ $new->store();
+ }
+ }
+
+ foreach ($existingServices as $existing) {
+ if (!in_array($existing->getObjectName(), $serviceNames)) {
+ $existing->delete();
+ }
+ }
+
+ return $object;
+ }
+
+ public function beforeDelete()
+ {
+ // check if this is a template, or directly assigned to a host
+ if ($this->get('host_id') === null) {
+ // find all host sets and delete them
+ foreach ($this->fetchHostSets() as $set) {
+ $set->delete();
+ }
+ }
+
+ parent::beforeDelete();
+ }
+
+ /**
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function onDelete()
+ {
+ $hostId = $this->get('host_id');
+ if ($hostId) {
+ $deleteIds = [];
+ foreach ($this->getServiceObjects() as $service) {
+ if ($idToDelete = $service->get('id')) {
+ $deleteIds[] = (int) $idToDelete;
+ }
+ }
+
+ if (! empty($deleteIds)) {
+ $db = $this->getDb();
+ $db->delete(
+ 'icinga_host_service_blacklist',
+ $db->quoteInto(
+ sprintf('host_id = %s AND service_id IN (?)', $hostId),
+ $deleteIds
+ )
+ );
+ }
+ }
+
+ parent::onDelete();
+ }
+
+ /**
+ * @param IcingaConfig $config
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function renderToConfig(IcingaConfig $config)
+ {
+ // always print the header, so you have minimal info present
+ $file = $this->getConfigFileWithHeader($config);
+
+ if ($this->get('assign_filter') === null && $this->isTemplate()) {
+ return;
+ }
+
+ if ($config->isLegacy()) {
+ $this->renderToLegacyConfig($config);
+ return;
+ }
+
+ $services = $this->getServiceObjects();
+ if (empty($services)) {
+ return;
+ }
+
+ // Loop over all services belonging to this set
+ // add our assign rules and then add the service to the config
+ // eventually clone them beforehand to not get into trouble with caches
+ // figure out whether we might need a zone property
+ foreach ($services as $service) {
+ if ($filter = $this->get('assign_filter')) {
+ $service->set('object_type', 'apply');
+ $service->set('assign_filter', $filter);
+ } elseif ($hostId = $this->get('host_id')) {
+ $host = $this->getRelatedObject('host', $hostId)->getObjectName();
+ if (in_array($host, $this->getBlacklistedHostnames($service))) {
+ continue;
+ }
+ $service->set('object_type', 'object');
+ $service->set('use_var_overrides', 'y');
+ $service->set('host_id', $hostId);
+ } else {
+ // Service set template without assign filter or host
+ continue;
+ }
+
+ $this->copyVarsToService($service);
+ $file->addObject($service);
+ }
+ }
+
+ /**
+ * @return array
+ */
+ public function getBlacklistedHostnames($service)
+ {
+ // Hint: if ($this->isApplyRule()) would be nice, but apply rules are
+ // not enough, one might want to blacklist single services from Sets
+ // assigned to single Hosts.
+ if (PrefetchCache::shouldBeUsed()) {
+ $lookup = PrefetchCache::instance()->hostServiceBlacklist();
+ } else {
+ $lookup = new HostServiceBlacklist($this->getConnection());
+ }
+
+ return $lookup->getBlacklistedHostnamesForService($service);
+ }
+
+ protected function getConfigFileWithHeader(IcingaConfig $config)
+ {
+ $file = $config->configFile(
+ 'zones.d/' . $this->getRenderingZone($config) . '/servicesets'
+ );
+
+ $file->addContent($this->getConfigHeaderComment($config));
+ return $file;
+ }
+
+ protected function getConfigHeaderComment(IcingaConfig $config)
+ {
+ $name = $this->getObjectName();
+ $assign = $this->get('assign_filter');
+
+ if ($config->isLegacy()) {
+ if ($assign !== null) {
+ return "## applied Service Set '${name}'\n\n";
+ } else {
+ return "## Service Set '${name}' on this host\n\n";
+ }
+ } else {
+ $comment = [
+ "Service Set: ${name}",
+ ];
+
+ if (($host = $this->get('host')) !== null) {
+ $comment[] = 'on host ' . $host;
+ }
+
+ if (($description = $this->get('description')) !== null) {
+ $comment[] = '';
+ foreach (preg_split('~\\n~', $description) as $line) {
+ $comment[] = $line;
+ }
+ }
+
+ if ($assign !== null) {
+ $comment[] = '';
+ $comment[] = trim($this->renderAssign_Filter());
+ }
+
+ return "/**\n * " . join("\n * ", $comment) . "\n */\n\n";
+ }
+ }
+
+ public function copyVarsToService(IcingaService $service)
+ {
+ $serviceVars = $service->vars();
+
+ foreach ($this->vars() as $k => $var) {
+ $serviceVars->$k = $var;
+ }
+
+ return $this;
+ }
+
+ /**
+ * @param IcingaConfig $config
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function renderToLegacyConfig(IcingaConfig $config)
+ {
+ if ($this->get('assign_filter') === null && $this->isTemplate()) {
+ return;
+ }
+
+ // evaluate my assign rules once, get related hosts
+ // Loop over all services belonging to this set
+ // generate every service with host_name host1,host2... -> not yet. And Zones?
+
+ $conn = $this->getConnection();
+
+ // Delegating this to the service would look, but this way it's faster
+ if ($filter = $this->get('assign_filter')) {
+ $filter = Filter::fromQueryString($filter);
+
+ $hostnames = HostApplyMatches::forFilter($filter, $conn);
+ } else {
+ $hostnames = array($this->getRelated('host')->getObjectName());
+ }
+
+ $blacklists = [];
+
+ foreach ($this->mapHostsToZones($hostnames) as $zone => $names) {
+ $file = $config->configFile('director/' . $zone . '/servicesets', '.cfg');
+ $file->addContent($this->getConfigHeaderComment($config));
+
+ foreach ($this->getServiceObjects() as $service) {
+ $object_name = $service->getObjectName();
+
+ if (! array_key_exists($object_name, $blacklists)) {
+ $blacklists[$object_name] = $service->getBlacklistedHostnames();
+ }
+
+ // check if all hosts in the zone ignore this service
+ $zoneNames = array_diff($names, $blacklists[$object_name]);
+
+ $disabled = [];
+ foreach ($zoneNames as $name) {
+ if (IcingaHost::load($name, $this->getConnection())->isDisabled()) {
+ $disabled[] = $name;
+ }
+ }
+ $zoneNames = array_diff($zoneNames, $disabled);
+
+ if (empty($zoneNames)) {
+ continue;
+ }
+
+ $service->set('object_type', 'object');
+ $service->set('host_id', $names);
+
+ $this->copyVarsToService($service);
+
+ $file->addLegacyObject($service);
+ }
+ }
+ }
+
+ public function getRenderingZone(IcingaConfig $config = null)
+ {
+ if ($this->get('host_id') === null) {
+ if ($hostname = $this->get('host')) {
+ $host = IcingaHost::load($hostname, $this->getConnection());
+ } else {
+ return $this->connection->getDefaultGlobalZoneName();
+ }
+ } else {
+ $host = $this->getRelatedObject('host', $this->get('host_id'));
+ }
+
+ return $host->getRenderingZone($config);
+ }
+
+ public function createWhere()
+ {
+ $where = parent::createWhere();
+ if (! $this->hasBeenLoadedFromDb()) {
+ if (null === $this->get('host_id') && null === $this->get('id')) {
+ $where .= " AND object_type = 'template'";
+ }
+ }
+
+ return $where;
+ }
+
+ /**
+ * @return IcingaService[]
+ */
+ public function fetchServices()
+ {
+ if ($store = self::$dbObjectStore) {
+ $uuid = $store->getBranch()->getUuid();
+ } else {
+ $uuid = null;
+ }
+ $builder = new ServiceSetQueryBuilder($this->getConnection(), $uuid);
+ return $builder->fetchServicesWithQuery($builder->selectServicesForSet($this));
+ }
+
+ /**
+ * Fetch IcingaServiceSet that are based on this set and added to hosts directly
+ *
+ * @return IcingaServiceSet[]
+ */
+ public function fetchHostSets()
+ {
+ $id = $this->get('id');
+ if ($id === null) {
+ return [];
+ }
+
+ $query = $this->db->select()
+ ->from(
+ ['o' => $this->table]
+ )->join(
+ ['ssi' => $this->table . '_inheritance'],
+ 'ssi.service_set_id = o.id',
+ []
+ )->where(
+ 'ssi.parent_service_set_id = ?',
+ $id
+ );
+
+ return static::loadAll($this->connection, $query);
+ }
+
+ /**
+ * @throws DuplicateKeyException
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ protected function beforeStore()
+ {
+ parent::beforeStore();
+
+ $name = $this->getObjectName();
+
+ if ($this->isObject() && $this->get('host_id') === null && $this->get('host') === null) {
+ throw new InvalidArgumentException(
+ 'A Service Set cannot be an object with no related host'
+ );
+ }
+ // checking if template object_name is unique
+ // TODO: Move to IcingaObject
+ if (! $this->hasBeenLoadedFromDb() && $this->isTemplate() && static::exists($name, $this->connection)) {
+ throw new DuplicateKeyException(
+ '%s template "%s" already existing in database!',
+ $this->getType(),
+ $name
+ );
+ }
+ }
+
+ public function toSingleIcingaConfig()
+ {
+ $config = parent::toSingleIcingaConfig();
+
+ try {
+ foreach ($this->fetchHostSets() as $set) {
+ $set->renderToConfig($config);
+ }
+ } catch (Exception $e) {
+ $config->configFile(
+ 'failed-to-render'
+ )->prepend(
+ "/** Failed to render this Service Set **/\n"
+ . '/* ' . $e->getMessage() . ' */'
+ );
+ }
+
+ return $config;
+ }
+}
diff --git a/library/Director/Objects/IcingaServiceSetAssignment.php b/library/Director/Objects/IcingaServiceSetAssignment.php
new file mode 100644
index 0000000..4a6ebbc
--- /dev/null
+++ b/library/Director/Objects/IcingaServiceSetAssignment.php
@@ -0,0 +1,20 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+class IcingaServiceSetAssignment extends IcingaObject
+{
+ protected $table = 'icinga_service_set_assignment';
+
+ protected $keyName = 'id';
+
+ protected $defaultProperties = array(
+ 'id' => null,
+ 'service_set_id' => null,
+ 'filter_string' => null,
+ );
+
+ protected $relations = array(
+ 'service_set' => 'IcingaServiceSet',
+ );
+}
diff --git a/library/Director/Objects/IcingaServiceVar.php b/library/Director/Objects/IcingaServiceVar.php
new file mode 100644
index 0000000..0b855b2
--- /dev/null
+++ b/library/Director/Objects/IcingaServiceVar.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+class IcingaServiceVar extends IcingaObject
+{
+ protected $keyName = array('service_id', 'varname');
+
+ protected $table = 'icinga_service_var';
+
+ protected $defaultProperties = array(
+ 'service_id' => null,
+ 'varname' => null,
+ 'varvalue' => null,
+ 'format' => null,
+ );
+
+ public function onInsert()
+ {
+ }
+
+ public function onUpdate()
+ {
+ }
+
+ public function onDelete()
+ {
+ }
+}
diff --git a/library/Director/Objects/IcingaTemplateChoice.php b/library/Director/Objects/IcingaTemplateChoice.php
new file mode 100644
index 0000000..1a1be90
--- /dev/null
+++ b/library/Director/Objects/IcingaTemplateChoice.php
@@ -0,0 +1,321 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Icinga\Exception\ProgrammingError;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\DirectorObject\Automation\ExportInterface;
+use Icinga\Module\Director\Exception\DuplicateKeyException;
+use Icinga\Module\Director\Web\Form\QuickForm;
+
+class IcingaTemplateChoice extends IcingaObject implements ExportInterface
+{
+ protected $objectTable;
+
+ protected $defaultProperties = [
+ 'id' => null,
+ 'object_name' => null,
+ 'description' => null,
+ 'min_required' => 0,
+ 'max_allowed' => 1,
+ 'required_template_id' => null,
+ 'allowed_roles' => null,
+ ];
+
+ private $choices;
+
+ private $newChoices;
+
+ public function getObjectShortTableName()
+ {
+ return substr(substr($this->table, 0, -16), 7);
+ }
+
+ public function getUniqueIdentifier()
+ {
+ return $this->getObjectName();
+ }
+
+ /**
+ * @param $plain
+ * @param Db $db
+ * @param bool $replace
+ * @return IcingaTemplateChoice
+ * @throws DuplicateKeyException
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public static function import($plain, Db $db, $replace = false)
+ {
+ $properties = (array) $plain;
+ if (isset($properties['originalId'])) {
+ unset($properties['originalId']);
+ }
+ $name = $properties['object_name'];
+ $key = $name;
+
+ if ($replace && static::exists($key, $db)) {
+ $object = static::load($key, $db);
+ } elseif (static::exists($key, $db)) {
+ throw new DuplicateKeyException(
+ 'Template Choice "%s" already exists',
+ $name
+ );
+ } else {
+ $object = static::create([], $db);
+ }
+
+ $object->setProperties($properties);
+
+ return $object;
+ }
+
+ /**
+ * @deprecated please use \Icinga\Module\Director\Data\Exporter
+ * @return array|object|\stdClass
+ */
+ public function export()
+ {
+ $plain = (object) $this->getProperties();
+ $plain->originalId = $plain->id;
+ unset($plain->id);
+ $requiredId = $plain->required_template_id;
+ unset($plain->required_template_id);
+ if ($requiredId) {
+ $db = $this->getDb();
+ $query = $db->select()
+ ->from(['o' => $this->getObjectTableName()], 'o.object_name')->where("o.object_type = 'template'")
+ ->where('o.id = ?', $this->get('id'));
+ $plain->required_template = $db->fetchOne($query);
+ }
+
+ $plain->members = array_values($this->getMembers());
+
+ return $plain;
+ }
+
+ public function isMainChoice()
+ {
+ return $this->hasBeenLoadedFromDb()
+ && $this->connection->settings()->get('main_host_choice');
+ }
+
+ public function getObjectTableName()
+ {
+ return substr($this->table, 0, -16);
+ }
+
+ /**
+ * @param QuickForm $form
+ * @param array $imports
+ * @param string $namePrefix
+ * @return \Zend_Form_Element
+ * @throws \Zend_Form_Exception
+ */
+ public function createFormElement(QuickForm $form, $imports = [], $namePrefix = 'choice')
+ {
+ $required = $this->isRequired() && !$this->isTemplate();
+ $type = $this->allowsMultipleChoices() ? 'multiselect' : 'select';
+ $choices = $this->enumChoices();
+
+ $chosen = [];
+ foreach ($imports as $import) {
+ if (array_key_exists($import, $choices)) {
+ $chosen[] = $import;
+ }
+ }
+
+ $attributes = [
+ 'label' => $this->getObjectName(),
+ 'description' => $this->get('description'),
+ 'required' => $required,
+ 'ignore' => true,
+ 'value' => $chosen,
+ 'multiOptions' => $form->optionalEnum($choices),
+ 'class' => 'autosubmit'
+ ];
+
+ // unused
+ if ($type === 'extensibleSet') {
+ $attributes['sorted'] = true;
+ }
+
+ $key = $namePrefix . $this->get('id');
+ return $form->createElement($type, $key, $attributes);
+ }
+
+ public function isRequired()
+ {
+ return (int) $this->get('min_required') > 0;
+ }
+
+ public function allowsMultipleChoices()
+ {
+ return (int) $this->get('max_allowed') > 1;
+ }
+
+ public function hasBeenModified()
+ {
+ if ($this->newChoices !== null && $this->choices !== $this->newChoices) {
+ return true;
+ }
+
+ return parent::hasBeenModified();
+ }
+
+ public function getMembers()
+ {
+ return $this->enumChoices();
+ }
+
+ public function setMembers($members)
+ {
+ if (empty($members)) {
+ $this->newChoices = array();
+ return $this;
+ }
+ $db = $this->getDb();
+ $query = $db->select()->from(
+ ['o' => $this->getObjectTableName()],
+ ['o.id', 'o.object_name']
+ )->where("o.object_type = 'template'")
+ ->where('o.object_name IN (?)', $members)
+ ->order('o.object_name');
+
+ $this->newChoices = $db->fetchPairs($query);
+ return $this;
+ }
+
+ public function getChoices()
+ {
+ if ($this->newChoices !== null) {
+ return $this->newChoices;
+ }
+
+ if ($this->choices === null) {
+ $this->choices = $this->fetchChoices();
+ }
+
+ return $this->choices;
+ }
+
+ public function fetchChoices()
+ {
+ if ($this->hasBeenLoadedFromDb()) {
+ $db = $this->getDb();
+ $query = $db->select()->from(
+ ['o' => $this->getObjectTableName()],
+ ['o.id', 'o.object_name']
+ )->where("o.object_type = 'template'")
+ ->where('o.template_choice_id = ?', $this->get('id'))
+ ->order('o.object_name');
+
+ return $db->fetchPairs($query);
+ } else {
+ return [];
+ }
+ }
+
+ public function enumChoices()
+ {
+ $choices = $this->getChoices();
+ return array_combine($choices, $choices);
+ }
+
+ /**
+ * @throws \Zend_Db_Adapter_Exception
+ */
+ public function onStore()
+ {
+ parent::onStore();
+ if ($this->newChoices !== $this->choices) {
+ $this->storeChoices();
+ }
+ }
+
+ /**
+ * @throws \Zend_Db_Adapter_Exception
+ */
+ protected function storeChoices()
+ {
+ $id = $this->getProperty('id');
+ $db = $this->getDb();
+ $ids = array_keys($this->newChoices);
+ $table = $this->getObjectTableName();
+
+ if (empty($ids)) {
+ $db->update(
+ $table,
+ ['template_choice_id' => null],
+ $db->quoteInto(
+ sprintf('template_choice_id = %d', $id),
+ $ids
+ )
+ );
+ } else {
+ $db->update(
+ $table,
+ ['template_choice_id' => null],
+ $db->quoteInto(
+ sprintf('template_choice_id = %d AND id NOT IN (?)', $id),
+ $ids
+ )
+ );
+ $db->update(
+ $table,
+ ['template_choice_id' => $id],
+ $db->quoteInto('id IN (?)', $ids)
+ );
+ }
+ }
+
+ /**
+ * @param $roles
+ * @throws ProgrammingError
+ * @codingStandardsIgnoreStart
+ */
+ public function setAllowed_roles($roles)
+ {
+ // @codingStandardsIgnoreEnd
+ $key = 'allowed_roles';
+ if (is_array($roles)) {
+ $this->reallySet($key, json_encode($roles));
+ } elseif (null === $roles) {
+ $this->reallySet($key, null);
+ } else {
+ throw new ProgrammingError(
+ 'Expected array or null for allowed_roles, got %s',
+ var_export($roles, 1)
+ );
+ }
+ }
+
+ /**
+ * @return array|null
+ * @codingStandardsIgnoreStart
+ */
+ public function getAllowed_roles()
+ {
+ // @codingStandardsIgnoreEnd
+
+ // Might be removed once all choice types have allowed_roles
+ if (! array_key_exists('allowed_roles', $this->properties)) {
+ return null;
+ }
+
+ $roles = $this->getProperty('allowed_roles');
+ if (is_string($roles)) {
+ return json_decode($roles);
+ } else {
+ return $roles;
+ }
+ }
+
+ /**
+ * @param $type
+ * @codingStandardsIgnoreStart
+ */
+ public function setObject_type($type)
+ {
+ // @codingStandardsIgnoreEnd
+ }
+}
diff --git a/library/Director/Objects/IcingaTemplateChoiceHost.php b/library/Director/Objects/IcingaTemplateChoiceHost.php
new file mode 100644
index 0000000..10ddedd
--- /dev/null
+++ b/library/Director/Objects/IcingaTemplateChoiceHost.php
@@ -0,0 +1,14 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+class IcingaTemplateChoiceHost extends IcingaTemplateChoice
+{
+ protected $table = 'icinga_host_template_choice';
+
+ protected $objectTable = 'icinga_host';
+
+ protected $relations = array(
+ 'required_template' => 'IcingaHost',
+ );
+}
diff --git a/library/Director/Objects/IcingaTemplateChoiceService.php b/library/Director/Objects/IcingaTemplateChoiceService.php
new file mode 100644
index 0000000..5cdb43e
--- /dev/null
+++ b/library/Director/Objects/IcingaTemplateChoiceService.php
@@ -0,0 +1,14 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+class IcingaTemplateChoiceService extends IcingaTemplateChoice
+{
+ protected $table = 'icinga_service_template_choice';
+
+ protected $objectTable = 'icinga_service';
+
+ protected $relations = array(
+ 'required_template' => 'IcingaService',
+ );
+}
diff --git a/library/Director/Objects/IcingaTemplateResolver.php b/library/Director/Objects/IcingaTemplateResolver.php
new file mode 100644
index 0000000..61122a0
--- /dev/null
+++ b/library/Director/Objects/IcingaTemplateResolver.php
@@ -0,0 +1,479 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Icinga\Application\Benchmark;
+use Icinga\Exception\NotFoundError;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Exception\NestingError;
+
+// TODO: move the 'type' layer to another class
+class IcingaTemplateResolver
+{
+ /** @var IcingaObject */
+ protected $object;
+
+ /** @var Db */
+ protected $connection;
+
+ /** @var \Zend_Db_Adapter_Abstract */
+ protected $db;
+
+ protected $type;
+
+ protected static $templates = array();
+
+ protected static $idIdx = array();
+
+ protected static $reverseIdIdx = array();
+
+ protected static $nameIdx = array();
+
+ protected static $idToName = array();
+
+ protected static $nameToId = array();
+
+ public function __construct(IcingaObject $object)
+ {
+ $this->setObject($object);
+ }
+
+ /**
+ * Set a specific object for this resolver instance
+ */
+ public function setObject(IcingaObject $object)
+ {
+ $this->object = $object;
+ $this->type = $object->getShortTableName();
+ $this->table = $object->getTableName();
+ $this->connection = $object->getConnection();
+ $this->db = $this->connection->getDbAdapter();
+
+ return $this;
+ }
+
+ /**
+ * Forget all template relation of the given object type
+ *
+ * @return self
+ */
+ public function clearCache()
+ {
+ unset(self::$templates[$this->type]);
+ return $this;
+ }
+
+ /**
+ * Fetch direct parents
+ *
+ * return IcingaObject[]
+ */
+ public function fetchParents()
+ {
+ // TODO: involve lookup cache
+ $res = array();
+ $class = $this->object;
+ foreach ($this->listParentIds() as $id) {
+ $object = $class::loadWithAutoIncId($id, $this->connection);
+ $res[$object->object_name] = $object;
+ }
+
+ return $res;
+ }
+
+ public function listParentIds($id = null)
+ {
+ $this->requireTemplates();
+
+ if ($id === null) {
+ $object = $this->object;
+
+ if ($object->hasBeenLoadedFromDb()) {
+ if ($object->gotImports() && $object->imports()->hasBeenModified()) {
+ return $this->listUnstoredParentIds();
+ }
+
+ $id = $object->id;
+ } else {
+ return $this->listUnstoredParentIds();
+ }
+ }
+
+ $type = $this->type;
+
+ if (array_key_exists($id, self::$idIdx[$type])) {
+ return array_keys(self::$idIdx[$type][$id]);
+ }
+
+ return array();
+ }
+
+ protected function listUnstoredParentIds()
+ {
+ return $this->getIdsForNames($this->listUnstoredParentNames());
+ }
+
+ protected function listUnstoredParentNames()
+ {
+ return $this->object->imports()->listImportNames();
+ }
+
+ public function listParentNames($name = null)
+ {
+ $this->requireTemplates();
+
+ if ($name === null) {
+ $object = $this->object;
+
+ if ($object->hasBeenLoadedFromDb()) {
+ if ($object->gotImports() && $object->imports()->hasBeenModified()) {
+ return $this->listUnstoredParentNames();
+ }
+
+ $name = $object->object_name;
+ } else {
+ return $this->listUnstoredParentNames();
+ }
+ }
+
+ $type = $this->type;
+
+ if (array_key_exists($name, self::$nameIdx[$type])) {
+ return array_keys(self::$nameIdx[$type][$name]);
+ }
+
+ return array();
+ }
+
+ public function fetchResolvedParents()
+ {
+ if ($this->object->hasBeenLoadedFromDb()) {
+ return $this->fetchObjectsById($this->listResolvedParentIds());
+ }
+
+ $objects = array();
+ foreach ($this->object->imports()->getObjects() as $parent) {
+ $objects += $parent->templateResolver()->fetchResolvedParents();
+ }
+
+ return $objects;
+ }
+
+ public function listResolvedParentIds()
+ {
+ $this->requireTemplates();
+ return $this->resolveParentIds();
+ }
+
+ /**
+ * TODO: unfinished and not used currently
+ *
+ * @return array
+ */
+ public function listResolvedParentNames()
+ {
+ $this->requireTemplates();
+ if (array_key_exists($name, self::$nameIdx[$type])) {
+ return array_keys(self::$nameIdx[$type][$name]);
+ }
+
+ return $this->resolveParentNames($this->object->object_name);
+ }
+
+ public function listParentsById($id)
+ {
+ return $this->getNamesForIds($this->resolveParentIds($id));
+ }
+
+ public function listParentsByName($name)
+ {
+ return $this->resolveParentNames($name);
+ }
+
+ /**
+ * Gives a list of all object ids met when walking through ancestry
+ *
+ * Tree is walked in import order, duplicates are preserved, the given
+ * objectId is added last
+ *
+ * @param int $objectId
+ *
+ * @return array
+ */
+ public function listFullInheritancePathIds($objectId = null)
+ {
+ $parentIds = $this->listParentIds($objectId);
+ $ids = array();
+
+ foreach ($parentIds as $parentId) {
+ foreach ($this->listFullInheritancePathIds($parentId) as $id) {
+ $ids[] = $id;
+ }
+
+ $ids[] = $parentId;
+ }
+
+ $object = $this->object;
+ if ($objectId === null && $object->hasBeenLoadedFromDb()) {
+ $ids[] = $object->id;
+ }
+
+ return $ids;
+ }
+
+ public function listChildren($objectId = null)
+ {
+ if ($objectId === null) {
+ $objectId = $this->object->id;
+ }
+
+ if (array_key_exists($objectId, self::$reverseIdIdx[$this->type])) {
+ return self::$reverseIdIdx[$this->type][$objectId];
+ } else {
+ return array();
+ }
+ }
+
+ public function listChildIds($objectId = null)
+ {
+ return array_keys($this->listChildren($objectId));
+ }
+
+ public function listDescendantIds($objectId = null)
+ {
+ if ($objectId === null) {
+ $objectId = $this->object->id;
+ }
+ }
+
+ public function listInheritancePathIds($objectId = null)
+ {
+ return $this->uniquePathIds($this->listFullInheritancePathIds($objectId));
+ }
+
+ public function uniquePathIds(array $ids)
+ {
+ $single = array();
+ foreach (array_reverse($ids) as $id) {
+ if (array_key_exists($id, $single)) {
+ continue;
+ }
+ $single[$id] = $id;
+ }
+
+ return array_reverse(array_keys($single));
+ }
+
+ protected function resolveParentNames($name, &$list = array(), $path = array())
+ {
+ $this->assertNotInList($name, $path);
+ $path[$name] = true;
+ foreach ($this->listParentNames($name) as $parent) {
+ $list[$parent] = true;
+ $this->resolveParentNames($parent, $list, $path);
+ unset($list[$parent]);
+ $list[$parent] = true;
+ }
+
+ return array_keys($list);
+ }
+
+ protected function resolveParentIds($id = null, &$list = array(), $path = array())
+ {
+ if ($id === null) {
+ if ($check = $this->object->id) {
+ $this->assertNotInList($check, $path);
+ $path[$check] = true;
+ }
+ } else {
+ $this->assertNotInList($id, $path);
+ $path[$id] = true;
+ }
+
+ foreach ($this->listParentIds($id) as $parent) {
+ $list[$parent] = true;
+ $this->resolveParentIds($parent, $list, $path);
+ unset($list[$parent]);
+ $list[$parent] = true;
+ }
+
+ return array_keys($list);
+ }
+
+ protected function assertNotInList($id, &$list)
+ {
+ if (array_key_exists($id, $list)) {
+ $list = array_keys($list);
+ $list[] = $id;
+ if (is_numeric($id)) {
+ throw new NestingError(
+ 'Loop detected: %s',
+ implode(' -> ', $this->getNamesForIds($list))
+ );
+ } else {
+ throw new NestingError(
+ 'Loop detected: %s',
+ implode(' -> ', $list)
+ );
+ }
+ }
+ }
+
+ protected function getNamesForIds($ids)
+ {
+ $names = array();
+ foreach ($ids as $id) {
+ $names[] = $this->getNameForId($id);
+ }
+
+ return $names;
+ }
+
+ protected function getNameForId($id)
+ {
+ return self::$idToName[$this->type][$id];
+ }
+
+ protected function getIdsForNames($names)
+ {
+ $this->requireTemplates();
+ $ids = array();
+ foreach ($names as $name) {
+ $ids[] = $this->getIdForName($name);
+ }
+
+ return $ids;
+ }
+
+ protected function getIdForName($name)
+ {
+ if (! array_key_exists($name, self::$nameToId[$this->type])) {
+ throw new NotFoundError('There is no such import: "%s"', $name);
+ }
+
+ return self::$nameToId[$this->type][$name];
+ }
+
+ protected function fetchObjectsById($ids)
+ {
+ $class = $this->object;
+ $connection = $this->connection;
+ $res = array();
+
+ foreach ($ids as $id) {
+ $res[] = $class::loadWithAutoIncId($id, $connection);
+ }
+
+ return $res;
+ }
+
+ protected function requireTemplates()
+ {
+ if (! array_key_exists($this->type, self::$templates)) {
+ $this->prepareLookupTables();
+ }
+
+ return $this;
+ }
+
+ protected function prepareLookupTables()
+ {
+ $type = $this->type;
+
+ Benchmark::measure("Preparing '$type' TemplateResolver lookup tables");
+ $templates = $this->fetchTemplates();
+
+ $ids = array();
+ $reverseIds = array();
+ $names = array();
+ $idToName = array();
+ $nameToId = array();
+
+ foreach ($templates as $row) {
+ $id = $row->id;
+ $idToName[$id] = $row->name;
+ $nameToId[$row->name] = $id;
+
+ if ($row->parent_id === null) {
+ continue;
+ }
+ $parentId = $row->parent_id;
+ $parentName = $row->parent_name;
+
+ if (array_key_exists($id, $ids)) {
+ $ids[$id][$parentId] = $parentName;
+ $names[$row->name][$parentName] = $row->parent_id;
+ } else {
+ $ids[$id] = array(
+ $parentId => $parentName
+ );
+
+ $names[$row->name] = array(
+ $parentName => $parentId
+ );
+ }
+
+ if (! array_key_exists($parentId, $reverseIds)) {
+ $reverseIds[$parentId] = array();
+ }
+ $reverseIds[$parentId][$id] = $row->name;
+ }
+
+ self::$idIdx[$type] = $ids;
+ self::$reverseIdIdx[$type] = $reverseIds;
+ self::$nameIdx[$type] = $names;
+ self::$templates[$type] = $templates; // TODO: this is unused, isn't it?
+ self::$idToName[$type] = $idToName;
+ self::$nameToId[$type] = $nameToId;
+ Benchmark::measure('Preparing TemplateResolver lookup tables');
+ }
+
+ protected function fetchTemplates()
+ {
+ $db = $this->db;
+ $type = $this->type;
+ $table = $this->object->getTableName();
+
+ $query = $db->select()->from(
+ array('o' => $table),
+ array(
+ 'id' => 'o.id',
+ 'name' => 'o.object_name',
+ 'parent_id' => 'p.id',
+ 'parent_name' => 'p.object_name',
+ )
+ )->joinLeft(
+ array('i' => $table . '_inheritance'),
+ 'o.id = i.' . $type . '_id',
+ array()
+ )->joinLeft(
+ array('p' => $table),
+ 'p.id = i.parent_' . $type . '_id',
+ array()
+ )->order('o.id')->order('i.weight');
+
+ return $db->fetchAll($query);
+ }
+
+ public function __destruct()
+ {
+ unset($this->connection);
+ unset($this->db);
+ unset($this->object);
+ }
+
+ public function refreshObject(IcingaObject $object)
+ {
+ $type = $object->getShortTableName();
+ $name = $object->getObjectName();
+ $parentNames = $object->imports;
+ self::$nameIdx[$type][$name] = $parentNames;
+ if ($object->hasBeenLoadedFromDb()) {
+ $id = $object->getProperty('id');
+ self::$idIdx[$type][$id] = $this->getIdsForNames($parentNames);
+ self::$idToName[$type][$id] = $name;
+ self::$nameToId[$type][$name] = $id;
+ }
+ return $this;
+ }
+}
diff --git a/library/Director/Objects/IcingaTimePeriod.php b/library/Director/Objects/IcingaTimePeriod.php
new file mode 100644
index 0000000..1232366
--- /dev/null
+++ b/library/Director/Objects/IcingaTimePeriod.php
@@ -0,0 +1,190 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\DirectorObject\Automation\ExportInterface;
+use Icinga\Module\Director\Exception\DuplicateKeyException;
+
+class IcingaTimePeriod extends IcingaObject implements ExportInterface
+{
+ protected $table = 'icinga_timeperiod';
+
+ protected $uuidColumn = 'uuid';
+
+ protected $defaultProperties = [
+ 'id' => null,
+ 'uuid' => null,
+ 'zone_id' => null,
+ 'object_name' => null,
+ 'object_type' => null,
+ 'disabled' => 'n',
+ 'prefer_includes' => null,
+ 'display_name' => null,
+ 'update_method' => null,
+ ];
+
+ protected $booleans = [
+ 'prefer_includes' => 'prefer_includes',
+ ];
+
+ protected $supportsImports = true;
+
+ protected $supportsRanges = true;
+
+ protected $supportedInLegacy = true;
+
+ protected $relations = array(
+ 'zone' => 'IcingaZone',
+ );
+
+ protected $multiRelations = [
+ 'includes' => [
+ 'relatedObjectClass' => 'IcingaTimeperiod',
+ 'relatedShortName' => 'include',
+ ],
+ 'excludes' => [
+ 'relatedObjectClass' => 'IcingaTimeperiod',
+ 'relatedShortName' => 'exclude',
+ 'legacyPropertyName' => 'exclude'
+ ],
+ ];
+
+ public function getUniqueIdentifier()
+ {
+ return $this->getObjectName();
+ }
+
+ /**
+ * @deprecated please use \Icinga\Module\Director\Data\Exporter
+ * @return object
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function export()
+ {
+ $props = (array) $this->toPlainObject();
+ ksort($props);
+
+ return (object) $props;
+ }
+
+ /**
+ * @param $plain
+ * @param Db $db
+ * @param bool $replace
+ * @return static
+ * @throws DuplicateKeyException
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public static function import($plain, Db $db, $replace = false)
+ {
+ $properties = (array) $plain;
+ $name = $properties['object_name'];
+ $key = $name;
+
+ if ($replace && static::exists($key, $db)) {
+ $object = static::load($key, $db);
+ } elseif (static::exists($key, $db)) {
+ throw new DuplicateKeyException(
+ 'Time Period "%s" already exists',
+ $name
+ );
+ } else {
+ $object = static::create([], $db);
+ }
+ $object->setProperties($properties);
+
+ return $object;
+ }
+
+ /**
+ * Render update property
+ *
+ * Avoid complaints for method names with underscore:
+ * @codingStandardsIgnoreStart
+ *
+ * @return string
+ */
+ public function renderUpdate_method()
+ {
+ // @codingStandardsIgnoreEnd
+ return '';
+ }
+
+ protected function renderObjectHeader()
+ {
+ return parent::renderObjectHeader()
+ . ' import "legacy-timeperiod"' . "\n";
+ }
+
+ protected function checkPeriodInRange($now, $name = null)
+ {
+ if ($name !== null) {
+ $period = static::load($name, $this->connection);
+ } else {
+ $period = $this;
+ }
+
+ foreach ($period->ranges()->getRanges() as $range) {
+ if ($range->isActive($now)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ public function isActive($now = null)
+ {
+ if ($now === null) {
+ $now = time();
+ }
+
+ $preferIncludes = $this->get('prefer_includes') !== 'n';
+
+ $active = $this->checkPeriodInRange($now);
+ $included = false;
+ $excluded = false;
+
+ $variants = [
+ 'includes' => &$included,
+ 'excludes' => &$excluded
+ ];
+
+ foreach ($variants as $key => &$var) {
+ foreach ($this->get($key) as $name) {
+ if ($this->checkPeriodInRange($now, $name)) {
+ $var = true;
+ break;
+ }
+ }
+ }
+
+ if ($preferIncludes) {
+ if ($included) {
+ return true;
+ } elseif ($excluded) {
+ return false;
+ } else {
+ return $active;
+ }
+ } else {
+ if ($excluded) {
+ return false;
+ } elseif ($included) {
+ return true;
+ } else {
+ return $active;
+ }
+ }
+
+ // TODO: no range currently means (and renders) "never", Icinga behaves
+ // different. Figure out whether and how we should support this
+ return false;
+ }
+
+ protected function prefersGlobalZone()
+ {
+ return true;
+ }
+}
diff --git a/library/Director/Objects/IcingaTimePeriodRange.php b/library/Director/Objects/IcingaTimePeriodRange.php
new file mode 100644
index 0000000..55c1a3e
--- /dev/null
+++ b/library/Director/Objects/IcingaTimePeriodRange.php
@@ -0,0 +1,88 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Icinga\Module\Director\Data\Db\DbObject;
+
+class IcingaTimePeriodRange extends DbObject
+{
+ protected $keyName = array('timeperiod_id', 'range_key', 'range_type');
+
+ protected $table = 'icinga_timeperiod_range';
+
+ protected $defaultProperties = array(
+ 'timeperiod_id' => null,
+ 'range_key' => null,
+ 'range_value' => null,
+ 'range_type' => 'include',
+ 'merge_behaviour' => 'set',
+ );
+
+ public function isActive($now = null)
+ {
+ if ($now === null) {
+ $now = time();
+ }
+
+ if (false === ($weekDay = $this->getWeekDay($this->get('range_key')))) {
+ // TODO, dates are not yet supported
+ return false;
+ }
+
+ if ((int) date('w', $now) !== $weekDay) {
+ return false;
+ }
+
+ $timeRanges = preg_split('/\s*,\s*/', $this->get('range_value'), -1, PREG_SPLIT_NO_EMPTY);
+ foreach ($timeRanges as $timeRange) {
+ if ($this->timeRangeIsActive($timeRange, $now)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ protected function timeRangeIsActive($rangeString, $now)
+ {
+ $hBegin = $mBegin = $hEnd = $mEnd = null;
+ if (sscanf($rangeString, '%2d:%2d-%2d:%2d', $hBegin, $mBegin, $hEnd, $mEnd) === 4) {
+ if ($this->timeFromHourMin($hBegin, $mBegin, $now) <= $now
+ && $this->timeFromHourMin($hEnd, $mEnd, $now) >= $now
+ ) {
+ return true;
+ }
+ } else {
+ // TODO: throw exception?
+ }
+
+ return false;
+ }
+
+ protected function timeFromHourMin($hour, $min, $now)
+ {
+ return strtotime(sprintf('%s %02d:%02d:00', date('Y-m-d', $now), $hour, $min));
+ }
+
+ protected function getWeekDay($day)
+ {
+ switch ($day) {
+ case 'sunday':
+ return 0;
+ case 'monday':
+ return 1;
+ case 'tuesday':
+ return 2;
+ case 'wednesday':
+ return 3;
+ case 'thursday':
+ return 4;
+ case 'friday':
+ return 5;
+ case 'saturday':
+ return 6;
+ }
+
+ return false;
+ }
+}
diff --git a/library/Director/Objects/IcingaTimePeriodRanges.php b/library/Director/Objects/IcingaTimePeriodRanges.php
new file mode 100644
index 0000000..b18437d
--- /dev/null
+++ b/library/Director/Objects/IcingaTimePeriodRanges.php
@@ -0,0 +1,35 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Countable;
+use Iterator;
+use Icinga\Module\Director\IcingaConfig\IcingaConfigRenderer;
+use Icinga\Module\Director\IcingaConfig\IcingaLegacyConfigHelper as c1;
+
+class IcingaTimePeriodRanges extends IcingaRanges implements Iterator, Countable, IcingaConfigRenderer
+{
+ protected $rangeClass = IcingaTimePeriodRange::class;
+ protected $objectIdColumn = 'timeperiod_id';
+
+ public function toLegacyConfigString()
+ {
+ if (empty($this->ranges) && $this->object->isTemplate()) {
+ return '';
+ }
+
+ $out = '';
+
+ foreach ($this->ranges as $range) {
+ $out .= c1::renderKeyValue(
+ $range->get('range_key'),
+ $range->get('range_value')
+ );
+ }
+ if ($out !== '') {
+ $out = "\n".$out;
+ }
+
+ return $out;
+ }
+}
diff --git a/library/Director/Objects/IcingaUser.php b/library/Director/Objects/IcingaUser.php
new file mode 100644
index 0000000..394e849
--- /dev/null
+++ b/library/Director/Objects/IcingaUser.php
@@ -0,0 +1,92 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\DirectorObject\Automation\ExportInterface;
+use Icinga\Module\Director\Exception\DuplicateKeyException;
+
+class IcingaUser extends IcingaObject implements ExportInterface
+{
+ protected $table = 'icinga_user';
+
+ protected $defaultProperties = array(
+ 'id' => null,
+ 'uuid' => null,
+ 'object_name' => null,
+ 'object_type' => null,
+ 'disabled' => 'n',
+ 'display_name' => null,
+ 'email' => null,
+ 'pager' => null,
+ 'enable_notifications' => null,
+ 'period_id' => null,
+ 'zone_id' => null,
+ );
+
+ protected $uuidColumn = 'uuid';
+
+ protected $supportsGroups = true;
+
+ protected $supportsCustomVars = true;
+
+ protected $supportsFields = true;
+
+ protected $supportsImports = true;
+
+ protected $booleans = array(
+ 'enable_notifications' => 'enable_notifications'
+ );
+
+ protected $relatedSets = array(
+ 'states' => 'StateFilterSet',
+ 'types' => 'TypeFilterSet',
+ );
+
+ protected $relations = array(
+ 'period' => 'IcingaTimePeriod',
+ 'zone' => 'IcingaZone',
+ );
+
+ public function export()
+ {
+ return ImportExportHelper::simpleExport($this);
+ }
+
+ /**
+ * @param $plain
+ * @param Db $db
+ * @param bool $replace
+ * @return IcingaUser
+ * @throws DuplicateKeyException
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public static function import($plain, Db $db, $replace = false)
+ {
+ $properties = (array) $plain;
+ $key = $properties['object_name'];
+
+ if ($replace && static::exists($key, $db)) {
+ $object = static::load($key, $db);
+ } elseif (static::exists($key, $db)) {
+ throw new DuplicateKeyException(
+ 'Cannot import, %s "%s" already exists',
+ static::create([])->getShortTableName(),
+ $key
+ );
+ } else {
+ $object = static::create([], $db);
+ }
+
+ // $object->newFields = $properties['fields'];
+ unset($properties['fields']);
+ $object->setProperties($properties);
+
+ return $object;
+ }
+
+ public function getUniqueIdentifier()
+ {
+ return $this->getObjectName();
+ }
+}
diff --git a/library/Director/Objects/IcingaUserField.php b/library/Director/Objects/IcingaUserField.php
new file mode 100644
index 0000000..4a6432c
--- /dev/null
+++ b/library/Director/Objects/IcingaUserField.php
@@ -0,0 +1,17 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+class IcingaUserField extends IcingaObjectField
+{
+ protected $keyName = array('user_id', 'datafield_id');
+
+ protected $table = 'icinga_user_field';
+
+ protected $defaultProperties = array(
+ 'user_id' => null,
+ 'datafield_id' => null,
+ 'is_required' => null,
+ 'var_filter' => null,
+ );
+}
diff --git a/library/Director/Objects/IcingaUserGroup.php b/library/Director/Objects/IcingaUserGroup.php
new file mode 100644
index 0000000..656235a
--- /dev/null
+++ b/library/Director/Objects/IcingaUserGroup.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+class IcingaUserGroup extends IcingaObjectGroup
+{
+ protected $table = 'icinga_usergroup';
+
+ protected $uuidColumn = 'uuid';
+
+ protected $defaultProperties = [
+ 'id' => null,
+ 'uuid' => null,
+ 'object_name' => null,
+ 'object_type' => null,
+ 'disabled' => 'n',
+ 'display_name' => null,
+ 'zone_id' => null,
+ ];
+
+ protected $relations = [
+ 'zone' => 'IcingaZone',
+ ];
+
+ protected function prefersGlobalZone()
+ {
+ return false;
+ }
+}
diff --git a/library/Director/Objects/IcingaVar.php b/library/Director/Objects/IcingaVar.php
new file mode 100644
index 0000000..10addf2
--- /dev/null
+++ b/library/Director/Objects/IcingaVar.php
@@ -0,0 +1,72 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Icinga\Module\Director\CustomVariable\CustomVariable;
+use Icinga\Module\Director\Data\Db\DbObject;
+use Icinga\Module\Director\Db;
+
+class IcingaVar extends DbObject
+{
+ protected $table = 'icinga_var';
+
+ protected $keyName = 'checksum';
+
+ /** @var CustomVariable */
+ protected $var;
+
+ protected $defaultProperties = [
+ 'checksum' => null,
+ 'rendered_checksum' => null,
+ 'varname' => null,
+ 'varvalue' => null,
+ 'rendered' => null
+ ];
+
+ protected $binaryProperties = [
+ 'checksum',
+ 'rendered_checksum',
+ ];
+
+ /**
+ * @param CustomVariable $customVar
+ * @param Db $db
+ *
+ * @return static
+ */
+ public static function forCustomVar(CustomVariable $customVar, Db $db)
+ {
+ $rendered = $customVar->render();
+
+ $var = static::create(array(
+ 'checksum' => $customVar->checksum(),
+ 'rendered_checksum' => sha1($rendered, true),
+ 'varname' => $customVar->getKey(),
+ 'varvalue' => $customVar->toJson(),
+ 'rendered' => $rendered,
+ ), $db);
+
+ $var->var = $customVar;
+
+ return $var;
+ }
+
+ /**
+ * @param CustomVariable $customVar
+ * @param Db $db
+ *
+ * @return static
+ * @throws \Icinga\Module\Director\Exception\DuplicateKeyException
+ */
+ public static function generateForCustomVar(CustomVariable $customVar, Db $db)
+ {
+ $var = static::forCustomVar($customVar, $db);
+ $var->store();
+ return $var;
+ }
+
+ protected function onInsert()
+ {
+ IcingaFlatVar::generateForCustomVar($this->var, $this->getConnection());
+ }
+}
diff --git a/library/Director/Objects/IcingaZone.php b/library/Director/Objects/IcingaZone.php
new file mode 100644
index 0000000..8d77e47
--- /dev/null
+++ b/library/Director/Objects/IcingaZone.php
@@ -0,0 +1,110 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\IcingaConfig\IcingaConfig;
+use Icinga\Module\Director\IcingaConfig\IcingaConfigHelper as c;
+
+class IcingaZone extends IcingaObject
+{
+ protected $table = 'icinga_zone';
+
+ protected $uuidColumn = 'uuid';
+
+ protected $defaultProperties = [
+ 'id' => null,
+ 'uuid' => null,
+ 'object_name' => null,
+ 'object_type' => null,
+ 'disabled' => 'n',
+ 'parent_id' => null,
+ 'is_global' => 'n',
+ ];
+
+ protected $booleans = [
+ // Global is a reserved word in SQL, column name was prefixed
+ 'is_global' => 'global'
+ ];
+
+ protected $relations = [
+ 'parent' => 'IcingaZone',
+ ];
+
+ protected $supportsImports = true;
+
+ protected static $globalZoneNames;
+
+ private $endpointList;
+
+ protected function renderCustomExtensions()
+ {
+ $endpoints = $this->listEndpoints();
+ if (empty($endpoints)) {
+ return '';
+ }
+
+ return c::renderKeyValue('endpoints', c::renderArray($endpoints));
+ }
+
+ public function isGlobal()
+ {
+ return $this->get('is_global') === 'y';
+ }
+
+ public static function zoneNameIsGlobal($name, Db $connection)
+ {
+ if (self::$globalZoneNames === null) {
+ $db = $connection->getDbAdapter();
+ self::setCachedGlobalZoneNames($db->fetchCol(
+ $db->select()->from('icinga_zone', 'object_name')->where('is_global = ?', 'y')
+ ));
+ }
+
+ return \in_array($name, self::$globalZoneNames);
+ }
+
+ public static function setCachedGlobalZoneNames($names)
+ {
+ self::$globalZoneNames = $names;
+ }
+
+ public function getRenderingZone(IcingaConfig $config = null)
+ {
+ // If the zone has a parent zone...
+ if ($this->get('parent_id')) {
+ // ...we render the zone object to the parent zone
+ return $this->get('parent');
+ } elseif ($this->get('is_global') === 'y') {
+ // ...additional global zones are rendered to our global zone...
+ return $this->connection->getDefaultGlobalZoneName();
+ } else {
+ // ...and all the other zones are rendered to our master zone
+ return $this->connection->getMasterZoneName();
+ }
+ }
+
+ public function setEndpointList($list)
+ {
+ $this->endpointList = $list;
+
+ return $this;
+ }
+
+ // TODO: Move this away, should be prefetchable:
+ public function listEndpoints()
+ {
+ $id = $this->get('id');
+ if ($id && $this->endpointList === null) {
+ $db = $this->getDb();
+ $query = $db->select()
+ ->from('icinga_endpoint', 'object_name')
+ ->where('zone_id = ?', $id)
+ ->order('object_name');
+
+ $this->endpointList = $db->fetchCol($query);
+ }
+
+ return $this->endpointList;
+ }
+}
diff --git a/library/Director/Objects/ImportExportHelper.php b/library/Director/Objects/ImportExportHelper.php
new file mode 100644
index 0000000..98d34c6
--- /dev/null
+++ b/library/Director/Objects/ImportExportHelper.php
@@ -0,0 +1,68 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Icinga\Module\Director\Db;
+
+/**
+ * Helper class, allows to reduce duplicate code. Might be moved elsewhere
+ * afterwards
+ */
+class ImportExportHelper
+{
+ /**
+ * Does not support every type out of the box
+ *
+ * @param IcingaObject $object
+ * @return object
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public static function simpleExport(IcingaObject $object)
+ {
+ $props = (array) $object->toPlainObject();
+ $props['fields'] = static::fetchFields($object);
+ ksort($props); // TODO: ksort in toPlainObject?
+
+ return (object) $props;
+ }
+
+ public static function fetchFields(IcingaObject $object)
+ {
+ return static::loadFieldReferences(
+ $object->getConnection(),
+ $object->getShortTableName(),
+ $object->get('id')
+ );
+ }
+
+ /**
+ * @param Db $connection
+ * @param string $type Warning: this will not be validated.
+ * @param int $id
+ * @return array
+ */
+ public static function loadFieldReferences(Db $connection, $type, $id)
+ {
+ $db = $connection->getDbAdapter();
+ $res = $db->fetchAll(
+ $db->select()->from([
+ 'f' => "icinga_${type}_field"
+ ], [
+ 'f.datafield_id',
+ 'f.is_required',
+ 'f.var_filter',
+ ])->join(['df' => 'director_datafield'], 'df.id = f.datafield_id', [])
+ ->where("${type}_id = ?", $id)
+ ->order('varname ASC')
+ );
+
+ if (empty($res)) {
+ return [];
+ }
+
+ foreach ($res as $field) {
+ $field->datafield_id = (int) $field->datafield_id;
+ }
+ return $res;
+ }
+}
diff --git a/library/Director/Objects/ImportRowModifier.php b/library/Director/Objects/ImportRowModifier.php
new file mode 100644
index 0000000..76982c2
--- /dev/null
+++ b/library/Director/Objects/ImportRowModifier.php
@@ -0,0 +1,91 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Icinga\Module\Director\Data\Db\DbObjectWithSettings;
+use Icinga\Module\Director\Hook\PropertyModifierHook;
+use Icinga\Module\Director\Objects\Extension\PriorityColumn;
+use RuntimeException;
+
+class ImportRowModifier extends DbObjectWithSettings implements InstantiatedViaHook
+{
+ use PriorityColumn;
+
+ protected $table = 'import_row_modifier';
+
+ protected $keyName = 'id';
+
+ protected $autoincKeyName = 'id';
+
+ protected $defaultProperties = [
+ 'id' => null,
+ 'source_id' => null,
+ 'property_name' => null,
+ 'provider_class' => null,
+ 'target_property' => null,
+ 'priority' => null,
+ 'description' => null,
+ ];
+
+ protected $settingsTable = 'import_row_modifier_setting';
+
+ protected $settingsRemoteId = 'row_modifier_id';
+
+ private $hookInstance;
+
+ public function getInstance()
+ {
+ if ($this->hookInstance === null) {
+ $class = $this->get('provider_class');
+ /** @var PropertyModifierHook $obj */
+ if (! class_exists($class)) {
+ throw new RuntimeException(sprintf(
+ 'Cannot instantiate Property modifier %s',
+ $class
+ ));
+ }
+ $obj = new $class;
+ $obj->setSettings($this->getSettings());
+ $obj->setPropertyName($this->get('property_name'));
+ $obj->setTargetProperty($this->get('target_property'));
+ $obj->setDb($this->connection);
+ $this->hookInstance = $obj;
+ }
+
+ return $this->hookInstance;
+ }
+
+ /**
+ * @deprecated please use \Icinga\Module\Director\Data\Exporter
+ * @return \stdClass
+ */
+ public function export()
+ {
+ $properties = $this->getProperties();
+ unset($properties['id']);
+ unset($properties['source_id']);
+ $properties['settings'] = $this->getInstance()->exportSettings();
+ ksort($properties);
+
+ return (object) $properties;
+ }
+
+ public function setSettings($settings)
+ {
+ $settings = $this->getInstance()->setSettings((array) $settings)->getSettings();
+
+ return parent::setSettings($settings); // TODO: Change the autogenerated stub
+ }
+
+ protected function beforeStore()
+ {
+ if (! $this->hasBeenLoadedFromDb() && $this->get('priority') === null) {
+ $this->setNextPriority('source_id');
+ }
+ }
+
+ protected function onInsert()
+ {
+ $this->refreshPriortyProperty();
+ }
+}
diff --git a/library/Director/Objects/ImportRun.php b/library/Director/Objects/ImportRun.php
new file mode 100644
index 0000000..d3bdb7c
--- /dev/null
+++ b/library/Director/Objects/ImportRun.php
@@ -0,0 +1,159 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Icinga\Module\Director\Data\Db\DbObject;
+use Icinga\Module\Director\Db;
+
+class ImportRun extends DbObject
+{
+ protected $table = 'import_run';
+
+ protected $keyName = 'id';
+
+ protected $autoincKeyName = 'id';
+
+ /** @var ImportSource */
+ protected $importSource = null;
+
+ protected $defaultProperties = [
+ 'id' => null,
+ 'source_id' => null,
+ 'rowset_checksum' => null,
+ 'start_time' => null,
+ 'end_time' => null,
+ // TODO: Check whether succeeded could be dropped
+ 'succeeded' => null,
+ ];
+
+ protected $binaryProperties = [
+ 'rowset_checksum',
+ ];
+
+ public function prepareImportedObjectQuery($columns = array('object_name'))
+ {
+ return $this->getDb()->select()->from(
+ array('r' => 'imported_row'),
+ $columns
+ )->joinLeft(
+ array('rsr' => 'imported_rowset_row'),
+ 'rsr.row_checksum = r.checksum',
+ array()
+ )->where(
+ 'rsr.rowset_checksum = ?',
+ $this->getConnection()->quoteBinary($this->rowset_checksum)
+ );
+ }
+
+ public function listColumnNames()
+ {
+ $db = $this->getDb();
+
+ $query = $db->select()->distinct()->from(
+ array('p' => 'imported_property'),
+ 'property_name'
+ )->join(
+ array('rp' => 'imported_row_property'),
+ 'rp.property_checksum = p.checksum',
+ array()
+ )->join(
+ array('rsr' => 'imported_rowset_row'),
+ 'rsr.row_checksum = rp.row_checksum',
+ array()
+ )->where('rsr.rowset_checksum = ?', $this->getConnection()->quoteBinary($this->rowset_checksum));
+
+ return $db->fetchCol($query);
+ }
+
+ public function fetchRows($columns, $filter = null, $keys = null)
+ {
+ $db = $this->getDb();
+ /** @var Db $connection */
+ $connection = $this->getConnection();
+ $binchecksum = $this->rowset_checksum;
+
+ $query = $db->select()->from(
+ array('rsr' => 'imported_rowset_row'),
+ array(
+ 'object_name' => 'r.object_name',
+ 'property_name' => 'p.property_name',
+ 'property_value' => 'p.property_value',
+ 'format' => 'p.format'
+ )
+ )->join(
+ array('r' => 'imported_row'),
+ 'rsr.row_checksum = r.checksum',
+ array()
+ )->join(
+ array('rp' => 'imported_row_property'),
+ 'r.checksum = rp.row_checksum',
+ array()
+ )->join(
+ array('p' => 'imported_property'),
+ 'p.checksum = rp.property_checksum',
+ array()
+ )->order('r.object_name');
+ if ($connection->isMysql()) {
+ $query->where('rsr.rowset_checksum = :checksum')->bind([
+ 'checksum' => $binchecksum
+ ]);
+ } else {
+ $query->where(
+ 'rsr.rowset_checksum = ?',
+ $connection->quoteBinary($binchecksum)
+ );
+ }
+
+ if ($columns === null) {
+ $columns = $this->listColumnNames();
+ } else {
+ $query->where('p.property_name IN (?)', $columns);
+ }
+
+ $result = array();
+ $empty = (object) array();
+ foreach ($columns as $k => $v) {
+ $empty->$k = null;
+ }
+
+ if ($keys !== null) {
+ $query->where('r.object_name IN (?)', $keys);
+ }
+
+ foreach ($db->fetchAll($query) as $row) {
+ if (! array_key_exists($row->object_name, $result)) {
+ $result[$row->object_name] = clone($empty);
+ }
+
+ if ($row->format === 'json') {
+ $result[$row->object_name]->{$row->property_name} = json_decode($row->property_value);
+ } else {
+ $result[$row->object_name]->{$row->property_name} = $row->property_value;
+ }
+ }
+
+ if ($filter) {
+ $filtered = array();
+ foreach ($result as $key => $row) {
+ if ($filter->matches($row)) {
+ $filtered[$key] = $row;
+ }
+ }
+
+ return $filtered;
+ }
+
+ return $result;
+ }
+
+ public function importSource()
+ {
+ if ($this->importSource === null) {
+ $this->importSource = ImportSource::loadWithAutoIncId(
+ (int) $this->get('source_id'),
+ $this->connection
+ );
+ }
+ return $this->importSource;
+ }
+}
diff --git a/library/Director/Objects/ImportSource.php b/library/Director/Objects/ImportSource.php
new file mode 100644
index 0000000..fd892ef
--- /dev/null
+++ b/library/Director/Objects/ImportSource.php
@@ -0,0 +1,537 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Icinga\Application\Benchmark;
+use Icinga\Exception\NotFoundError;
+use Icinga\Module\Director\Application\MemoryLimit;
+use Icinga\Module\Director\Data\Db\DbObjectWithSettings;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\DirectorObject\Automation\ExportInterface;
+use Icinga\Module\Director\Exception\DuplicateKeyException;
+use Icinga\Module\Director\Hook\PropertyModifierHook;
+use Icinga\Module\Director\Import\Import;
+use Icinga\Module\Director\Import\SyncUtils;
+use InvalidArgumentException;
+use Exception;
+
+class ImportSource extends DbObjectWithSettings implements ExportInterface
+{
+ protected $table = 'import_source';
+
+ protected $keyName = 'source_name';
+
+ protected $autoincKeyName = 'id';
+
+ protected $protectAutoinc = false;
+
+ protected $defaultProperties = [
+ 'id' => null,
+ 'source_name' => null,
+ 'provider_class' => null,
+ 'key_column' => null,
+ 'import_state' => 'unknown',
+ 'last_error_message' => null,
+ 'last_attempt' => null,
+ 'description' => null,
+ ];
+
+ protected $stateProperties = [
+ 'import_state',
+ 'last_error_message',
+ 'last_attempt',
+ ];
+
+ protected $settingsTable = 'import_source_setting';
+
+ protected $settingsRemoteId = 'source_id';
+
+ private $rowModifiers;
+
+ private $loadedRowModifiers;
+
+ private $newRowModifiers;
+
+ /**
+ * @deprecated please use \Icinga\Module\Director\Data\FieldReferenceLoader
+ * @return \stdClass
+ */
+ public function export()
+ {
+ $plain = $this->getProperties();
+ $plain['originalId'] = $plain['id'];
+ unset($plain['id']);
+
+ foreach ($this->stateProperties as $key) {
+ unset($plain[$key]);
+ }
+
+ $plain['settings'] = (object) $this->getSettings();
+ $plain['modifiers'] = $this->exportRowModifiers();
+ ksort($plain);
+
+ return (object) $plain;
+ }
+
+ /**
+ * @param $plain
+ * @param Db $db
+ * @param bool $replace
+ * @return ImportSource
+ * @throws DuplicateKeyException
+ * @throws NotFoundError
+ */
+ public static function import($plain, Db $db, $replace = false)
+ {
+ $properties = (array) $plain;
+ if (isset($properties['originalId'])) {
+ $id = $properties['originalId'];
+ unset($properties['originalId']);
+ } else {
+ $id = null;
+ }
+ $name = $properties['source_name'];
+
+ if ($replace && $id && static::existsWithNameAndId($name, $id, $db)) {
+ $object = static::loadWithAutoIncId($id, $db);
+ } elseif ($replace && static::exists($name, $db)) {
+ $object = static::load($name, $db);
+ } elseif (static::existsWithName($name, $db)) {
+ throw new DuplicateKeyException(
+ 'Import Source %s already exists',
+ $name
+ );
+ } else {
+ $object = static::create([], $db);
+ }
+
+ if (! isset($properties['modifiers'])) {
+ $properties['modifiers'] = [];
+ }
+
+ $object->setProperties($properties);
+
+ return $object;
+ }
+
+ public function setModifiers(array $modifiers)
+ {
+ if ($this->loadedRowModifiers === null && $this->hasBeenLoadedFromDb()) {
+ $this->loadedRowModifiers = $this->fetchRowModifiers();
+ }
+ $current = (array) $this->loadedRowModifiers;
+ if (\count($current) !== \count($modifiers)) {
+ $this->newRowModifiers = $modifiers;
+ } else {
+ $i = 0;
+ $modified = false;
+ foreach ($modifiers as $props) {
+ $this->loadedRowModifiers[$i]->setProperties((array) $props);
+ if ($this->loadedRowModifiers[$i]->hasBeenModified()) {
+ $modified = true;
+ }
+ $i++;
+ }
+ if ($modified) {
+ $this->newRowModifiers = $modifiers;
+ }
+ }
+ }
+
+ public function hasBeenModified()
+ {
+ return $this->newRowModifiers !== null
+ || parent::hasBeenModified();
+ }
+
+ public function getUniqueIdentifier()
+ {
+ return $this->get('source_name');
+ }
+
+ /**
+ * @param $name
+ * @param Db $connection
+ * @return ImportSource
+ * @throws NotFoundError
+ */
+ public static function loadByName($name, Db $connection)
+ {
+ $db = $connection->getDbAdapter();
+ $properties = $db->fetchRow(
+ $db->select()->from('import_source')->where('source_name = ?', $name)
+ );
+ if ($properties === false) {
+ throw new NotFoundError(sprintf(
+ 'There is no such Import Source: "%s"',
+ $name
+ ));
+ }
+
+ return static::create([], $connection)->setDbProperties($properties);
+ }
+
+ public static function existsWithName($name, Db $connection)
+ {
+ $db = $connection->getDbAdapter();
+
+ return (string) $name === (string) $db->fetchOne(
+ $db->select()
+ ->from('import_source', 'source_name')
+ ->where('source_name = ?', $name)
+ );
+ }
+
+ /**
+ * @param string $name
+ * @param int $id
+ * @param Db $connection
+ * @api internal
+ * @return bool
+ */
+ protected static function existsWithNameAndId($name, $id, Db $connection)
+ {
+ $db = $connection->getDbAdapter();
+ $dummy = new static;
+ $idCol = $dummy->autoincKeyName;
+ $keyCol = $dummy->keyName;
+
+ return (string) $id === (string) $db->fetchOne(
+ $db->select()
+ ->from($dummy->table, $idCol)
+ ->where("$idCol = ?", $id)
+ ->where("$keyCol = ?", $name)
+ );
+ }
+
+ /**
+ * @deprecated please use \Icinga\Module\Director\Data\FieldReferenceLoader
+ * @return array
+ */
+ protected function exportRowModifiers()
+ {
+ $modifiers = [];
+ foreach ($this->fetchRowModifiers() as $modifier) {
+ $modifiers[] = $modifier->export();
+ }
+
+ return $modifiers;
+ }
+
+ /**
+ * @param bool $required
+ * @return ImportRun|null
+ * @throws NotFoundError
+ */
+ public function fetchLastRun($required = false)
+ {
+ return $this->fetchLastRunBefore(time() + 1, $required);
+ }
+
+ /**
+ * @throws DuplicateKeyException
+ */
+ protected function onStore()
+ {
+ parent::onStore();
+ if ($this->newRowModifiers !== null) {
+ $connection = $this->getConnection();
+ $db = $connection->getDbAdapter();
+ $myId = $this->get('id');
+ if ($this->hasBeenLoadedFromDb()) {
+ $db->delete(
+ 'import_row_modifier',
+ $db->quoteInto('source_id = ?', $myId)
+ );
+ }
+
+ foreach ($this->newRowModifiers as $modifier) {
+ $modifier = ImportRowModifier::create((array) $modifier, $connection);
+ $modifier->set('source_id', $myId);
+ $modifier->store();
+ }
+ }
+ }
+
+ /**
+ * @param $timestamp
+ * @param bool $required
+ * @return ImportRun|null
+ * @throws NotFoundError
+ */
+ public function fetchLastRunBefore($timestamp, $required = false)
+ {
+ if (! $this->hasBeenLoadedFromDb()) {
+ return $this->nullUnlessRequired($required);
+ }
+
+ if ($timestamp === null) {
+ $timestamp = time();
+ }
+
+ $db = $this->getDb();
+ $query = $db->select()->from(
+ ['ir' => 'import_run'],
+ 'ir.id'
+ )->where('ir.source_id = ?', $this->get('id'))
+ ->where('ir.start_time < ?', date('Y-m-d H:i:s', $timestamp))
+ ->order('ir.start_time DESC')
+ ->limit(1);
+
+ $runId = $db->fetchOne($query);
+
+ if ($runId) {
+ return ImportRun::load($runId, $this->getConnection());
+ } else {
+ return $this->nullUnlessRequired($required);
+ }
+ }
+
+ /**
+ * @param $required
+ * @return null
+ * @throws NotFoundError
+ */
+ protected function nullUnlessRequired($required)
+ {
+ if ($required) {
+ throw new NotFoundError(
+ 'No data has been imported for "%s" yet',
+ $this->get('source_name')
+ );
+ }
+
+ return null;
+ }
+
+ public function applyModifiers(&$data)
+ {
+ $modifiers = $this->fetchFlatRowModifiers();
+
+ if (empty($modifiers)) {
+ return $this;
+ }
+
+ foreach ($modifiers as $modPair) {
+ /** @var PropertyModifierHook $modifier */
+ list($property, $modifier) = $modPair;
+ $rejected = [];
+ $newRows = [];
+ foreach ($data as $key => $row) {
+ $this->applyPropertyModifierToRow($modifier, $property, $row);
+ if ($modifier->rejectsRow()) {
+ $rejected[] = $key;
+ $modifier->rejectRow(false);
+ }
+ if ($modifier->expandsRows()) {
+ $target = $modifier->getTargetProperty($property);
+
+ $newValue = $row->$target;
+ if (\is_array($newValue)) {
+ foreach ($newValue as $val) {
+ $newRow = clone $row;
+ $newRow->$target = $val;
+ $newRows[] = $newRow;
+ }
+ $rejected[] = $key;
+ }
+ }
+ }
+
+ foreach ($rejected as $key) {
+ unset($data[$key]);
+ }
+ foreach ($newRows as $row) {
+ $data[] = $row;
+ }
+ }
+
+ return $this;
+ }
+
+ public function getObjectName()
+ {
+ return $this->get('source_name');
+ }
+
+ public static function getKeyColumnName()
+ {
+ return 'source_name';
+ }
+
+ protected function applyPropertyModifierToRow(PropertyModifierHook $modifier, $key, $row)
+ {
+ if (! is_object($row)) {
+ throw new InvalidArgumentException('Every imported row MUST be an object');
+ }
+ if ($modifier->requiresRow()) {
+ $modifier->setRow($row);
+ }
+
+ if (property_exists($row, $key)) {
+ $value = $row->$key;
+ } elseif (strpos($key, '.') !== false) {
+ $value = SyncUtils::getSpecificValue($row, $key);
+ } else {
+ $value = null;
+ }
+
+ $target = $modifier->getTargetProperty($key);
+ if (strpos($target, '.') !== false) {
+ throw new InvalidArgumentException(sprintf(
+ 'Cannot set value for nested key "%s"',
+ $target
+ ));
+ }
+
+ if (is_array($value) && ! $modifier->hasArraySupport()) {
+ $new = [];
+ foreach ($value as $k => $v) {
+ $new[$k] = $modifier->transform($v);
+ }
+ $row->$target = $new;
+ } else {
+ $row->$target = $modifier->transform($value);
+ }
+ }
+
+ public function getRowModifiers()
+ {
+ if ($this->rowModifiers === null) {
+ $this->prepareRowModifiers();
+ }
+
+ return $this->rowModifiers;
+ }
+
+ public function hasRowModifiers()
+ {
+ return count($this->getRowModifiers()) > 0;
+ }
+
+ /**
+ * @return ImportRowModifier[]
+ */
+ public function fetchRowModifiers()
+ {
+ $db = $this->getDb();
+ $modifiers = ImportRowModifier::loadAll(
+ $this->getConnection(),
+ $db->select()
+ ->from('import_row_modifier')
+ ->where('source_id = ?', $this->get('id'))
+ ->order('priority ASC')
+ );
+
+ if ($modifiers) {
+ return $modifiers;
+ } else {
+ return [];
+ }
+ }
+
+ protected function fetchFlatRowModifiers()
+ {
+ $mods = [];
+ foreach ($this->fetchRowModifiers() as $mod) {
+ $mods[] = [$mod->get('property_name'), $mod->getInstance()];
+ }
+
+ return $mods;
+ }
+
+ protected function prepareRowModifiers()
+ {
+ $modifiers = [];
+
+ foreach ($this->fetchRowModifiers() as $mod) {
+ $name = $mod->get('property_name');
+ if (! array_key_exists($name, $modifiers)) {
+ $modifiers[$name] = [];
+ }
+
+ $modifiers[$name][] = $mod->getInstance();
+ }
+
+ $this->rowModifiers = $modifiers;
+ }
+
+ public function listModifierTargetProperties()
+ {
+ $list = [];
+ foreach ($this->getRowModifiers() as $rowMods) {
+ /** @var PropertyModifierHook $mod */
+ foreach ($rowMods as $mod) {
+ if ($mod->hasTargetProperty()) {
+ $list[$mod->getTargetProperty()] = true;
+ }
+ }
+ }
+
+ return array_keys($list);
+ }
+
+ /**
+ * @param bool $runImport
+ * @return bool
+ * @throws DuplicateKeyException
+ */
+ public function checkForChanges($runImport = false)
+ {
+ $hadChanges = false;
+
+ $name = $this->get('source_name');
+ Benchmark::measure("Starting with import $name");
+ $this->raiseLimits();
+ try {
+ $import = new Import($this);
+ $this->set('last_attempt', date('Y-m-d H:i:s'));
+ if ($import->providesChanges()) {
+ Benchmark::measure("Found changes for $name");
+ $hadChanges = true;
+ $this->set('import_state', 'pending-changes');
+
+ if ($runImport && $import->run()) {
+ Benchmark::measure("Import succeeded for $name");
+ $this->set('import_state', 'in-sync');
+ }
+ } else {
+ $this->set('import_state', 'in-sync');
+ }
+
+ $this->set('last_error_message', null);
+ } catch (Exception $e) {
+ $this->set('import_state', 'failing');
+ Benchmark::measure("Import failed for $name");
+ $this->set('last_error_message', $e->getMessage());
+ }
+
+ if ($this->hasBeenModified()) {
+ $this->store();
+ }
+
+ return $hadChanges;
+ }
+
+ /**
+ * @return bool
+ * @throws DuplicateKeyException
+ */
+ public function runImport()
+ {
+ return $this->checkForChanges(true);
+ }
+
+ /**
+ * Raise PHP resource limits
+ *
+ * @return $this;
+ */
+ protected function raiseLimits()
+ {
+ MemoryLimit::raiseTo('1024M');
+ ini_set('max_execution_time', 0);
+
+ return $this;
+ }
+}
diff --git a/library/Director/Objects/InstantiatedViaHook.php b/library/Director/Objects/InstantiatedViaHook.php
new file mode 100644
index 0000000..79f3442
--- /dev/null
+++ b/library/Director/Objects/InstantiatedViaHook.php
@@ -0,0 +1,14 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Icinga\Module\Director\Hook\JobHook;
+use Icinga\Module\Director\Hook\PropertyModifierHook;
+
+interface InstantiatedViaHook
+{
+ /**
+ * @return mixed|PropertyModifierHook|JobHook
+ */
+ public function getInstance();
+}
diff --git a/library/Director/Objects/ObjectApplyMatches.php b/library/Director/Objects/ObjectApplyMatches.php
new file mode 100644
index 0000000..018c880
--- /dev/null
+++ b/library/Director/Objects/ObjectApplyMatches.php
@@ -0,0 +1,239 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Icinga\Application\Benchmark;
+use Icinga\Data\Filter\Filter;
+use Icinga\Data\Filter\FilterExpression;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Module\Director\Application\MemoryLimit;
+use Icinga\Module\Director\Data\AssignFilterHelper;
+use Icinga\Module\Director\Data\Db\DbObjectTypeRegistry;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Db\Cache\PrefetchCache;
+use stdClass;
+
+abstract class ObjectApplyMatches
+{
+ protected static $flatObjects;
+
+ protected static $columnMap = array(
+ 'name' => 'object_name'
+ );
+
+ protected $object;
+
+ protected $flatObject;
+
+ protected static $type;
+
+ protected static $preparedFilters = array();
+
+ public static function prepare(IcingaObject $object)
+ {
+ return new static($object);
+ }
+
+ /**
+ * Prepare a Filter with fixed columns, and store the result
+ *
+ * @param Filter $filter
+ *
+ * @return Filter
+ */
+ protected static function getPreparedFilter(Filter $filter)
+ {
+ $hash = spl_object_hash($filter);
+ if (! array_key_exists($hash, self::$preparedFilters)) {
+ $filter = clone($filter);
+ static::fixFilterColumns($filter);
+ self::$preparedFilters[$hash] = $filter;
+ }
+ return self::$preparedFilters[$hash];
+ }
+
+ public function matchesFilter(Filter $filter)
+ {
+ $filterObj = static::getPreparedFilter($filter);
+ if ($filterObj->isExpression() || ! $filterObj->isEmpty()) {
+ return AssignFilterHelper::matchesFilter($filterObj, $this->flatObject);
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * @param Filter $filter
+ * @param Db $db
+ *
+ * @return array
+ */
+ public static function forFilter(Filter $filter, Db $db)
+ {
+ $result = array();
+ Benchmark::measure(sprintf('Starting Filter %s', $filter));
+ $filter = clone($filter);
+ static::fixFilterColumns($filter);
+ $helper = new AssignFilterHelper($filter);
+
+ foreach (static::flatObjects($db) as $object) {
+ if ($helper->matches($object)) {
+ $name = $object->object_name;
+ $result[] = $name;
+ }
+ }
+ Benchmark::measure(sprintf('Got %d results for %s', count($result), $filter));
+
+ return array_values($result);
+ }
+
+ protected static function getType()
+ {
+ if (static::$type === null) {
+ throw new ProgrammingError(
+ 'Implementations of %s need ::$type to be defined, %s has not',
+ __CLASS__,
+ get_called_class()
+ );
+ }
+
+ return static::$type;
+ }
+
+ protected static function flatObjects(Db $db)
+ {
+ if (self::$flatObjects === null) {
+ self::$flatObjects = static::fetchFlatObjects($db);
+ }
+
+ return self::$flatObjects;
+ }
+
+ protected static function raiseLimits()
+ {
+ // Note: IcingaConfig also raises the limit for generation, **but** we
+ // need the higher limit for preview.
+ MemoryLimit::raiseTo('1024M');
+ }
+
+ protected static function fetchFlatObjects(Db $db)
+ {
+ return static::fetchFlatObjectsByType($db, static::getType());
+ }
+
+ protected static function fetchFlatObjectsByType(Db $db, $type)
+ {
+ self::raiseLimits();
+
+ Benchmark::measure("ObjectApplyMatches: prefetching $type");
+ PrefetchCache::initialize($db);
+ /** @var IcingaObject $class */
+ $class = DbObjectTypeRegistry::classByType($type);
+ $all = $class::prefetchAll($db);
+ Benchmark::measure("ObjectApplyMatches: related objects for $type");
+ $class::prefetchAllRelationsByType($type, $db);
+ Benchmark::measure("ObjectApplyMatches: preparing flat $type objects");
+
+ $objects = array();
+ foreach ($all as $object) {
+ if ($object->isTemplate()) {
+ continue;
+ }
+
+ $flat = $object->toPlainObject(true, false);
+ static::flattenVars($flat);
+ $objects[$object->getObjectName()] = $flat;
+ }
+ Benchmark::measure("ObjectApplyMatches: $type cache ready");
+
+ return $objects;
+ }
+
+ public static function fixFilterColumns(Filter $filter)
+ {
+ if ($filter->isExpression()) {
+ /** @var FilterExpression $filter */
+ static::fixFilterExpressionColumn($filter);
+ } else {
+ foreach ($filter->filters() as $sub) {
+ static::fixFilterColumns($sub);
+ }
+ }
+ }
+
+ protected static function fixFilterExpressionColumn(FilterExpression $filter)
+ {
+ if (static::columnIsJson($filter)) {
+ $column = $filter->getExpression();
+ $filter->setExpression($filter->getColumn());
+ $filter->setColumn($column);
+ }
+
+ $col = $filter->getColumn();
+ $type = static::$type;
+
+ if ($type && substr($col, 0, strlen($type) + 1) === "${type}.") {
+ $filter->setColumn($col = substr($col, strlen($type) + 1));
+ }
+
+ if (array_key_exists($col, self::$columnMap)) {
+ $filter->setColumn(self::$columnMap[$col]);
+ }
+
+ $filter->setExpression(json_decode($filter->getExpression()));
+ }
+
+ protected static function columnIsJson(FilterExpression $filter)
+ {
+ $col = $filter->getColumn();
+ return strlen($col) && $col[0] === '"';
+ }
+
+ /**
+ * Helper, flattens all vars of a given object
+ *
+ * The object itself will be modified, and the 'vars' property will be
+ * replaced with corresponding 'vars.whatever' properties
+ *
+ * @param $object
+ * @param string $key
+ */
+ protected static function flattenVars(stdClass $object, $key = 'vars')
+ {
+ if (property_exists($object, 'vars')) {
+ foreach ($object->vars as $k => $v) {
+ if (is_object($v)) {
+ static::flattenVars($v, $k);
+ }
+ $object->{$key . '.' . $k} = $v;
+ }
+ unset($object->vars);
+ }
+ }
+
+ protected function __construct(IcingaObject $object)
+ {
+ $this->object = $object;
+ $flat = $object->toPlainObject(true, false);
+ // Sure, we are flat - but we might still want to match templates.
+ unset($flat->imports);
+ $flat->templates = $object->listFlatResolvedImportNames();
+ $this->addAppliedGroupsToFlatObject($flat, $object);
+ static::flattenVars($flat);
+ $this->flatObject = $flat;
+ }
+
+ protected function addAppliedGroupsToFlatObject($flat, IcingaObject $object)
+ {
+ if ($object instanceof IcingaHost) {
+ $appliedGroups = $object->getAppliedGroups();
+ if (! empty($appliedGroups)) {
+ if (isset($flat->groups)) {
+ $flat->groups = array_merge($flat->groups, $appliedGroups);
+ } else {
+ $flat->groups = $appliedGroups;
+ }
+ }
+ }
+ }
+}
diff --git a/library/Director/Objects/ObjectWithArguments.php b/library/Director/Objects/ObjectWithArguments.php
new file mode 100644
index 0000000..2f99460
--- /dev/null
+++ b/library/Director/Objects/ObjectWithArguments.php
@@ -0,0 +1,18 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+interface ObjectWithArguments
+{
+ /**
+ * @return boolean
+ */
+ public function gotArguments();
+
+ /**
+ * @return IcingaArguments
+ */
+ public function arguments();
+
+ public function unsetArguments();
+}
diff --git a/library/Director/Objects/ServiceGroupMembershipResolver.php b/library/Director/Objects/ServiceGroupMembershipResolver.php
new file mode 100644
index 0000000..4649212
--- /dev/null
+++ b/library/Director/Objects/ServiceGroupMembershipResolver.php
@@ -0,0 +1,8 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+class ServiceGroupMembershipResolver extends GroupMembershipResolver
+{
+ protected $type = 'service';
+}
diff --git a/library/Director/Objects/SyncProperty.php b/library/Director/Objects/SyncProperty.php
new file mode 100644
index 0000000..20c4700
--- /dev/null
+++ b/library/Director/Objects/SyncProperty.php
@@ -0,0 +1,48 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Icinga\Module\Director\Data\Db\DbObject;
+use Icinga\Module\Director\Objects\Extension\PriorityColumn;
+
+class SyncProperty extends DbObject
+{
+ use PriorityColumn;
+
+ protected $table = 'sync_property';
+
+ protected $keyName = 'id';
+
+ protected $autoincKeyName = 'id';
+
+ protected $defaultProperties = [
+ 'id' => null,
+ 'rule_id' => null,
+ 'source_id' => null,
+ 'source_expression' => null,
+ 'destination_field' => null,
+ 'priority' => null,
+ 'filter_expression' => null,
+ 'merge_policy' => null
+ ];
+
+ protected function beforeStore()
+ {
+ if (! $this->hasBeenLoadedFromDb() && $this->get('priority') === null) {
+ $this->setNextPriority('rule_id');
+ }
+ }
+
+ public function setSource($name)
+ {
+ $source = ImportSource::loadByName($name, $this->getConnection());
+ $this->set('source_id', $source->get('id'));
+
+ return $this;
+ }
+
+ protected function onInsert()
+ {
+ $this->refreshPriortyProperty();
+ }
+}
diff --git a/library/Director/Objects/SyncRule.php b/library/Director/Objects/SyncRule.php
new file mode 100644
index 0000000..89f7fd1
--- /dev/null
+++ b/library/Director/Objects/SyncRule.php
@@ -0,0 +1,553 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Icinga\Application\Benchmark;
+use Icinga\Data\Filter\Filter;
+use Icinga\Exception\IcingaException;
+use Icinga\Module\Director\Data\Db\DbObject;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\DirectorObject\Automation\ExportInterface;
+use Icinga\Module\Director\Exception\DuplicateKeyException;
+use Icinga\Module\Director\Import\PurgeStrategy\PurgeStrategy;
+use Icinga\Module\Director\Import\Sync;
+use Exception;
+
+class SyncRule extends DbObject implements ExportInterface
+{
+ protected $table = 'sync_rule';
+
+ protected $keyName = 'rule_name';
+
+ protected $autoincKeyName = 'id';
+
+ protected $protectAutoinc = false;
+
+ protected $defaultProperties = [
+ 'id' => null,
+ 'rule_name' => null,
+ 'object_type' => null,
+ 'update_policy' => null,
+ 'purge_existing' => null,
+ 'purge_action' => null,
+ 'filter_expression' => null,
+ 'sync_state' => 'unknown',
+ 'last_error_message' => null,
+ 'last_attempt' => null,
+ 'description' => null,
+ ];
+
+ protected $stateProperties = [
+ 'sync_state',
+ 'last_error_message',
+ 'last_attempt',
+ ];
+
+ private $sync;
+
+ private $purgeStrategy;
+
+ private $filter;
+
+ private $hasCombinedKey;
+
+ /** @var SyncProperty[] */
+ private $syncProperties;
+
+ private $sourceKeyPattern;
+
+ private $destinationKeyPattern;
+
+ private $newSyncProperties;
+
+ private $originalId;
+
+ public function listInvolvedSourceIds()
+ {
+ if (! $this->hasBeenLoadedFromDb()) {
+ return [];
+ }
+
+ $db = $this->getDb();
+ return array_map('intval', array_unique(
+ $db->fetchCol(
+ $db->select()
+ ->from(['p' => 'sync_property'], 'p.source_id')
+ ->join(['s' => 'import_source'], 's.id = p.source_id', array())
+ ->where('rule_id = ?', $this->get('id'))
+ ->order('s.source_name')
+ )
+ ));
+ }
+
+ /**
+ * @return array
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function fetchInvolvedImportSources()
+ {
+ $sources = [];
+
+ foreach ($this->listInvolvedSourceIds() as $sourceId) {
+ $sources[$sourceId] = ImportSource::loadWithAutoIncId($sourceId, $this->getConnection());
+ }
+
+ return $sources;
+ }
+
+ public function getLastSyncTimestamp()
+ {
+ if (! $this->hasBeenLoadedFromDb()) {
+ return null;
+ }
+
+ $db = $this->getDb();
+ $query = $db->select()->from(
+ ['sr' => 'sync_run'],
+ 'sr.start_time'
+ )->where('sr.rule_id = ?', $this->get('id'))
+ ->order('sr.start_time DESC')
+ ->limit(1);
+
+ return $db->fetchOne($query);
+ }
+
+ public function getLastSyncRunId()
+ {
+ if (! $this->hasBeenLoadedFromDb()) {
+ return null;
+ }
+
+ $db = $this->getDb();
+ $query = $db->select()->from(
+ ['sr' => 'sync_run'],
+ 'sr.id'
+ )->where('sr.rule_id = ?', $this->get('id'))
+ ->order('sr.start_time DESC')
+ ->limit(1);
+
+ return $db->fetchOne($query);
+ }
+
+ public function matches($row)
+ {
+ if ($this->get('filter_expression') === null) {
+ return true;
+ }
+
+ return $this->filter()->matches($row);
+ }
+
+ /**
+ * @param bool $apply
+ * @return bool
+ * @throws DuplicateKeyException
+ */
+ public function checkForChanges($apply = false)
+ {
+ $hadChanges = false;
+
+ Benchmark::measure('Checking sync rule ' . $this->get('rule_name'));
+ try {
+ $this->set('last_attempt', date('Y-m-d H:i:s'));
+ $this->set('sync_state', 'unknown');
+ $sync = $this->sync();
+ if ($sync->hasModifications()) {
+ Benchmark::measure('Got modifications for sync rule ' . $this->get('rule_name'));
+ $this->set('sync_state', 'pending-changes');
+ if ($apply && $runId = $sync->apply()) {
+ Benchmark::measure('Successfully synced rule ' . $this->get('rule_name'));
+ $this->set('sync_state', 'in-sync');
+ }
+
+ $hadChanges = true;
+ } else {
+ Benchmark::measure('No modifications for sync rule ' . $this->get('rule_name'));
+ $this->set('sync_state', 'in-sync');
+ }
+
+ $this->set('last_error_message', null);
+ } catch (Exception $e) {
+ $this->set('sync_state', 'failing');
+ $this->set('last_error_message', $e->getMessage());
+ // TODO: Store last error details / trace?
+ }
+
+ if ($this->hasBeenModified()) {
+ $this->store();
+ }
+
+ return $hadChanges;
+ }
+
+ /**
+ * @return IcingaObject[]
+ * @throws IcingaException
+ */
+ public function getExpectedModifications()
+ {
+ return $this->sync()->getExpectedModifications();
+ }
+
+ /**
+ * @return bool
+ * @throws DuplicateKeyException
+ */
+ public function applyChanges()
+ {
+ return $this->checkForChanges(true);
+ }
+
+ public function getSourceKeyPattern()
+ {
+ if ($this->hasCombinedKey()) {
+ return $this->sourceKeyPattern;
+ } else {
+ return null; // ??
+ }
+ }
+
+ public function getDestinationKeyPattern()
+ {
+ if ($this->hasCombinedKey()) {
+ return $this->destinationKeyPattern;
+ } else {
+ return null; // ??
+ }
+ }
+
+ protected function sync()
+ {
+ if ($this->sync === null) {
+ $this->sync = new Sync($this);
+ }
+
+ return $this->sync;
+ }
+
+ /**
+ * @return Filter
+ */
+ public function filter()
+ {
+ if ($this->filter === null) {
+ $this->filter = Filter::fromQueryString($this->get('filter_expression'));
+ }
+
+ return $this->filter;
+ }
+
+ public function purgeStrategy()
+ {
+ if ($this->purgeStrategy === null) {
+ $this->purgeStrategy = $this->loadConfiguredPurgeStrategy();
+ }
+
+ return $this->purgeStrategy;
+ }
+
+ // TODO: Allow for more
+ protected function loadConfiguredPurgeStrategy()
+ {
+ if ($this->get('purge_existing') === 'y') {
+ return PurgeStrategy::load('ImportRunBased', $this);
+ } else {
+ return PurgeStrategy::load('PurgeNothing', $this);
+ }
+ }
+
+ /**
+ * @deprecated please use \Icinga\Module\Director\Data\Exporter
+ * @return object
+ */
+ public function export()
+ {
+ $plain = $this->getProperties();
+ $plain['originalId'] = $plain['id'];
+ unset($plain['id']);
+
+ foreach ($this->stateProperties as $key) {
+ unset($plain[$key]);
+ }
+ $plain['properties'] = $this->exportSyncProperties();
+ ksort($plain);
+
+ return (object) $plain;
+ }
+
+ /**
+ * @param object $plain
+ * @param Db $db
+ * @param bool $replace
+ * @return static
+ * @throws DuplicateKeyException
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public static function import($plain, Db $db, $replace = false)
+ {
+ $properties = (array) $plain;
+ if (isset($properties['originalId'])) {
+ $id = $properties['originalId'];
+ unset($properties['originalId']);
+ } else {
+ $id = null;
+ }
+ $name = $properties['rule_name'];
+
+ if ($replace && $id && static::existsWithNameAndId($name, $id, $db)) {
+ $object = static::loadWithAutoIncId($id, $db);
+ } elseif ($replace && static::exists($name, $db)) {
+ $object = static::load($name, $db);
+ } elseif (static::existsWithName($name, $db)) {
+ throw new DuplicateKeyException(
+ 'Sync Rule %s already exists',
+ $name
+ );
+ } else {
+ $object = static::create([], $db);
+ }
+
+ $object->newSyncProperties = $properties['properties'];
+ unset($properties['properties']);
+ $object->setProperties($properties);
+
+ return $object;
+ }
+
+ public function getUniqueIdentifier()
+ {
+ return $this->get('rule_name');
+ }
+
+ /**
+ * @throws DuplicateKeyException
+ */
+ protected function onStore()
+ {
+ parent::onStore();
+ if ($this->newSyncProperties !== null) {
+ $connection = $this->getConnection();
+ $db = $connection->getDbAdapter();
+ $myId = $this->get('id');
+ if ($this->originalId === null) {
+ $originalId = $myId;
+ } else {
+ $originalId = $this->originalId;
+ $this->originalId = null;
+ }
+ if ($this->hasBeenLoadedFromDb()) {
+ $db->delete(
+ 'sync_property',
+ $db->quoteInto('rule_id = ?', $myId)
+ );
+ }
+
+ foreach ($this->newSyncProperties as $property) {
+ unset($property->rule_name);
+ $property = SyncProperty::create((array) $property, $connection);
+ $property->set('rule_id', $myId);
+ $property->store();
+ }
+ }
+ }
+
+ /**
+ * @deprecated
+ * @return array
+ */
+ protected function exportSyncProperties()
+ {
+ $all = [];
+ $db = $this->getDb();
+ $sourceNames = $db->fetchPairs(
+ $db->select()->from('import_source', ['id', 'source_name'])
+ );
+
+ foreach ($this->getSyncProperties() as $property) {
+ $properties = $property->getProperties();
+ $properties['source'] = $sourceNames[$properties['source_id']];
+ unset($properties['id']);
+ unset($properties['rule_id']);
+ unset($properties['source_id']);
+ ksort($properties);
+ $all[] = (object) $properties;
+ }
+
+ return $all;
+ }
+
+ /**
+ * Whether we have a combined key (e.g. services on hosts)
+ *
+ * @return bool
+ */
+ public function hasCombinedKey()
+ {
+ if ($this->hasCombinedKey === null) {
+ $this->hasCombinedKey = false;
+
+ // TODO: Move to Objects
+ if ($this->get('object_type') === 'service') {
+ $hasHost = false;
+ $hasObjectName = false;
+ $hasServiceSet = false;
+
+ foreach ($this->getSyncProperties() as $key => $property) {
+ if ($property->destination_field === 'host') {
+ $hasHost = $property->source_expression;
+ }
+ if ($property->destination_field === 'service_set') {
+ $hasServiceSet = $property->source_expression;
+ }
+ if ($property->destination_field === 'object_name') {
+ $hasObjectName = $property->source_expression;
+ }
+ }
+
+ if ($hasHost !== false && $hasObjectName !== false) {
+ $this->hasCombinedKey = true;
+ $this->sourceKeyPattern = sprintf(
+ '%s!%s',
+ $hasHost,
+ $hasObjectName
+ );
+
+ $this->destinationKeyPattern = '${host}!${object_name}';
+ } elseif ($hasServiceSet !== false && $hasObjectName !== false) {
+ $this->hasCombinedKey = true;
+ $this->sourceKeyPattern = sprintf(
+ '%s!%s',
+ $hasServiceSet,
+ $hasObjectName
+ );
+
+ $this->destinationKeyPattern = '${service_set}!${object_name}';
+ }
+ } elseif ($this->get('object_type') === 'serviceSet') {
+ $hasHost = false;
+ $hasObjectName = false;
+
+ foreach ($this->getSyncProperties() as $key => $property) {
+ if ($property->destination_field === 'host') {
+ $hasHost = $property->source_expression;
+ }
+ if ($property->destination_field === 'object_name') {
+ $hasObjectName = $property->source_expression;
+ }
+ }
+
+ if ($hasHost !== false && $hasObjectName !== false) {
+ $this->hasCombinedKey = true;
+ $this->sourceKeyPattern = sprintf(
+ '%s!%s',
+ $hasHost,
+ $hasObjectName
+ );
+
+ $this->destinationKeyPattern = '${host}!${object_name}';
+ }
+ } elseif ($this->get('object_type') === 'datalistEntry') {
+ $hasList = false;
+ $hasName = false;
+
+ foreach ($this->getSyncProperties() as $key => $property) {
+ if ($property->destination_field === 'list_id') {
+ $hasList = $property->source_expression;
+ }
+ if ($property->destination_field === 'entry_name') {
+ $hasName = $property->source_expression;
+ }
+ }
+
+ if ($hasList !== false && $hasName !== false) {
+ $this->hasCombinedKey = true;
+ $this->sourceKeyPattern = sprintf(
+ '%s!%s',
+ $hasList,
+ $hasName
+ );
+
+ $this->destinationKeyPattern = '${list_id}!${entry_name}';
+ }
+ }
+ }
+
+ return $this->hasCombinedKey;
+ }
+
+ public function hasSyncProperties()
+ {
+ $properties = $this->getSyncProperties();
+ return ! empty($properties);
+ }
+
+ /**
+ * @return SyncProperty[]
+ */
+ public function getSyncProperties()
+ {
+ if (! $this->hasBeenLoadedFromDb()) {
+ return [];
+ }
+
+ if ($this->syncProperties === null) {
+ $this->syncProperties = $this->fetchSyncProperties();
+ }
+
+ return $this->syncProperties;
+ }
+
+ public function fetchSyncProperties()
+ {
+ $db = $this->getDb();
+
+ return SyncProperty::loadAll(
+ $this->getConnection(),
+ $db->select()
+ ->from('sync_property')
+ ->where('rule_id = ?', $this->get('id'))
+ ->order('priority ASC')
+ );
+ }
+
+ /**
+ * TODO: implement in a generic way, this is duplicated code
+ *
+ * @param string $name
+ * @param Db $connection
+ * @api internal
+ * @return bool
+ */
+ public static function existsWithName($name, Db $connection)
+ {
+ $db = $connection->getDbAdapter();
+
+ return (string) $name === (string) $db->fetchOne(
+ $db->select()
+ ->from('sync_rule', 'rule_name')
+ ->where('rule_name = ?', $name)
+ );
+ }
+
+ /**
+ * @param string $name
+ * @param int $id
+ * @param Db $connection
+ * @api internal
+ * @return bool
+ */
+ protected static function existsWithNameAndId($name, $id, Db $connection)
+ {
+ $db = $connection->getDbAdapter();
+ $dummy = new static;
+ $idCol = $dummy->autoincKeyName;
+ $keyCol = $dummy->keyName;
+
+ return (string) $id === (string) $db->fetchOne(
+ $db->select()
+ ->from($dummy->table, $idCol)
+ ->where("$idCol = ?", $id)
+ ->where("$keyCol = ?", $name)
+ );
+ }
+}
diff --git a/library/Director/Objects/SyncRun.php b/library/Director/Objects/SyncRun.php
new file mode 100644
index 0000000..62f7378
--- /dev/null
+++ b/library/Director/Objects/SyncRun.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace Icinga\Module\Director\Objects;
+
+use Icinga\Module\Director\Data\Db\DbObject;
+
+class SyncRun extends DbObject
+{
+ protected $table = 'sync_run';
+
+ protected $keyName = 'id';
+
+ protected $autoincKeyName = 'id';
+
+ protected $defaultProperties = array(
+ 'id' => null,
+ 'rule_id' => null,
+ 'rule_name' => null,
+ 'start_time' => null,
+ 'duration_ms' => null,
+ 'objects_created' => null,
+ 'objects_deleted' => null,
+ 'objects_modified' => null,
+ 'last_former_activity' => null,
+ 'last_related_activity' => null,
+ );
+
+ public static function start(SyncRule $rule)
+ {
+ return static::create(
+ array(
+ 'start_time' => date('Y-m-d H:i:s'),
+ 'rule_id' => $rule->id,
+ 'rule_name' => $rule->rule_name,
+ ),
+ $rule->getConnection()
+ );
+ }
+
+ public function countActivities()
+ {
+ return (int) $this->get('objects_deleted')
+ + (int) $this->get('objects_created')
+ + (int) $this->get('objects_modified');
+ }
+}
diff --git a/library/Director/PlainObjectRenderer.php b/library/Director/PlainObjectRenderer.php
new file mode 100644
index 0000000..4dadf4f
--- /dev/null
+++ b/library/Director/PlainObjectRenderer.php
@@ -0,0 +1,130 @@
+<?php
+
+namespace Icinga\Module\Director;
+
+class PlainObjectRenderer
+{
+ const INDENTATION = ' ';
+
+ public static function render($object)
+ {
+ return self::renderObject($object);
+ }
+
+ protected static function renderBoolean($value)
+ {
+ return $value ? 'true' : 'false';
+ }
+
+ protected static function renderInteger($value)
+ {
+ return (string) $value;
+ }
+
+ protected static function renderFloat($value)
+ {
+ // Render .0000 floats as integers, mainly because of some JSON
+ // implementations:
+ if ((string) (int) $value === (string) $value) {
+ return static::renderInteger((int) $value);
+ } else {
+ return sprintf('%F', $value);
+ }
+ }
+
+ protected static function renderNull()
+ {
+ return 'null';
+ }
+
+ protected static function renderString($value)
+ {
+ return '"' . addslashes($value) . '"';
+ }
+
+ protected static function renderArray($array, $prefix = '')
+ {
+ if (empty($array)) {
+ return '[]';
+ }
+
+ $vals = array();
+
+ foreach ($array as $val) {
+ $vals[] = $prefix
+ . self::INDENTATION
+ . self::renderObject($val, $prefix . self::INDENTATION);
+ }
+ return "[\n" . implode(",\n", $vals) . "\n$prefix]";
+ }
+
+ protected static function renderHash($hash, $prefix = '')
+ {
+ $vals = array();
+ $hash = (array) $hash;
+ if (empty($hash)) {
+ return '{}';
+ }
+
+ if (count($hash) === 1) {
+ $current = self::renderObject(current($hash), $prefix . self::INDENTATION);
+ if (strlen($current) < 62) {
+ return sprintf(
+ '{ %s: %s }',
+ key($hash),
+ $current
+ );
+ }
+ }
+
+ ksort($hash);
+ foreach ($hash as $key => $val) {
+ $vals[] = $prefix
+ . self::INDENTATION
+ . $key
+ . ': '
+ . self::renderObject($val, $prefix . self::INDENTATION);
+ }
+ return "{\n" . implode(",\n", $vals) . "\n$prefix}";
+ }
+
+ protected static function renderObject($object, $prefix = '')
+ {
+ if (is_null($object)) {
+ return self::renderNull();
+ } elseif (is_bool($object)) {
+ return self::renderBoolean($object);
+ } elseif (is_integer($object)) {
+ return self::renderInteger($object);
+ } elseif (is_float($object)) {
+ return self::renderFloat($object);
+ } elseif (is_object($object) || static::isAssocArray($object)) {
+ return self::renderHash($object, $prefix);
+ } elseif (is_array($object)) {
+ return self::renderArray($object, $prefix);
+ } elseif (is_string($object)) {
+ return self::renderString($object);
+ } else {
+ return '(UNKNOWN TYPE) ' . var_export($object, 1);
+ }
+ }
+
+ /**
+ * Check if an array contains assoc keys
+ *
+ * @from https://stackoverflow.com/questions/173400/how-to-check-if-php-array-is-associative-or-sequential
+ * @param $arr
+ * @return bool
+ */
+ protected static function isAssocArray($arr)
+ {
+ if (! is_array($arr)) {
+ return false;
+ }
+ if (array() === $arr) {
+ return false;
+ }
+
+ return array_keys($arr) !== range(0, count($arr) - 1);
+ }
+}
diff --git a/library/Director/PropertyModifier/PropertyModifierArrayElementByPosition.php b/library/Director/PropertyModifier/PropertyModifierArrayElementByPosition.php
new file mode 100644
index 0000000..0ffc8af
--- /dev/null
+++ b/library/Director/PropertyModifier/PropertyModifierArrayElementByPosition.php
@@ -0,0 +1,172 @@
+<?php
+
+namespace Icinga\Module\Director\PropertyModifier;
+
+use Icinga\Exception\ConfigurationError;
+use Icinga\Module\Director\Hook\PropertyModifierHook;
+use Icinga\Module\Director\Web\Form\QuickForm;
+use InvalidArgumentException;
+use stdClass;
+
+class PropertyModifierArrayElementByPosition extends PropertyModifierHook
+{
+ public function getName()
+ {
+ return 'Get a specific Array Element';
+ }
+
+ public function hasArraySupport()
+ {
+ return true;
+ }
+
+ /**
+ * @param QuickForm $form
+ * @throws \Zend_Form_Exception
+ */
+ public static function addSettingsFormFields(QuickForm $form)
+ {
+ $form->addElement('select', 'position_type', [
+ 'label' => $form->translate('Position Type'),
+ 'required' => true,
+ 'multiOptions' => $form->optionalEnum([
+ 'first' => $form->translate('First Element'),
+ 'last' => $form->translate('Last Element'),
+ 'fixed' => $form->translate('Specific Element (by position)'),
+ 'keyname' => $form->translate('Specific Element (by key name)'),
+ ]),
+ ]);
+
+ $form->addElement('text', 'position', [
+ 'label' => $form->translate('Position'),
+ 'description' => $form->translate(
+ 'Numeric position or key name'
+ ),
+ ]);
+
+ $form->addElement('select', 'when_missing', [
+ 'label' => $form->translate('When not available'),
+ 'required' => true,
+ 'description' => $form->translate(
+ 'What should happen when the specified element is not available?'
+ ),
+ 'value' => 'null',
+ 'multiOptions' => $form->optionalEnum([
+ 'fail' => $form->translate('Let the whole Import Run fail'),
+ 'null' => $form->translate('return NULL'),
+ ])
+ ]);
+ }
+
+ /**
+ * @param $value
+ * @return string|null
+ * @throws ConfigurationError
+ * @throws InvalidArgumentException
+ */
+ public function transform($value)
+ {
+ // First and Last will work with hashes too:
+ if ($value instanceof stdClass) {
+ $value = (array) $value;
+ }
+
+ if (! is_array($value)) {
+ return $this->emptyValue($value);
+ }
+
+ switch ($this->getSetting('position_type')) {
+ case 'first':
+ if (empty($value)) {
+ return $this->emptyValue($value);
+ } else {
+ return array_shift($value);
+ }
+ // https://github.com/squizlabs/PHP_CodeSniffer/pull/1363
+ case 'last':
+ if (empty($value)) {
+ return $this->emptyValue($value);
+ } else {
+ return array_pop($value);
+ }
+ // https://github.com/squizlabs/PHP_CodeSniffer/pull/1363
+ case 'fixed':
+ $pos = $this->getSetting('position');
+ if (! is_int($pos) && ! ctype_digit($pos)) {
+ throw new InvalidArgumentException(sprintf(
+ '"%s" is not a valid array position',
+ $pos
+ ));
+ }
+ $pos = (int) $pos;
+
+ if (array_key_exists($pos, $value)) {
+ return $value[$pos];
+ } else {
+ return $this->emptyValue($value);
+ }
+ // https://github.com/squizlabs/PHP_CodeSniffer/pull/1363
+ case 'keyname':
+ $pos = $this->getSetting('position');
+ if (! is_string($pos)) {
+ throw new InvalidArgumentException(sprintf(
+ '"%s" is not a valid array key name',
+ $pos
+ ));
+ }
+
+ if (array_key_exists($pos, $value)) {
+ return $value[$pos];
+ } else {
+ return $this->emptyValue($value);
+ }
+ // https://github.com/squizlabs/PHP_CodeSniffer/pull/1363
+ default:
+ throw new ConfigurationError(
+ '"%s" is not a valid array position_type',
+ $this->getSetting('position_type')
+ );
+ }
+ }
+
+ /**
+ * @return string
+ * @throws ConfigurationError
+ */
+ protected function getPositionForError()
+ {
+ switch ($this->getSetting('position_type')) {
+ case 'first':
+ return 'first';
+ case 'last':
+ return 'last';
+ case 'fixed':
+ return '#' . $this->getSetting('position');
+ case 'keyname':
+ return '#' . $this->getSetting('position');
+ default:
+ throw new ConfigurationError(
+ '"%s" is not a valid array position_type',
+ $this->getSetting('position_type')
+ );
+ }
+ }
+
+ /**
+ * @param $value
+ * @return null
+ * @throws ConfigurationError
+ */
+ protected function emptyValue($value)
+ {
+ if ($this->getSetting('when_missing', 'fail') === 'null') {
+ return null;
+ } else {
+ throw new InvalidArgumentException(sprintf(
+ 'There is no %s element in %s',
+ $this->getPositionForError(),
+ json_encode($value)
+ ));
+ }
+ }
+}
diff --git a/library/Director/PropertyModifier/PropertyModifierArrayFilter.php b/library/Director/PropertyModifier/PropertyModifierArrayFilter.php
new file mode 100644
index 0000000..0b52987
--- /dev/null
+++ b/library/Director/PropertyModifier/PropertyModifierArrayFilter.php
@@ -0,0 +1,152 @@
+<?php
+
+namespace Icinga\Module\Director\PropertyModifier;
+
+use Icinga\Data\Filter\FilterExpression;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Exception\InvalidPropertyException;
+use Icinga\Module\Director\Hook\PropertyModifierHook;
+use Icinga\Module\Director\Web\Form\QuickForm;
+
+class PropertyModifierArrayFilter extends PropertyModifierHook
+{
+ /** @var FilterExpression */
+ private $filterExpression;
+
+ public function getName()
+ {
+ return 'Filter Array Values';
+ }
+
+ public function hasArraySupport()
+ {
+ return true;
+ }
+
+ public static function addSettingsFormFields(QuickForm $form)
+ {
+ $form->addElement('select', 'filter_method', array(
+ 'label' => $form->translate('Filter method'),
+ 'required' => true,
+ 'value' => 'wildcard',
+ 'multiOptions' => $form->optionalEnum(array(
+ 'wildcard' => $form->translate('Simple match with wildcards (*)'),
+ 'regex' => $form->translate('Regular Expression'),
+ )),
+ ));
+
+ $form->addElement('text', 'filter_string', array(
+ 'label' => 'Filter',
+ 'description' => $form->translate(
+ 'The string/pattern you want to search for. Depends on the'
+ . ' chosen method, use www.* or *linux* for wildcard matches'
+ . ' and expression like /^www\d+\./ in case you opted for a'
+ . ' regular expression'
+ ),
+ 'required' => true,
+ ));
+
+ $form->addElement('select', 'policy', array(
+ 'label' => $form->translate('Policy'),
+ 'required' => true,
+ 'description' => $form->translate(
+ 'What should happen with matching elements?'
+ ),
+ 'value' => 'keep',
+ 'multiOptions' => array(
+ 'keep' => $form->translate('Keep matching elements'),
+ 'reject' => $form->translate('Reject matching elements'),
+ ),
+ ));
+
+ $form->addElement('select', 'when_empty', array(
+ 'label' => $form->translate('When empty'),
+ 'required' => true,
+ 'description' => $form->translate(
+ 'What should happen when the result array is empty?'
+ ),
+ 'value' => 'empty_array',
+ 'multiOptions' => $form->optionalEnum(array(
+ 'empty_array' => $form->translate('return an empty array'),
+ 'null' => $form->translate('return NULL'),
+ ))
+ ));
+ }
+
+ public function matchesRegexp($string, $expression)
+ {
+ return preg_match($expression, $string);
+ }
+
+ public function matchesWildcard($string, $expression)
+ {
+ return $this->filterExpression->matches(
+ (object) array('value' => $string)
+ );
+ }
+
+ public function transform($value)
+ {
+ if (empty($value)) {
+ return $this->emptyValue();
+ }
+
+ if (is_string($value)) {
+ $value = [$value];
+ }
+
+ if (! is_array($value)) {
+ throw new InvalidPropertyException(
+ 'The ArrayFilter property modifier be applied to arrays only'
+ );
+ }
+
+ $method = $this->getSetting('filter_method');
+ $filter = $this->getSetting('filter_string');
+ $policy = $this->getSetting('policy');
+
+ switch ($method) {
+ case 'wildcard':
+ $func = 'matchesWildcard';
+ $this->filterExpression = new FilterExpression('value', '=', $filter);
+ break;
+ case 'regex':
+ $func = 'matchesRegexp';
+ break;
+ default:
+ throw new ConfigurationError(
+ '%s is not a valid value for an ArrayFilter filter_method',
+ var_export($method, 1)
+ );
+ }
+
+ $result = array();
+
+ foreach ($value as $val) {
+ if ($this->$func($val, $filter)) {
+ if ($policy === 'keep') {
+ $result[] = $val;
+ }
+ } else {
+ if ($policy === 'reject') {
+ $result[] = $val;
+ }
+ }
+ }
+
+ if (empty($result)) {
+ return $this->emptyValue();
+ }
+
+ return $result;
+ }
+
+ protected function emptyValue()
+ {
+ if ($this->getSetting('when_empty', 'empty_array') === 'empty_array') {
+ return array();
+ } else {
+ return null;
+ }
+ }
+}
diff --git a/library/Director/PropertyModifier/PropertyModifierArrayToRow.php b/library/Director/PropertyModifier/PropertyModifierArrayToRow.php
new file mode 100644
index 0000000..35ae063
--- /dev/null
+++ b/library/Director/PropertyModifier/PropertyModifierArrayToRow.php
@@ -0,0 +1,68 @@
+<?php
+
+namespace Icinga\Module\Director\PropertyModifier;
+
+use Icinga\Module\Director\Hook\PropertyModifierHook;
+use Icinga\Module\Director\Web\Form\QuickForm;
+use InvalidArgumentException;
+use ipl\Html\Error;
+
+class PropertyModifierArrayToRow extends PropertyModifierHook
+{
+ public function getName()
+ {
+ return 'Clone the row for every entry of an Array';
+ }
+
+ public static function addSettingsFormFields(QuickForm $form)
+ {
+ $form->addElement('select', 'on_empty', [
+ 'label' => 'When empty',
+ 'description' => $form->translate('What should we do in case the given value is empty?'),
+ 'multiOptions' => $form->optionalEnum([
+ 'reject' => $form->translate('Drop the current row'),
+ 'fail' => $form->translate('Let the whole import run fail'),
+ 'keep' => $form->translate('Keep the row, set the column value to null'),
+ ]),
+ 'value' => 'reject',
+ 'required' => true,
+ ]);
+ }
+
+ public function hasArraySupport()
+ {
+ return true;
+ }
+
+ public function expandsRows()
+ {
+ return true;
+ }
+
+ public function transform($value)
+ {
+ if (empty($value)) {
+ $onDuplicate = $this->getSetting('on_empty', 'reject');
+ switch ($onDuplicate) {
+ case 'reject':
+ return [];
+ case 'keep':
+ return [null];
+ case 'fail':
+ throw new InvalidArgumentException('Failed to clone row, value is empty');
+ default:
+ throw new InvalidArgumentException(
+ "'$onDuplicate' is not a valid 'on_duplicate' setting"
+ );
+ }
+ }
+
+ if (! \is_array($value)) {
+ throw new InvalidArgumentException(
+ "Array required to clone this row, got " . Error::getPhpTypeName($value)
+ );
+ }
+
+ return $value;
+ }
+}
diff --git a/library/Director/PropertyModifier/PropertyModifierArrayUnique.php b/library/Director/PropertyModifier/PropertyModifierArrayUnique.php
new file mode 100644
index 0000000..e3446f9
--- /dev/null
+++ b/library/Director/PropertyModifier/PropertyModifierArrayUnique.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace Icinga\Module\Director\PropertyModifier;
+
+use Icinga\Exception\InvalidPropertyException;
+use Icinga\Module\Director\Hook\PropertyModifierHook;
+use function array_unique;
+use function array_values;
+use function is_array;
+
+class PropertyModifierArrayUnique extends PropertyModifierHook
+{
+ public function getName()
+ {
+ return 'Unique Array Values';
+ }
+
+ public function hasArraySupport()
+ {
+ return true;
+ }
+
+ public function transform($value)
+ {
+ if (empty($value)) {
+ return $value;
+ }
+
+ if (! is_array($value)) {
+ throw new InvalidPropertyException(
+ 'The ArrayUnique property modifier can be applied to arrays only'
+ );
+ }
+
+ return array_values(array_unique($value));
+ }
+}
diff --git a/library/Director/PropertyModifier/PropertyModifierBitmask.php b/library/Director/PropertyModifier/PropertyModifierBitmask.php
new file mode 100644
index 0000000..d334f09
--- /dev/null
+++ b/library/Director/PropertyModifier/PropertyModifierBitmask.php
@@ -0,0 +1,39 @@
+<?php
+
+namespace Icinga\Module\Director\PropertyModifier;
+
+use Icinga\Exception\InvalidPropertyException;
+use Icinga\Module\Director\Hook\PropertyModifierHook;
+use Icinga\Module\Director\Web\Form\QuickForm;
+
+class PropertyModifierBitmask extends PropertyModifierHook
+{
+ public static function addSettingsFormFields(QuickForm $form)
+ {
+ $form->addElement('text', 'bitmask', array(
+ 'label' => 'Bitmask',
+ 'description' => $form->translate(
+ 'The numeric bitmask you want to apply. In case you have a hexadecimal'
+ . ' or binary mask please transform it to a decimal number first. The'
+ . ' result of this modifier is a boolean value, telling whether the'
+ . ' given mask applies to the numeric value in your source column'
+ ),
+ 'required' => true,
+ ));
+ }
+
+ public function getName()
+ {
+ return 'Bitmask match (numeric)';
+ }
+
+ public function transform($value)
+ {
+ if ($value === null) {
+ return null;
+ }
+
+ $mask = (int) $this->getSetting('bitmask');
+ return (((int) $value) & $mask) === $mask;
+ }
+}
diff --git a/library/Director/PropertyModifier/PropertyModifierCombine.php b/library/Director/PropertyModifier/PropertyModifierCombine.php
new file mode 100644
index 0000000..5be09ea
--- /dev/null
+++ b/library/Director/PropertyModifier/PropertyModifierCombine.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace Icinga\Module\Director\PropertyModifier;
+
+use Icinga\Module\Director\Hook\PropertyModifierHook;
+use Icinga\Module\Director\Import\SyncUtils;
+use Icinga\Module\Director\Web\Form\QuickForm;
+
+class PropertyModifierCombine extends PropertyModifierHook
+{
+ public static function addSettingsFormFields(QuickForm $form)
+ {
+ $form->addElement('text', 'pattern', array(
+ 'label' => $form->translate('Pattern'),
+ 'required' => false,
+ 'description' => $form->translate(
+ 'This pattern will be evaluated, and variables like ${some_column}'
+ . ' will be filled accordingly. A typical use-case is generating'
+ . ' unique service identifiers via ${host}!${service} in case your'
+ . ' data source doesn\'t allow you to ship such. The chosen "property"'
+ . ' has no effect here and will be ignored.'
+ )
+ ));
+ }
+
+ public function getName()
+ {
+ return 'Combine multiple properties';
+ }
+
+ public function requiresRow()
+ {
+ return true;
+ }
+
+ public function transform($value)
+ {
+ return SyncUtils::fillVariables($this->getSetting('pattern'), $this->getRow());
+ }
+}
diff --git a/library/Director/PropertyModifier/PropertyModifierDictionaryToRow.php b/library/Director/PropertyModifier/PropertyModifierDictionaryToRow.php
new file mode 100644
index 0000000..2a60ab3
--- /dev/null
+++ b/library/Director/PropertyModifier/PropertyModifierDictionaryToRow.php
@@ -0,0 +1,94 @@
+<?php
+
+namespace Icinga\Module\Director\PropertyModifier;
+
+use Icinga\Module\Director\Data\InvalidDataException;
+use Icinga\Module\Director\Hook\PropertyModifierHook;
+use Icinga\Module\Director\Web\Form\QuickForm;
+use InvalidArgumentException;
+use ipl\Html\Error;
+
+class PropertyModifierDictionaryToRow extends PropertyModifierHook
+{
+ public function getName()
+ {
+ return 'Clone the row for every entry of a nested Dictionary/Hash structure';
+ }
+
+ public static function addSettingsFormFields(QuickForm $form)
+ {
+ $form->addElement('text', 'key_column', [
+ 'label' => $form->translate('Key Property Name'),
+ 'description' => $form->translate(
+ 'Every Dictionary entry has a key, its value will be provided in this column'
+ )
+ ]);
+ $form->addElement('select', 'on_empty', [
+ 'label' => $form->translate('When empty'),
+ 'description' => $form->translate('What should we do in case the given value is empty?'),
+ 'multiOptions' => $form->optionalEnum([
+ 'reject' => $form->translate('Drop the current row'),
+ 'fail' => $form->translate('Let the whole import run fail'),
+ 'keep' => $form->translate('Keep the row, set the column value to null'),
+ ]),
+ 'value' => 'reject',
+ 'required' => true,
+ ]);
+ }
+
+ public function requiresRow()
+ {
+ return true;
+ }
+
+ public function hasArraySupport()
+ {
+ return true;
+ }
+
+ public function expandsRows()
+ {
+ return true;
+ }
+
+ public function transform($value)
+ {
+ if (empty($value)) {
+ $onDuplicate = $this->getSetting('on_empty', 'reject');
+ switch ($onDuplicate) {
+ case 'reject':
+ return [];
+ case 'keep':
+ return [null];
+ case 'fail':
+ throw new InvalidArgumentException('Failed to clone row, value is empty');
+ default:
+ throw new InvalidArgumentException(
+ "'$onDuplicate' is not a valid 'on_duplicate' setting"
+ );
+ }
+ }
+
+ $keyColumn = $this->getSetting('key_column');
+
+ if (! \is_object($value)) {
+ throw new InvalidArgumentException(
+ "Object required to clone this row, got " . Error::getPhpTypeName($value)
+ );
+ }
+ $result = [];
+ foreach ($value as $key => $properties) {
+ if (! is_object($properties)) {
+ throw new InvalidDataException(
+ sprintf('Nested "%s" dictionary', $key),
+ $properties
+ );
+ }
+
+ $properties->$keyColumn = $key;
+ $result[] = $properties;
+ }
+
+ return $result;
+ }
+}
diff --git a/library/Director/PropertyModifier/PropertyModifierDnsRecords.php b/library/Director/PropertyModifier/PropertyModifierDnsRecords.php
new file mode 100644
index 0000000..d5d8d41
--- /dev/null
+++ b/library/Director/PropertyModifier/PropertyModifierDnsRecords.php
@@ -0,0 +1,112 @@
+<?php
+
+namespace Icinga\Module\Director\PropertyModifier;
+
+use Icinga\Exception\InvalidPropertyException;
+use Icinga\Module\Director\Hook\PropertyModifierHook;
+use Icinga\Module\Director\Web\Form\QuickForm;
+
+class PropertyModifierDnsRecords extends PropertyModifierHook
+{
+ protected static $types = array(
+ 'A' => DNS_A,
+ 'AAAA' => DNS_AAAA,
+ 'CNAME' => DNS_CNAME,
+ 'MX' => DNS_MX,
+ 'NS' => DNS_NS,
+ 'PTR' => DNS_PTR,
+ 'TXT' => DNS_TXT,
+ );
+
+ public static function addSettingsFormFields(QuickForm $form)
+ {
+ $form->addElement('select', 'record_type', array(
+ 'label' => 'Record type',
+ 'description' => $form->translate('DNS record type'),
+ 'multiOptions' => $form->optionalEnum(static::enumTypes()),
+ 'required' => true,
+ ));
+
+ $form->addElement('select', 'on_failure', array(
+ 'label' => 'On failure',
+ 'description' => $form->translate('What should we do if the DNS lookup fails?'),
+ 'multiOptions' => $form->optionalEnum(array(
+ 'null' => $form->translate('Set no value (null)'),
+ 'keep' => $form->translate('Keep the property as is'),
+ 'fail' => $form->translate('Let the whole import run fail'),
+ )),
+ 'required' => true,
+ ));
+ }
+
+ protected static function enumTypes()
+ {
+ $types = array_keys(self::$types);
+ return array_combine($types, $types);
+ }
+
+ public function getName()
+ {
+ return 'Get DNS records of a specific type';
+ }
+
+ public function transform($value)
+ {
+ if ($value === null) {
+ return null;
+ }
+
+ $type = self::$types[$this->getSetting('record_type')];
+ $response = dns_get_record($value, $type);
+
+ if ($response === false) {
+ switch ($this->getSetting('on_failure')) {
+ case 'null':
+ return null;
+ case 'keep':
+ return $value;
+ case 'fail':
+ default:
+ throw new InvalidPropertyException(
+ 'DNS lookup failed for "%s"',
+ $value
+ );
+ }
+ }
+
+ $result = array();
+ switch ($type) {
+ case DNS_A:
+ return $this->extractProperty('ip', $response);
+ case DNS_AAAA:
+ return $this->extractProperty('ipv6', $response);
+ case DNS_CNAME:
+ case DNS_MX:
+ case DNS_NS:
+ case DNS_PTR:
+ return $this->extractProperty('target', $response);
+ case DNS_TXT:
+ return $this->extractProperty('txt', $response);
+ return $response;
+ }
+
+ return $result;
+ }
+
+ protected function extractProperty($key, $response)
+ {
+ $result = array();
+ foreach ($response as $entry) {
+ $result[] = $entry[$key];
+ }
+
+ if (version_compare(PHP_VERSION, '5.4.0') >= 0) {
+ sort($result, SORT_NATURAL);
+ } else {
+ natsort($result);
+ $result = array_values($result);
+ }
+
+ return $result;
+ }
+}
diff --git a/library/Director/PropertyModifier/PropertyModifierExtractFromDN.php b/library/Director/PropertyModifier/PropertyModifierExtractFromDN.php
new file mode 100644
index 0000000..c79c5b2
--- /dev/null
+++ b/library/Director/PropertyModifier/PropertyModifierExtractFromDN.php
@@ -0,0 +1,99 @@
+<?php
+
+namespace Icinga\Module\Director\PropertyModifier;
+
+use Icinga\Exception\InvalidPropertyException;
+use Icinga\Module\Director\Hook\PropertyModifierHook;
+use Icinga\Module\Director\Web\Form\QuickForm;
+use Icinga\Protocol\Ldap\LdapUtils;
+
+class PropertyModifierExtractFromDN extends PropertyModifierHook
+{
+ public static function addSettingsFormFields(QuickForm $form)
+ {
+ $form->addElement('select', 'dn_component', array(
+ 'label' => $form->translate('DN component'),
+ 'description' => $form->translate('What should we extract from the DN?'),
+ 'multiOptions' => $form->optionalEnum(array(
+ 'cn' => $form->translate('The first (leftmost) CN'),
+ 'ou' => $form->translate('The first (leftmost) OU'),
+ 'first' => $form->translate('Any first (leftmost) component'),
+ 'last_ou' => $form->translate('The last (rightmost) OU'),
+ )),
+ 'required' => true,
+ ));
+
+ $form->addElement('select', 'on_failure', array(
+ 'label' => $form->translate('On failure'),
+ 'description' => $form->translate('What should we do if the desired part does not exist?'),
+ 'multiOptions' => $form->optionalEnum(array(
+ 'null' => $form->translate('Set no value (null)'),
+ 'keep' => $form->translate('Keep the DN as is'),
+ 'fail' => $form->translate('Let the whole import run fail'),
+ )),
+ 'required' => true,
+ ));
+ }
+
+ public function getName()
+ {
+ return 'Extract from a Distinguished Name (DN)';
+ }
+
+ public function transform($value)
+ {
+ if ($value === null) {
+ return null;
+ }
+
+ $parts = LdapUtils::explodeDN($value);
+ $result = null;
+
+ switch ($this->getSetting('dn_component')) {
+ case 'cn':
+ $result = $this->extractFirst($parts, 'cn');
+ break;
+ case 'ou':
+ $result = $this->extractFirst($parts, 'ou');
+ break;
+ case 'last_ou':
+ $result = $this->extractFirst(array_reverse($parts), 'ou');
+ break;
+ case 'first':
+ $result = $this->extractFirst($parts);
+ break;
+ }
+
+ if ($result === null) {
+ switch ($this->getSetting('on_failure')) {
+ case 'null':
+ return null;
+ case 'keep':
+ return $value;
+ case 'fail':
+ default:
+ throw new InvalidPropertyException(
+ 'DN part extraction failed for %s',
+ var_export($value, 1)
+ );
+ }
+ }
+
+ return $result;
+ }
+
+ protected function extractFirst($parts, $what = null)
+ {
+ foreach ($parts as $part) {
+ if (false === ($pos = strpos($part, '='))) {
+ continue;
+ }
+
+ if (null === $what || strtolower(substr($part, 0, $pos)) === $what) {
+ return substr($part, $pos + 1);
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/library/Director/PropertyModifier/PropertyModifierFromAdSid.php b/library/Director/PropertyModifier/PropertyModifierFromAdSid.php
new file mode 100644
index 0000000..ee306e3
--- /dev/null
+++ b/library/Director/PropertyModifier/PropertyModifierFromAdSid.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace Icinga\Module\Director\PropertyModifier;
+
+use Icinga\Module\Director\Hook\PropertyModifierHook;
+
+class PropertyModifierFromAdSid extends PropertyModifierHook
+{
+ public function getName()
+ {
+ return 'Decode a binary object SID (MSAD)';
+ }
+
+ public function transform($value)
+ {
+ if ($value === null) {
+ return null;
+ }
+
+ // Strongly inspired by
+ // http://www.chadsikorra.com/blog/decoding-and-encoding-active-directory-objectsid-php
+ //
+ // Not perfect yet, but should suffice for now. When improving this please also see:
+ // https://blogs.msdn.microsoft.com/oldnewthing/20040315-00/?p=40253
+
+ $sid = $value;
+ $sidHex = unpack('H*hex', $value);
+ $sidHex = $sidHex['hex'];
+ $subAuths = implode('-', unpack('H2/H2/n/N/V*', $sid));
+
+ $revLevel = hexdec(substr($sidHex, 0, 2));
+ $authIdent = hexdec(substr($sidHex, 4, 12));
+
+ return sprintf('S-%s-%s-%s', $revLevel, $authIdent, $subAuths);
+ }
+}
diff --git a/library/Director/PropertyModifier/PropertyModifierFromLatin1.php b/library/Director/PropertyModifier/PropertyModifierFromLatin1.php
new file mode 100644
index 0000000..272956c
--- /dev/null
+++ b/library/Director/PropertyModifier/PropertyModifierFromLatin1.php
@@ -0,0 +1,23 @@
+<?php
+
+namespace Icinga\Module\Director\PropertyModifier;
+
+use Icinga\Module\Director\Hook\PropertyModifierHook;
+use function iconv;
+
+class PropertyModifierFromLatin1 extends PropertyModifierHook
+{
+ public function getName()
+ {
+ return 'Convert a latin1 string to utf8';
+ }
+
+ public function transform($value)
+ {
+ if ($value === null) {
+ return null;
+ }
+
+ return iconv('ISO-8859-15', 'UTF-8', $value);
+ }
+}
diff --git a/library/Director/PropertyModifier/PropertyModifierGetHostByAddr.php b/library/Director/PropertyModifier/PropertyModifierGetHostByAddr.php
new file mode 100644
index 0000000..2b0f9c3
--- /dev/null
+++ b/library/Director/PropertyModifier/PropertyModifierGetHostByAddr.php
@@ -0,0 +1,53 @@
+<?php
+
+namespace Icinga\Module\Director\PropertyModifier;
+
+use Icinga\Exception\InvalidPropertyException;
+use Icinga\Module\Director\Hook\PropertyModifierHook;
+use Icinga\Module\Director\Web\Form\QuickForm;
+
+class PropertyModifierGetHostByAddr extends PropertyModifierHook
+{
+ public static function addSettingsFormFields(QuickForm $form)
+ {
+ $form->addElement('select', 'on_failure', array(
+ 'label' => 'On failure',
+ 'description' => $form->translate('What should we do if the host (DNS) lookup fails?'),
+ 'multiOptions' => $form->optionalEnum(array(
+ 'null' => $form->translate('Set no value (null)'),
+ 'keep' => $form->translate('Keep the property (hostname) as is'),
+ 'fail' => $form->translate('Let the whole import run fail'),
+ )),
+ 'required' => true,
+ ));
+ }
+
+ public function getName()
+ {
+ return mt('director', 'Get host by address (Reverse DNS lookup)');
+ }
+
+ public function transform($value)
+ {
+ if ($value === null) {
+ return null;
+ }
+ $host = gethostbyaddr($value);
+ if ($host === false) {
+ switch ($this->getSetting('on_failure')) {
+ case 'null':
+ return null;
+ case 'keep':
+ return $value;
+ case 'fail':
+ default:
+ throw new InvalidPropertyException(
+ 'Reverse Host lookup failed for "%s"',
+ $value
+ );
+ }
+ }
+
+ return $host;
+ }
+}
diff --git a/library/Director/PropertyModifier/PropertyModifierGetHostByName.php b/library/Director/PropertyModifier/PropertyModifierGetHostByName.php
new file mode 100644
index 0000000..36884e8
--- /dev/null
+++ b/library/Director/PropertyModifier/PropertyModifierGetHostByName.php
@@ -0,0 +1,54 @@
+<?php
+
+namespace Icinga\Module\Director\PropertyModifier;
+
+use Icinga\Exception\InvalidPropertyException;
+use Icinga\Module\Director\Hook\PropertyModifierHook;
+use Icinga\Module\Director\Web\Form\QuickForm;
+
+class PropertyModifierGetHostByName extends PropertyModifierHook
+{
+ public static function addSettingsFormFields(QuickForm $form)
+ {
+ $form->addElement('select', 'on_failure', array(
+ 'label' => 'On failure',
+ 'description' => $form->translate('What should we do if the host (DNS) lookup fails?'),
+ 'multiOptions' => $form->optionalEnum(array(
+ 'null' => $form->translate('Set no value (null)'),
+ 'keep' => $form->translate('Keep the property (hostname) as is'),
+ 'fail' => $form->translate('Let the whole import run fail'),
+ )),
+ 'required' => true,
+ ));
+ }
+
+ public function getName()
+ {
+ return mt('director', 'Get host by name (DNS lookup)');
+ }
+
+ public function transform($value)
+ {
+ if ($value === null) {
+ return null;
+ }
+
+ $host = gethostbyname($value);
+ if (strlen(@inet_pton($host)) !== 4) {
+ switch ($this->getSetting('on_failure')) {
+ case 'null':
+ return null;
+ case 'keep':
+ return $value;
+ case 'fail':
+ default:
+ throw new InvalidPropertyException(
+ 'Host lookup failed for "%s"',
+ $value
+ );
+ }
+ }
+
+ return $host;
+ }
+}
diff --git a/library/Director/PropertyModifier/PropertyModifierGetPropertyFromOtherImportSource.php b/library/Director/PropertyModifier/PropertyModifierGetPropertyFromOtherImportSource.php
new file mode 100644
index 0000000..d57b427
--- /dev/null
+++ b/library/Director/PropertyModifier/PropertyModifierGetPropertyFromOtherImportSource.php
@@ -0,0 +1,129 @@
+<?php
+
+namespace Icinga\Module\Director\PropertyModifier;
+
+use Icinga\Module\Director\Hook\PropertyModifierHook;
+use Icinga\Module\Director\Objects\ImportRowModifier;
+use Icinga\Module\Director\Objects\ImportSource;
+use Icinga\Module\Director\Web\Form\DirectorObjectForm;
+use Icinga\Module\Director\Web\Form\QuickForm;
+
+class PropertyModifierGetPropertyFromOtherImportSource extends PropertyModifierHook
+{
+ protected $importSource;
+
+ private $importedData;
+
+ public function getName()
+ {
+ return 'Get a property from another Import Source';
+ }
+
+ /**
+ * @inheritdoc
+ * @throws \Zend_Form_Exception
+ */
+ public static function addSettingsFormFields(QuickForm $form)
+ {
+ if (! $form instanceof DirectorObjectForm) {
+ throw new \RuntimeException('This property modifier works only with a DirectorObjectForm');
+ }
+ $db = $form->getDb();
+ $form->addElement('select', 'import_source_id', [
+ 'label' => $form->translate('Import Source'),
+ 'description' => $form->translate(
+ 'Another Import Source. We\'re going to look up the row with the'
+ . ' key matching the value in the chosen column'
+ ),
+ 'required' => true,
+ 'multiOptions' => $form->optionalEnum($db->enumImportSource()),
+ 'class' => 'autosubmit',
+ ]);
+
+ if ($form->hasBeenSent()) {
+ $sourceId = $form->getSentValue('import_source_id');
+ } else {
+ $object = $form->getObject();
+ if ($object instanceof ImportRowModifier) {
+ $sourceId = $object->getSetting('import_source_id');
+ } else {
+ $sourceId = null;
+ }
+ }
+ $extra = [];
+ if ($sourceId) {
+ $extra = [
+ 'class' => 'director-suggest',
+ 'data-suggestion-context' => 'importsourceproperties!' . (int) $sourceId,
+ ];
+ }
+ $form->addElement('text', 'foreign_property', [
+ 'label' => $form->translate('Property'),
+ 'required' => true,
+ 'description' => $form->translate(
+ 'The property to get from the row we found in the chosen Import Source'
+ ),
+ ] + $extra);
+ }
+
+ /**
+ * @param $settings
+ * @return PropertyModifierHook
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function setSettings(array $settings)
+ {
+ if (isset($settings['import_source'])) {
+ $settings['import_source_id'] = ImportSource::load(
+ $settings['import_source'],
+ $this->getDb()
+ )->get('id');
+ unset($settings['import_source']);
+ }
+
+ return parent::setSettings($settings);
+ }
+
+ public function transform($value)
+ {
+ $data = $this->getImportedData();
+
+ if (isset($data[$value])) {
+ return $data[$value]->{$this->getSetting('foreign_property')};
+ } else {
+ return null;
+ }
+ }
+
+ public function exportSettings()
+ {
+ $settings = parent::exportSettings();
+ $settings->import_source = $this->getImportSource()->getObjectName();
+ unset($settings->import_source_id);
+
+ return $settings;
+ }
+
+ protected function & getImportedData()
+ {
+ if ($this->importedData === null) {
+ $this->importedData = $this->getImportSource()
+ ->fetchLastRun(true)
+ ->fetchRows([$this->getSetting('foreign_property')]);
+ }
+
+ return $this->importedData;
+ }
+
+ protected function getImportSource()
+ {
+ if ($this->importSource === null) {
+ $this->importSource = ImportSource::loadWithAutoIncId(
+ $this->getSetting('import_source_id'),
+ $this->getDb()
+ );
+ }
+
+ return $this->importSource;
+ }
+}
diff --git a/library/Director/PropertyModifier/PropertyModifierJoin.php b/library/Director/PropertyModifier/PropertyModifierJoin.php
new file mode 100644
index 0000000..daa6fdb
--- /dev/null
+++ b/library/Director/PropertyModifier/PropertyModifierJoin.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Icinga\Module\Director\PropertyModifier;
+
+use Icinga\Module\Director\Hook\PropertyModifierHook;
+use Icinga\Module\Director\Web\Form\QuickForm;
+
+class PropertyModifierJoin extends PropertyModifierHook
+{
+ public static function addSettingsFormFields(QuickForm $form)
+ {
+ $form->addElement('text', 'glue', array(
+ 'label' => $form->translate('Glue'),
+ 'required' => false,
+ 'description' => $form->translate(
+ 'One or more characters that will be used to glue an input array to a string. Can be left empty'
+ )
+ ));
+ }
+
+ public function hasArraySupport()
+ {
+ return true;
+ }
+
+ public function transform($value)
+ {
+ if ($value === null) {
+ return null;
+ }
+
+ return implode($this->getSetting('glue'), $value);
+ }
+}
diff --git a/library/Director/PropertyModifier/PropertyModifierJsonDecode.php b/library/Director/PropertyModifier/PropertyModifierJsonDecode.php
new file mode 100644
index 0000000..f6b9af8
--- /dev/null
+++ b/library/Director/PropertyModifier/PropertyModifierJsonDecode.php
@@ -0,0 +1,68 @@
+<?php
+
+namespace Icinga\Module\Director\PropertyModifier;
+
+use Icinga\Exception\InvalidPropertyException;
+use Icinga\Module\Director\Exception\JsonException;
+use Icinga\Module\Director\Hook\PropertyModifierHook;
+use Icinga\Module\Director\Web\Form\QuickForm;
+
+class PropertyModifierJsonDecode extends PropertyModifierHook
+{
+ /**
+ * @param QuickForm $form
+ * @return QuickForm|void
+ * @throws \Zend_Form_Exception
+ */
+ public static function addSettingsFormFields(QuickForm $form)
+ {
+ $form->addElement('select', 'on_failure', array(
+ 'label' => 'On failure',
+ 'description' => $form->translate(
+ 'What should we do in case we are unable to decode the given string?'
+ ),
+ 'multiOptions' => $form->optionalEnum(array(
+ 'null' => $form->translate('Set no value (null)'),
+ 'keep' => $form->translate('Keep the JSON string as is'),
+ 'fail' => $form->translate('Let the whole import run fail'),
+ )),
+ 'required' => true,
+ ));
+ }
+
+ public function getName()
+ {
+ return 'Decode a JSON string';
+ }
+
+ /**
+ * @param $value
+ * @return mixed|null
+ * @throws InvalidPropertyException
+ */
+ public function transform($value)
+ {
+ if (null === $value) {
+ return $value;
+ }
+
+ $decoded = @json_decode($value);
+ if ($decoded === null && JSON_ERROR_NONE !== json_last_error()) {
+ switch ($this->getSetting('on_failure')) {
+ case 'null':
+ return null;
+ case 'keep':
+ return $value;
+ case 'fail':
+ default:
+ throw new InvalidPropertyException(
+ 'JSON decoding failed with "%s" for %s',
+ JsonException::getJsonErrorMessage(json_last_error()),
+ substr($value, 0, 128)
+ );
+ }
+ }
+
+ return $decoded;
+ }
+}
diff --git a/library/Director/PropertyModifier/PropertyModifierLConfCustomVar.php b/library/Director/PropertyModifier/PropertyModifierLConfCustomVar.php
new file mode 100644
index 0000000..35db6c8
--- /dev/null
+++ b/library/Director/PropertyModifier/PropertyModifierLConfCustomVar.php
@@ -0,0 +1,48 @@
+<?php
+
+namespace Icinga\Module\Director\PropertyModifier;
+
+use Icinga\Module\Director\Hook\PropertyModifierHook;
+
+class PropertyModifierLConfCustomVar extends PropertyModifierHook
+{
+ public function transform($value)
+ {
+ if ($value === null) {
+ return null;
+ }
+
+ $vars = (object) array();
+ $this->extractLConfVars($value, $vars);
+
+ return $vars;
+ }
+
+ public function getName()
+ {
+ return 'Transform LConf CustomVars to Hash';
+ }
+
+ public function hasArraySupport()
+ {
+ return true;
+ }
+
+ protected function extractLConfVars($value, $vars)
+ {
+ if (is_string($value)) {
+ $this->extractLConfVar($value, $vars);
+ } elseif (is_array($value)) {
+ foreach ($value as $val) {
+ $this->extractLConfVar($val, $vars);
+ }
+ }
+ }
+
+ protected function extractLConfVar($value, $vars)
+ {
+ list($key, $val) = preg_split('/ /', $value, 2);
+ $key = ltrim($key, '_');
+ $vars->$key = $val;
+ }
+}
diff --git a/library/Director/PropertyModifier/PropertyModifierListToObject.php b/library/Director/PropertyModifier/PropertyModifierListToObject.php
new file mode 100644
index 0000000..9889c8f
--- /dev/null
+++ b/library/Director/PropertyModifier/PropertyModifierListToObject.php
@@ -0,0 +1,95 @@
+<?php
+
+namespace Icinga\Module\Director\PropertyModifier;
+
+use Icinga\Module\Director\Hook\PropertyModifierHook;
+use Icinga\Module\Director\Web\Form\QuickForm;
+use InvalidArgumentException;
+use ipl\Html\Error;
+
+class PropertyModifierListToObject extends PropertyModifierHook
+{
+ public static function addSettingsFormFields(QuickForm $form)
+ {
+ $form->addElement('text', 'key_property', [
+ 'label' => $form->translate('Key Property'),
+ 'required' => true,
+ 'description' => $form->translate(
+ 'Each Array in the list must contain this property. It\'s value'
+ . ' will be used as the key/object property name for the row.'
+ )
+ ]);
+ $form->addElement('select', 'on_duplicate', [
+ 'label' => 'On duplicate key',
+ 'description' => $form->translate('What should we do, if the same key occurs twice?'),
+ 'multiOptions' => $form->optionalEnum([
+ 'fail' => $form->translate('Let the whole import run fail'),
+ 'keep_first' => $form->translate('Keep the first row with that key'),
+ 'keep_last' => $form->translate('Keep the last row with that key'),
+ ]),
+ 'required' => true,
+ ]);
+ }
+
+ public function getName()
+ {
+ return 'Transform Array/Object list into single Object';
+ }
+
+ public function hasArraySupport()
+ {
+ return true;
+ }
+
+ public function transform($value)
+ {
+ if ($value === null) {
+ return null;
+ }
+ if (! \is_array($value)) {
+ throw new InvalidArgumentException(
+ 'Array expected, got ' . Error::getPhpTypeName($value)
+ );
+ }
+ $keyProperty = $this->getSetting('key_property');
+ $onDuplicate = $this->getSetting('on_duplicate');
+ $result = (object) [];
+ foreach ($value as $key => $row) {
+ if (\is_object($row)) {
+ $row = (array) $row;
+ }
+ if (! \is_array($row)) {
+ throw new InvalidArgumentException(
+ "List of Arrays expected expected. Array entry '$key' is "
+ . Error::getPhpTypeName($value)
+ );
+ }
+
+ if (! \array_key_exists($keyProperty, $row)) {
+ throw new InvalidArgumentException(
+ "Key property '$keyProperty' is required, but missing on row '$key'"
+ );
+ }
+
+ $property = $row[$keyProperty];
+ if (isset($result->$property)) {
+ switch ($onDuplicate) {
+ case 'fail':
+ throw new InvalidArgumentException(
+ "Duplicate row with $keyProperty=$property found on row '$key'"
+ );
+ case 'keep_first':
+ // Do nothing
+ break;
+ case 'keep_last':
+ $result->$property = (object) $row;
+ break;
+ }
+ } else {
+ $result->$property = (object) $row;
+ }
+ }
+
+ return $result;
+ }
+}
diff --git a/library/Director/PropertyModifier/PropertyModifierLowercase.php b/library/Director/PropertyModifier/PropertyModifierLowercase.php
new file mode 100644
index 0000000..1fdbb4d
--- /dev/null
+++ b/library/Director/PropertyModifier/PropertyModifierLowercase.php
@@ -0,0 +1,17 @@
+<?php
+
+namespace Icinga\Module\Director\PropertyModifier;
+
+use Icinga\Module\Director\Hook\PropertyModifierHook;
+
+class PropertyModifierLowercase extends PropertyModifierHook
+{
+ public function transform($value)
+ {
+ if ($value === null) {
+ return null;
+ }
+
+ return \mb_strtolower($value, 'UTF-8');
+ }
+}
diff --git a/library/Director/PropertyModifier/PropertyModifierMakeBoolean.php b/library/Director/PropertyModifier/PropertyModifierMakeBoolean.php
new file mode 100644
index 0000000..ed91bcf
--- /dev/null
+++ b/library/Director/PropertyModifier/PropertyModifierMakeBoolean.php
@@ -0,0 +1,90 @@
+<?php
+
+namespace Icinga\Module\Director\PropertyModifier;
+
+use Icinga\Exception\InvalidPropertyException;
+use Icinga\Module\Director\Hook\PropertyModifierHook;
+use Icinga\Module\Director\Web\Form\QuickForm;
+
+class PropertyModifierMakeBoolean extends PropertyModifierHook
+{
+ protected static $validStrings = array(
+ '0' => false,
+ 'false' => false,
+ 'n' => false,
+ 'no' => false,
+ '1' => true,
+ 'true' => true,
+ 'y' => true,
+ 'yes' => true,
+ );
+
+ public function getName()
+ {
+ return 'Convert to a boolean value';
+ }
+
+ public static function addSettingsFormFields(QuickForm $form)
+ {
+ $form->addElement('select', 'on_invalid', array(
+ 'label' => 'Invalid properties',
+ 'required' => true,
+ 'description' => $form->translate(
+ 'This modifier transforms 0/"0"/false/"false"/"n"/"no" to false'
+ . ' and 1, "1", true, "true", "y" and "yes" to true, both in a'
+ . ' case insensitive way. What should happen if the given value'
+ . ' does not match any of those?'
+ . ' You could return a null value, or default to false or true.'
+ . ' You might also consider interrupting the whole import process'
+ . ' as of invalid source data'
+ ),
+ 'multiOptions' => $form->optionalEnum(array(
+ 'null' => $form->translate('Set null'),
+ 'true' => $form->translate('Set true'),
+ 'false' => $form->translate('Set false'),
+ 'fail' => $form->translate('Let the import fail'),
+ )),
+ ));
+ }
+
+ public function transform($value)
+ {
+ if ($value === false || $value === true || $value === null) {
+ return $value;
+ }
+
+ if ($value === 0) {
+ return false;
+ }
+
+ if ($value === 1) {
+ return true;
+ }
+
+ if (is_string($value)) {
+ $value = strtolower($value);
+
+ if (array_key_exists($value, self::$validStrings)) {
+ return self::$validStrings[$value];
+ }
+ }
+
+ switch ($this->getSetting('on_invalid')) {
+ case 'null':
+ return null;
+
+ case 'false':
+ return false;
+
+ case 'true':
+ return true;
+
+ case 'fail':
+ default:
+ throw new InvalidPropertyException(
+ '"%s" cannot be converted to a boolean value',
+ $value
+ );
+ }
+ }
+}
diff --git a/library/Director/PropertyModifier/PropertyModifierMap.php b/library/Director/PropertyModifier/PropertyModifierMap.php
new file mode 100644
index 0000000..a6cb422
--- /dev/null
+++ b/library/Director/PropertyModifier/PropertyModifierMap.php
@@ -0,0 +1,97 @@
+<?php
+
+namespace Icinga\Module\Director\PropertyModifier;
+
+use Icinga\Exception\InvalidPropertyException;
+use Icinga\Module\Director\Hook\PropertyModifierHook;
+use Icinga\Module\Director\Web\Form\QuickForm;
+
+class PropertyModifierMap extends PropertyModifierHook
+{
+ private $cache;
+
+ public static function addSettingsFormFields(QuickForm $form)
+ {
+ $form->addElement('select', 'datalist_id', array(
+ 'label' => 'Lookup list',
+ 'required' => true,
+ 'description' => $form->translate(
+ 'Please choose a data list that can be used for map lookups'
+ ),
+ 'multiOptions' => $form->optionalEnum($form->getDb()->enumDatalist()),
+ ));
+
+ $form->addElement('select', 'on_missing', array(
+ 'label' => 'Missing entries',
+ 'required' => true,
+ 'description' => $form->translate(
+ 'What should happen if the lookup key does not exist in the data list?'
+ . ' You could return a null value, keep the unmodified imported value'
+ . ' or interrupt the import process'
+ ),
+ 'multiOptions' => $form->optionalEnum(array(
+ 'null' => $form->translate('Set null'),
+ 'keep' => $form->translate('Return lookup key unmodified'),
+ 'fail' => $form->translate('Let the import fail'),
+ )),
+ ));
+
+ // TODO: ignore case
+ }
+
+ public function transform($value)
+ {
+ $this->loadCache();
+ if (array_key_exists($value, $this->cache)) {
+ return $this->cache[$value];
+ }
+
+ switch ($this->getSetting('on_missing')) {
+ case 'null':
+ return null;
+
+ case 'keep':
+ return $value;
+
+ case 'fail':
+ default:
+ throw new InvalidPropertyException(
+ '"%s" cannot be found in the "%s" data list',
+ $value,
+ $this->getDatalistName()
+ );
+ }
+ }
+
+ protected function getDatalistName()
+ {
+ $db = $this->getDb()->getDbAdapter();
+ $query = $db->select()->from(
+ 'director_datalist',
+ 'list_name'
+ )->where(
+ 'id = ?',
+ $this->getSetting('datalist_id')
+ );
+ $result = $db->fetchOne($query);
+
+ return $result;
+ }
+
+ protected function loadCache($force = false)
+ {
+ if ($this->cache === null || $force) {
+ $this->cache = array();
+ $db = $this->getDb()->getDbAdapter();
+ $select = $db->select()->from(
+ 'director_datalist_entry',
+ array('entry_name', 'entry_value')
+ )->where('list_id = ?', $this->getSetting('datalist_id'))
+ ->order('entry_value');
+
+ $this->cache = $db->fetchPairs($select);
+ }
+
+ return $this;
+ }
+}
diff --git a/library/Director/PropertyModifier/PropertyModifierNegateBoolean.php b/library/Director/PropertyModifier/PropertyModifierNegateBoolean.php
new file mode 100644
index 0000000..e60d692
--- /dev/null
+++ b/library/Director/PropertyModifier/PropertyModifierNegateBoolean.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace Icinga\Module\Director\PropertyModifier;
+
+use Icinga\Module\Director\Hook\PropertyModifierHook;
+use function ipl\Stdlib\get_php_type;
+
+class PropertyModifierNegateBoolean extends PropertyModifierHook
+{
+ public function getName()
+ {
+ return 'Negate a boolean value';
+ }
+
+ public function transform($value)
+ {
+ if ($value === null) {
+ return true;
+ }
+ if (! is_bool($value)) {
+ throw new \InvalidArgumentException('Boolean expected, got ' . get_php_type($value));
+ }
+
+ return ! $value;
+ }
+}
diff --git a/library/Director/PropertyModifier/PropertyModifierParseURL.php b/library/Director/PropertyModifier/PropertyModifierParseURL.php
new file mode 100644
index 0000000..ce7c81b
--- /dev/null
+++ b/library/Director/PropertyModifier/PropertyModifierParseURL.php
@@ -0,0 +1,81 @@
+<?php
+
+namespace Icinga\Module\Director\PropertyModifier;
+
+use Icinga\Exception\InvalidPropertyException;
+use Icinga\Module\Director\Hook\PropertyModifierHook;
+use Icinga\Module\Director\Web\Form\QuickForm;
+
+class PropertyModifierParseURL extends PropertyModifierHook
+{
+
+ /**
+ * Array with possible components that can be returned from URL.
+ */
+ protected static $components = [
+ 'scheme' => PHP_URL_SCHEME,
+ 'host' => PHP_URL_HOST,
+ 'port' => PHP_URL_PORT,
+ 'path' => PHP_URL_PATH,
+ 'query' => PHP_URL_QUERY,
+ 'fragment' => PHP_URL_FRAGMENT,
+ ];
+
+ public static function addSettingsFormFields(QuickForm $form)
+ {
+ $form->addElement('select', 'url_component', [
+ 'label' => $form->translate('URL component'),
+ 'description' => $form->translate('URL component'),
+ 'multiOptions' => $form->optionalEnum(static::enumComponents()),
+ 'required' => true,
+ ]);
+
+ $form->addElement('select', 'on_failure', [
+ 'label' => $form->translate('On failure'),
+ 'description' => $form->translate(
+ 'What should we do if the URL could not get parsed or component not found?'
+ ),
+ 'multiOptions' => $form->optionalEnum([
+ 'null' => $form->translate('Set no value (null)'),
+ 'keep' => $form->translate('Keep the property as is'),
+ 'fail' => $form->translate('Let the whole import run fail'),
+ ]),
+ 'required' => true,
+ ]);
+ }
+
+ protected static function enumComponents()
+ {
+ $components = array_keys(self::$components);
+ return array_combine($components, $components);
+ }
+
+ public function getName()
+ {
+ return 'Parse a URL and return its components';
+ }
+
+ public function transform($value)
+ {
+ $component = self::$components[$this->getSetting('url_component')];
+ $response = parse_url($value, $component);
+
+ // if component not found $response will be null, false if seriously malformed URL
+ if ($response === null || $response === false) {
+ switch ($this->getSetting('on_failure')) {
+ case 'null':
+ return null;
+ case 'keep':
+ return $value;
+ case 'fail':
+ default:
+ throw new InvalidPropertyException(
+ 'Parsing URL "%s" failed.',
+ $value
+ );
+ }
+ }
+
+ return $response;
+ }
+}
diff --git a/library/Director/PropertyModifier/PropertyModifierRegexReplace.php b/library/Director/PropertyModifier/PropertyModifierRegexReplace.php
new file mode 100644
index 0000000..59cb245
--- /dev/null
+++ b/library/Director/PropertyModifier/PropertyModifierRegexReplace.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace Icinga\Module\Director\PropertyModifier;
+
+use Icinga\Module\Director\Hook\PropertyModifierHook;
+use Icinga\Module\Director\Web\Form\QuickForm;
+
+class PropertyModifierRegexReplace extends PropertyModifierHook
+{
+ public static function addSettingsFormFields(QuickForm $form)
+ {
+ $form->addElement('text', 'pattern', array(
+ 'label' => 'Regex pattern',
+ 'description' => $form->translate(
+ 'The pattern you want to search for. This can be a regular expression like /^www\d+\./'
+ ),
+ 'required' => true,
+ ));
+
+ $form->addElement('text', 'replacement', array(
+ 'label' => 'Replacement',
+ 'description' => $form->translate(
+ 'The string that should be used as a preplacement'
+ ),
+ ));
+ }
+
+ public function getName()
+ {
+ return 'Regular expression based replacement';
+ }
+
+ public function transform($value)
+ {
+ if ($value === null) {
+ return null;
+ }
+
+ return preg_replace(
+ $this->getSetting('pattern'),
+ $this->getSetting('replacement'),
+ $value
+ );
+ }
+}
diff --git a/library/Director/PropertyModifier/PropertyModifierRegexSplit.php b/library/Director/PropertyModifier/PropertyModifierRegexSplit.php
new file mode 100644
index 0000000..829810f
--- /dev/null
+++ b/library/Director/PropertyModifier/PropertyModifierRegexSplit.php
@@ -0,0 +1,51 @@
+<?php
+
+namespace Icinga\Module\Director\PropertyModifier;
+
+use Icinga\Module\Director\Hook\PropertyModifierHook;
+use Icinga\Module\Director\Web\Form\QuickForm;
+
+class PropertyModifierRegexSplit extends PropertyModifierHook
+{
+ public static function addSettingsFormFields(QuickForm $form)
+ {
+ $form->addElement('text', 'pattern', array(
+ 'label' => $form->translate('Pattern'),
+ 'required' => true,
+ 'description' => $form->translate(
+ 'Regular expression pattern to split the string (e.g. /\s+/ or /[,;]/)'
+ )
+ ));
+
+ $form->addElement('select', 'when_empty', array(
+ 'label' => $form->translate('When empty'),
+ 'required' => true,
+ 'description' => $form->translate(
+ 'What should happen when the given string is empty?'
+ ),
+ 'value' => 'empty_array',
+ 'multiOptions' => $form->optionalEnum(array(
+ 'empty_array' => $form->translate('return an empty array'),
+ 'null' => $form->translate('return NULL'),
+ ))
+ ));
+ }
+
+ public function transform($value)
+ {
+ if (! strlen(trim($value))) {
+ if ($this->getSetting('when_empty', 'empty_array') === 'empty_array') {
+ return array();
+ } else {
+ return null;
+ }
+ }
+
+ return preg_split(
+ $this->getSetting('pattern'),
+ $value,
+ -1,
+ PREG_SPLIT_NO_EMPTY
+ );
+ }
+}
diff --git a/library/Director/PropertyModifier/PropertyModifierRejectOrSelect.php b/library/Director/PropertyModifier/PropertyModifierRejectOrSelect.php
new file mode 100644
index 0000000..1485d5d
--- /dev/null
+++ b/library/Director/PropertyModifier/PropertyModifierRejectOrSelect.php
@@ -0,0 +1,147 @@
+<?php
+
+namespace Icinga\Module\Director\PropertyModifier;
+
+use Icinga\Data\Filter\FilterExpression;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Module\Director\Hook\PropertyModifierHook;
+use Icinga\Module\Director\Web\Form\QuickForm;
+
+class PropertyModifierRejectOrSelect extends PropertyModifierHook
+{
+ /** @var FilterExpression */
+ private $filterExpression;
+
+ public function getName()
+ {
+ return mt('director', 'Reject or keep rows based on property value');
+ }
+
+ /**
+ * @inheritdoc
+ * @throws \Zend_Form_Exception
+ */
+ public static function addSettingsFormFields(QuickForm $form)
+ {
+ $form->addElement('select', 'filter_method', [
+ 'label' => $form->translate('Filter method'),
+ 'required' => true,
+ 'value' => 'wildcard',
+ 'multiOptions' => $form->optionalEnum([
+ 'wildcard' => $form->translate('Simple match with wildcards (*)'),
+ 'regex' => $form->translate('Regular Expression'),
+ 'is_true' => $form->translate('Match boolean TRUE'),
+ 'is_false' => $form->translate('Match boolean FALSE'),
+ 'is_null' => $form->translate('Match NULL value columns'),
+ ]),
+ 'class' => 'autosubmit',
+ ]);
+
+ $method = $form->getSetting('filter_method');
+ switch ($method) {
+ case 'wildcard':
+ $form->addElement('text', 'filter_string', [
+ 'label' => $form->translate('Filter'),
+ 'description' => $form->translate(
+ 'The string/pattern you want to search for, use wildcard'
+ . ' matches like www.* or *linux*'
+ ),
+ 'required' => true,
+ ]);
+ break;
+ case 'regex':
+ $form->addElement('text', 'filter_string', [
+ 'label' => $form->translate('Filter'),
+ 'description' => $form->translate(
+ 'The string/pattern you want to search for, use regular'
+ . ' expression like /^www\d+\./'
+ ),
+ 'required' => true,
+ ]);
+ break;
+ }
+
+ $form->addElement('select', 'policy', [
+ 'label' => $form->translate('Policy'),
+ 'required' => true,
+ 'description' => $form->translate(
+ 'What should happen with the row, when this property matches the given expression?'
+ ),
+ 'value' => 'reject',
+ 'multiOptions' => [
+ 'reject' => $form->translate('Reject the whole row'),
+ 'keep' => $form->translate('Keep only matching rows'),
+ ],
+ ]);
+ }
+
+ public function matchesRegexp($string, $expression)
+ {
+ return preg_match($expression, $string);
+ }
+
+ public function isNull($string, $expression)
+ {
+ return $string === null;
+ }
+
+ public function isTrue($string, $expression)
+ {
+ return $string === true;
+ }
+
+ public function isFalse($string, $expression)
+ {
+ return $string === false;
+ }
+
+ public function matchesWildcard($string, $expression)
+ {
+ return $this->filterExpression->matches(
+ (object) ['value' => $string]
+ );
+ }
+
+ public function transform($value)
+ {
+ $method = $this->getSetting('filter_method');
+ $filter = $this->getSetting('filter_string');
+ $policy = $this->getSetting('policy');
+
+ switch ($method) {
+ case 'wildcard':
+ $func = 'matchesWildcard';
+ $this->filterExpression = new FilterExpression('value', '=', $filter);
+ break;
+ case 'regex':
+ $func = 'matchesRegexp';
+ break;
+ case 'is_null':
+ $func = 'isNull';
+ break;
+ case 'is_true':
+ $func = 'isTrue';
+ break;
+ case 'is_false':
+ $func = 'isFalse';
+ break;
+ default:
+ throw new ConfigurationError(
+ '%s is not a valid value for an ArrayFilter filter_method',
+ var_export($method, 1)
+ );
+ }
+
+ if ($this->$func($value, $filter)) {
+ if ($policy === 'reject') {
+ $this->rejectRow();
+ }
+ } else {
+ if ($policy === 'keep') {
+ $this->rejectRow();
+ }
+ }
+
+ return $value;
+ }
+}
diff --git a/library/Director/PropertyModifier/PropertyModifierRenameColumn.php b/library/Director/PropertyModifier/PropertyModifierRenameColumn.php
new file mode 100644
index 0000000..12524d5
--- /dev/null
+++ b/library/Director/PropertyModifier/PropertyModifierRenameColumn.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Icinga\Module\Director\PropertyModifier;
+
+use Icinga\Module\Director\Hook\PropertyModifierHook;
+
+class PropertyModifierRenameColumn extends PropertyModifierHook
+{
+ public function getName()
+ {
+ return 'Rename a Property/Column';
+ }
+
+ public function requiresRow()
+ {
+ return true;
+ }
+
+ public function hasArraySupport()
+ {
+ return true;
+ }
+
+ public function transform($value)
+ {
+ $row = $this->getRow();
+ $property = $this->getPropertyName();
+ if ($row) {
+ unset($row->$property);
+ }
+ // $this->rejectRow();
+ return $value;
+ }
+}
diff --git a/library/Director/PropertyModifier/PropertyModifierReplace.php b/library/Director/PropertyModifier/PropertyModifierReplace.php
new file mode 100644
index 0000000..54e6616
--- /dev/null
+++ b/library/Director/PropertyModifier/PropertyModifierReplace.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace Icinga\Module\Director\PropertyModifier;
+
+use Icinga\Module\Director\Hook\PropertyModifierHook;
+use Icinga\Module\Director\Web\Form\QuickForm;
+
+class PropertyModifierReplace extends PropertyModifierHook
+{
+ public static function addSettingsFormFields(QuickForm $form)
+ {
+ $form->addElement('text', 'string', array(
+ 'label' => 'Search string',
+ 'description' => $form->translate('The string you want to search for'),
+ 'required' => true,
+ ));
+
+ $form->addElement('text', 'replacement', array(
+ 'label' => 'Replacement',
+ 'description' => $form->translate('Your replacement string'),
+ ));
+ }
+
+ public function transform($value)
+ {
+ if ($value === null) {
+ return null;
+ }
+
+ return str_replace(
+ $this->getSetting('string'),
+ $this->getSetting('replacement'),
+ $value
+ );
+ }
+}
diff --git a/library/Director/PropertyModifier/PropertyModifierReplaceNull.php b/library/Director/PropertyModifier/PropertyModifierReplaceNull.php
new file mode 100644
index 0000000..d6f9fd3
--- /dev/null
+++ b/library/Director/PropertyModifier/PropertyModifierReplaceNull.php
@@ -0,0 +1,33 @@
+<?php
+
+namespace Icinga\Module\Director\PropertyModifier;
+
+use Icinga\Module\Director\Hook\PropertyModifierHook;
+use Icinga\Module\Director\Web\Form\QuickForm;
+
+class PropertyModifierReplaceNull extends PropertyModifierHook
+{
+
+ public function getName()
+ {
+ return 'Replace null value with String';
+ }
+
+ public static function addSettingsFormFields(QuickForm $form)
+ {
+ $form->addElement('text', 'string', [
+ 'label' => 'Replacement String',
+ 'description' => $form->translate('Your replacement string'),
+ 'required' => true,
+ ]);
+ }
+
+ public function transform($value)
+ {
+ if ($value === null) {
+ return $this->getSetting('string');
+ } else {
+ return $value;
+ }
+ }
+}
diff --git a/library/Director/PropertyModifier/PropertyModifierSimpleGroupBy.php b/library/Director/PropertyModifier/PropertyModifierSimpleGroupBy.php
new file mode 100644
index 0000000..6c1452f
--- /dev/null
+++ b/library/Director/PropertyModifier/PropertyModifierSimpleGroupBy.php
@@ -0,0 +1,68 @@
+<?php
+
+namespace Icinga\Module\Director\PropertyModifier;
+
+use Icinga\Module\Director\Hook\PropertyModifierHook;
+use Icinga\Module\Director\Web\Form\QuickForm;
+
+class PropertyModifierSimpleGroupBy extends PropertyModifierHook
+{
+ private $keptRows = [];
+
+ public function getName()
+ {
+ return mt('director', 'Group by a column, aggregate others');
+ }
+
+ public function requiresRow()
+ {
+ return true;
+ }
+
+ public function transform($value)
+ {
+ $row = $this->getRow();
+ $aggregationColumns = preg_split(
+ '/\s*,\s*/',
+ $this->getSetting('aggregation_columns'),
+ -1,
+ PREG_SPLIT_NO_EMPTY
+ );
+ if (isset($this->keptRows[$value])) {
+ foreach ($aggregationColumns as $column) {
+ if (isset($row->$column)) {
+ $this->keptRows[$value]->{$column} = array_unique(array_merge(
+ $this->keptRows[$value]->{$column},
+ [$row->$column]
+ ));
+ sort($this->keptRows[$value]->{$column});
+ }
+ }
+ $this->rejectRow();
+ } else {
+ foreach ($aggregationColumns as $column) {
+ if (isset($row->$column)) {
+ $row->$column = [$row->$column];
+ } else {
+ $row->$column = [];
+ }
+ }
+
+ $this->keptRows[$value] = $row;
+ }
+
+ return $value;
+ }
+
+ public static function addSettingsFormFields(QuickForm $form)
+ {
+ $form->addElement('text', 'aggregation_columns', [
+ 'label' => $form->translate('Aggregation Columns'),
+ 'description' => $form->translate(
+ 'Comma-separated list of columns that should be aggregated (transformed into an Array).'
+ . ' For all other columns only the first value will be kept.'
+ ),
+ 'required' => true,
+ ]);
+ }
+}
diff --git a/library/Director/PropertyModifier/PropertyModifierSkipDuplicates.php b/library/Director/PropertyModifier/PropertyModifierSkipDuplicates.php
new file mode 100644
index 0000000..bf9bd31
--- /dev/null
+++ b/library/Director/PropertyModifier/PropertyModifierSkipDuplicates.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace Icinga\Module\Director\PropertyModifier;
+
+use Icinga\Module\Director\Hook\PropertyModifierHook;
+
+class PropertyModifierSkipDuplicates extends PropertyModifierHook
+{
+ private $seen = [];
+
+ public function getName()
+ {
+ return mt('director', 'Skip row if this value appears more than once');
+ }
+
+ public function transform($value)
+ {
+ if (isset($this->seen[$value])) {
+ $this->rejectRow();
+ }
+
+ $this->seen[$value] = true;
+
+ return $value;
+ }
+}
diff --git a/library/Director/PropertyModifier/PropertyModifierSplit.php b/library/Director/PropertyModifier/PropertyModifierSplit.php
new file mode 100644
index 0000000..4a6fef6
--- /dev/null
+++ b/library/Director/PropertyModifier/PropertyModifierSplit.php
@@ -0,0 +1,51 @@
+<?php
+
+namespace Icinga\Module\Director\PropertyModifier;
+
+use Icinga\Module\Director\Hook\PropertyModifierHook;
+use Icinga\Module\Director\Web\Form\QuickForm;
+
+class PropertyModifierSplit extends PropertyModifierHook
+{
+ public static function addSettingsFormFields(QuickForm $form)
+ {
+ $form->addElement('text', 'delimiter', array(
+ 'label' => $form->translate('Delimiter'),
+ 'required' => true,
+ 'description' => $form->translate(
+ 'One or more characters that should be used to split this string'
+ )
+ ));
+
+ $form->addElement('select', 'when_empty', array(
+ 'label' => $form->translate('When empty'),
+ 'required' => true,
+ 'description' => $form->translate(
+ 'What should happen when the given string is empty?'
+ ),
+ 'value' => 'empty_array',
+ 'multiOptions' => $form->optionalEnum(array(
+ 'empty_array' => $form->translate('return an empty array'),
+ 'null' => $form->translate('return NULL'),
+ ))
+ ));
+ }
+
+ public function transform($value)
+ {
+ if (! strlen(trim($value))) {
+ if ($this->getSetting('when_empty', 'empty_array') === 'empty_array') {
+ return array();
+ } else {
+ return null;
+ }
+ }
+
+ return preg_split(
+ '/' . preg_quote($this->getSetting('delimiter'), '/') . '/',
+ $value,
+ -1,
+ PREG_SPLIT_NO_EMPTY
+ );
+ }
+}
diff --git a/library/Director/PropertyModifier/PropertyModifierStripDomain.php b/library/Director/PropertyModifier/PropertyModifierStripDomain.php
new file mode 100644
index 0000000..34fb6ba
--- /dev/null
+++ b/library/Director/PropertyModifier/PropertyModifierStripDomain.php
@@ -0,0 +1,38 @@
+<?php
+
+namespace Icinga\Module\Director\PropertyModifier;
+
+use Icinga\Module\Director\Hook\PropertyModifierHook;
+use Icinga\Module\Director\Web\Form\QuickForm;
+
+class PropertyModifierStripDomain extends PropertyModifierHook
+{
+ public static function addSettingsFormFields(QuickForm $form)
+ {
+ $form->addElement('text', 'domain', array(
+ 'label' => 'Domain name',
+ 'description' => $form->translate('The domain name you want to be stripped'),
+ 'required' => true,
+ ));
+ }
+
+ public function getName()
+ {
+ return 'Strip a domain name';
+ }
+
+ public function transform($value)
+ {
+ if ($value === null) {
+ return null;
+ }
+
+ $domain = preg_quote(ltrim($this->getSetting('domain'), '.'), '/');
+
+ return preg_replace(
+ '/\.' . $domain . '$/',
+ '',
+ $value
+ );
+ }
+}
diff --git a/library/Director/PropertyModifier/PropertyModifierSubstring.php b/library/Director/PropertyModifier/PropertyModifierSubstring.php
new file mode 100644
index 0000000..37a2f92
--- /dev/null
+++ b/library/Director/PropertyModifier/PropertyModifierSubstring.php
@@ -0,0 +1,54 @@
+<?php
+
+namespace Icinga\Module\Director\PropertyModifier;
+
+use Icinga\Module\Director\Hook\PropertyModifierHook;
+use Icinga\Module\Director\Web\Form\QuickForm;
+
+class PropertyModifierSubstring extends PropertyModifierHook
+{
+ public static function addSettingsFormFields(QuickForm $form)
+ {
+ $form->addElement('text', 'start', array(
+ 'label' => 'Start',
+ 'required' => true,
+ 'description' => sprintf(
+ $form->translate(
+ 'Please see %s for detailled instructions of how start and end work'
+ ),
+ 'http://php.net/manual/en/function.substr.php'
+ )
+ ));
+
+ $form->addElement('text', 'length', array(
+ 'label' => 'End',
+ 'description' => sprintf(
+ $form->translate(
+ 'Please see %s for detailled instructions of how start and end work'
+ ),
+ 'http://php.net/manual/en/function.substr.php'
+ )
+ ));
+ }
+
+ public function transform($value)
+ {
+ if ($value === null) {
+ return null;
+ }
+
+ $length = $this->getSetting('length');
+ if (is_numeric($length)) {
+ return substr(
+ $value,
+ (int) $this->getSetting('start'),
+ (int) $length
+ );
+ } else {
+ return substr(
+ $value,
+ (int) $this->getSetting('start')
+ );
+ }
+ }
+}
diff --git a/library/Director/PropertyModifier/PropertyModifierToInt.php b/library/Director/PropertyModifier/PropertyModifierToInt.php
new file mode 100644
index 0000000..dca7db9
--- /dev/null
+++ b/library/Director/PropertyModifier/PropertyModifierToInt.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Icinga\Module\Director\PropertyModifier;
+
+use Icinga\Module\Director\Data\InvalidDataException;
+use Icinga\Module\Director\Hook\PropertyModifierHook;
+
+class PropertyModifierToInt extends PropertyModifierHook
+{
+ public function getName()
+ {
+ return 'Cast a string value to an Integer';
+ }
+
+ public function transform($value)
+ {
+ if ($value === null) {
+ return null;
+ }
+
+ if (is_int($value)) {
+ return $value;
+ }
+
+ if (is_string($value)) {
+ return (int) $value;
+ }
+
+ throw new InvalidDataException('String, integer or null', $value);
+ }
+}
diff --git a/library/Director/PropertyModifier/PropertyModifierTrim.php b/library/Director/PropertyModifier/PropertyModifierTrim.php
new file mode 100644
index 0000000..64a655b
--- /dev/null
+++ b/library/Director/PropertyModifier/PropertyModifierTrim.php
@@ -0,0 +1,54 @@
+<?php
+
+namespace Icinga\Module\Director\PropertyModifier;
+
+use Icinga\Module\Director\Hook\PropertyModifierHook;
+use Icinga\Module\Director\Web\Form\QuickForm;
+use InvalidArgumentException;
+
+class PropertyModifierTrim extends PropertyModifierHook
+{
+ const VALID_METHODS = ['trim', 'ltrim', 'rtrim'];
+
+ public static function addSettingsFormFields(QuickForm $form)
+ {
+ $form->addElement('select', 'trim_method', [
+ 'label' => $form->translate('Trim Method'),
+ 'description' => $form->translate('Where to trim the string(s)'),
+ 'value' => 'trim',
+ 'multiOptions' => $form->optionalEnum([
+ 'trim' => $form->translate('Beginning and Ending'),
+ 'ltrim' => $form->translate('Beginning only'),
+ 'rtrim' => $form->translate('Ending only'),
+ ]),
+ 'required' => true,
+ ]);
+
+ $form->addElement('text', 'character_mask', [
+ 'label' => $form->translate('Character Mask'),
+ 'description' => $form->translate(
+ 'Specify the characters that trim should remove.'
+ . 'Default is: " \t\n\r\0\x0B"'
+ ),
+ ]);
+ }
+
+ public function transform($value)
+ {
+ if ($value === null) {
+ return null;
+ }
+
+ $mask = $this->getSetting('character_mask');
+ $method = $this->getSetting('trim_method');
+ if (in_array($method, self::VALID_METHODS)) {
+ if ($mask) {
+ return $method($value, $mask);
+ } else {
+ return $method($value);
+ }
+ }
+
+ throw new InvalidArgumentException("'$method' is not a valid trim method");
+ }
+}
diff --git a/library/Director/PropertyModifier/PropertyModifierURLEncode.php b/library/Director/PropertyModifier/PropertyModifierURLEncode.php
new file mode 100644
index 0000000..34f3588
--- /dev/null
+++ b/library/Director/PropertyModifier/PropertyModifierURLEncode.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace Icinga\Module\Director\PropertyModifier;
+
+use Icinga\Module\Director\Hook\PropertyModifierHook;
+
+class PropertyModifierURLEncode extends PropertyModifierHook
+{
+ public function getName()
+ {
+ return 'URL-encode a string';
+ }
+
+
+ public function transform($value)
+ {
+ return rawurlencode($value);
+ }
+}
diff --git a/library/Director/PropertyModifier/PropertyModifierUpperCaseFirst.php b/library/Director/PropertyModifier/PropertyModifierUpperCaseFirst.php
new file mode 100644
index 0000000..a2fff40
--- /dev/null
+++ b/library/Director/PropertyModifier/PropertyModifierUpperCaseFirst.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace Icinga\Module\Director\PropertyModifier;
+
+use Icinga\Module\Director\Hook\PropertyModifierHook;
+use Icinga\Module\Director\Web\Form\QuickForm;
+
+class PropertyModifierUpperCaseFirst extends PropertyModifierHook
+{
+ public function getName()
+ {
+ return 'Uppercase the first character of each word in a string';
+ }
+
+ public static function addSettingsFormFields(QuickForm $form)
+ {
+ $form->addElement('select', 'lowerfirst', array(
+ 'label' => $form->translate('Use lowercase first'),
+ 'required' => true,
+ 'description' => $form->translate(
+ 'Should all the other characters be lowercased first?'
+ ),
+ 'value' => 'y',
+ 'multiOptions' => array(
+ 'y' => $form->translate('Yes'),
+ 'n' => $form->translate('No'),
+ ),
+ ));
+ }
+
+
+ public function transform($value)
+ {
+ if ($value === null) {
+ return null;
+ }
+
+ if ($this->getSetting('lowerfirst', 'y') === 'y') {
+ return ucwords(strtolower($value));
+ } else {
+ return ucwords($value);
+ }
+ }
+}
diff --git a/library/Director/PropertyModifier/PropertyModifierUppercase.php b/library/Director/PropertyModifier/PropertyModifierUppercase.php
new file mode 100644
index 0000000..e3d3d59
--- /dev/null
+++ b/library/Director/PropertyModifier/PropertyModifierUppercase.php
@@ -0,0 +1,17 @@
+<?php
+
+namespace Icinga\Module\Director\PropertyModifier;
+
+use Icinga\Module\Director\Hook\PropertyModifierHook;
+
+class PropertyModifierUppercase extends PropertyModifierHook
+{
+ public function transform($value)
+ {
+ if ($value === null) {
+ return null;
+ }
+
+ return \mb_strtoupper($value, 'UTF-8');
+ }
+}
diff --git a/library/Director/PropertyModifier/PropertyModifierUuidBinToHex.php b/library/Director/PropertyModifier/PropertyModifierUuidBinToHex.php
new file mode 100644
index 0000000..a1e5d9c
--- /dev/null
+++ b/library/Director/PropertyModifier/PropertyModifierUuidBinToHex.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace Icinga\Module\Director\PropertyModifier;
+
+use Icinga\Module\Director\Hook\PropertyModifierHook;
+use Ramsey\Uuid\Uuid;
+
+class PropertyModifierUuidBinToHex extends PropertyModifierHook
+{
+ public function getName()
+ {
+ return mt('director', 'UUID: from binary to hex');
+ }
+
+ public function transform($value)
+ {
+ return Uuid::fromBytes($value)->toString();
+ }
+}
diff --git a/library/Director/PropertyModifier/PropertyModifierXlsNumericIp.php b/library/Director/PropertyModifier/PropertyModifierXlsNumericIp.php
new file mode 100644
index 0000000..969068e
--- /dev/null
+++ b/library/Director/PropertyModifier/PropertyModifierXlsNumericIp.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace Icinga\Module\Director\PropertyModifier;
+
+use Icinga\Module\Director\Hook\PropertyModifierHook;
+
+class PropertyModifierXlsNumericIp extends PropertyModifierHook
+{
+ public function getName()
+ {
+ return 'Fix IP formatted as a number in MS Excel';
+ }
+
+ public function transform($value)
+ {
+ if (ctype_digit($value) && strlen($value) > 9 && strlen($value) <= 12) {
+ return preg_replace(
+ '/^(\d{1,3})(\d{3})(\d{3})(\d{3})/',
+ '\1.\2.\3.\4',
+ $value
+ );
+ } else {
+ return $value;
+ }
+ }
+}
diff --git a/library/Director/ProvidedHook/CubeLinks.php b/library/Director/ProvidedHook/CubeLinks.php
new file mode 100644
index 0000000..2cb9559
--- /dev/null
+++ b/library/Director/ProvidedHook/CubeLinks.php
@@ -0,0 +1,65 @@
+<?php
+
+namespace Icinga\Module\Director\ProvidedHook;
+
+use Icinga\Data\Filter\Filter;
+use Icinga\Module\Cube\Cube;
+use Icinga\Module\Cube\Hook\ActionsHook;
+use Icinga\Module\Cube\Ido\IdoHostStatusCube;
+use Icinga\Web\View;
+
+class CubeLinks extends ActionsHook
+{
+ /**
+ * @inheritdoc
+ */
+ public function prepareActionLinks(Cube $cube, View $view)
+ {
+ if (! $cube instanceof IdoHostStatusCube) {
+ return;
+ }
+
+ $cube->finalizeInnerQuery();
+ $query = $cube->innerQuery()
+ ->reset('columns')
+ ->columns(array('host' => 'o.name1'))
+ ->reset('group');
+
+ $hosts = $cube->db()->fetchCol($query);
+
+ $count = count($hosts);
+ if ($count === 1) {
+ $url = 'director/host/edit';
+ $params = array('name' => $hosts[0]);
+
+ $title = $view->translate('Modify a host');
+ $description = sprintf(
+ $view->translate('This allows you to modify properties for "%s"'),
+ $hosts[0]
+ );
+ } else {
+ $params = null;
+
+ $filter = Filter::matchAny();
+ foreach ($hosts as $host) {
+ $filter->addFilter(
+ Filter::matchAny(Filter::expression('name', '=', $host))
+ );
+ }
+
+ $url = 'director/hosts/edit?' . $filter->toQueryString();
+
+ $title = sprintf($view->translate('Modify %d hosts'), $count);
+ $description = $view->translate(
+ 'This allows you to modify properties for all chosen hosts at once'
+ );
+ }
+
+ $this->addActionLink(
+ $this->makeUrl($url, $params),
+ $title,
+ $description,
+ 'wrench'
+ );
+ }
+}
diff --git a/library/Director/ProvidedHook/IcingaDbCubeLinks.php b/library/Director/ProvidedHook/IcingaDbCubeLinks.php
new file mode 100644
index 0000000..234f61f
--- /dev/null
+++ b/library/Director/ProvidedHook/IcingaDbCubeLinks.php
@@ -0,0 +1,66 @@
+<?php
+
+namespace Icinga\Module\Director\ProvidedHook;
+
+use Icinga\Data\Filter\Filter;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Module\Cube\Hook\IcingaDbActionsHook;
+use Icinga\Module\Cube\IcingaDb\IcingaDbCube;
+use Icinga\Module\Cube\IcingaDb\IcingaDbHostStatusCube;
+
+class IcingaDbCubeLinks extends IcingaDbActionsHook
+{
+ /**
+ * @inheritDoc
+ * @param IcingaDbCube $cube
+ * @throws ProgrammingError
+ */
+ public function createActionLinks(IcingaDbCube $cube)
+ {
+ if (! $cube instanceof IcingaDbHostStatusCube) {
+ return;
+ }
+
+ $filterChain = $cube->getObjectsFilter();
+
+ if ($filterChain->count() === 1) {
+ $url = 'director/host/edit?';
+ $params = ['name' => $filterChain->getIterator()->current()->getValue()];
+
+ $title = t('Modify a host');
+ $description = sprintf(
+ t('This allows you to modify properties for "%s"'),
+ $filterChain->getIterator()->current()->getValue()
+ );
+ } else {
+ $params = null;
+
+ $urlFilter = Filter::matchAny();
+ foreach ($filterChain as $filter) {
+ $urlFilter->addFilter(
+ Filter::matchAny(
+ Filter::expression(
+ 'name',
+ '=',
+ $filter->getValue()
+ )
+ )
+ );
+ }
+
+ $url = 'director/hosts/edit?' . $urlFilter->toQueryString();
+
+ $title = sprintf(t('Modify %d hosts'), $filterChain->count());
+ $description = t(
+ 'This allows you to modify properties for all chosen hosts at once'
+ );
+ }
+
+ $this->addActionLink(
+ $this->makeUrl($url, $params),
+ $title,
+ $description,
+ 'wrench'
+ );
+ }
+}
diff --git a/library/Director/ProvidedHook/Monitoring/HostActions.php b/library/Director/ProvidedHook/Monitoring/HostActions.php
new file mode 100644
index 0000000..2e3fba0
--- /dev/null
+++ b/library/Director/ProvidedHook/Monitoring/HostActions.php
@@ -0,0 +1,73 @@
+<?php
+
+namespace Icinga\Module\Director\ProvidedHook\Monitoring;
+
+use Exception;
+use Icinga\Application\Config;
+use Icinga\Authentication\Auth;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Monitoring;
+use Icinga\Module\Director\Objects\IcingaHost;
+use Icinga\Module\Director\Util;
+use Icinga\Module\Monitoring\Hook\HostActionsHook;
+use Icinga\Module\Monitoring\Object\Host;
+use Icinga\Web\Url;
+
+class HostActions extends HostActionsHook
+{
+ public function getActionsForHost(Host $host)
+ {
+ try {
+ return $this->getThem($host);
+ } catch (Exception $e) {
+ return array();
+ }
+ }
+
+ protected function getThem(Host $host)
+ {
+ $actions = array();
+ $db = $this->db();
+ if (! $db) {
+ return $actions;
+ }
+ $hostname = $host->host_name;
+ if (Util::hasPermission('director/inspect')) {
+ $actions[mt('director', 'Inspect')] = Url::fromPath(
+ 'director/inspect/object',
+ array('type' => 'host', 'plural' => 'hosts', 'name' => $hostname)
+ );
+ }
+
+ $allowEdit = false;
+ if (Util::hasPermission('director/hosts') && IcingaHost::exists($hostname, $db)) {
+ $allowEdit = true;
+ }
+ $auth = Auth::getInstance();
+ if (Util::hasPermission('director/monitoring/hosts')) {
+ $monitoring = new Monitoring();
+ if ($monitoring->isAvailable() && $monitoring->authCanEditHost($auth, $hostname)) {
+ $allowEdit = IcingaHost::exists($hostname, $db);
+ }
+ }
+
+ if ($allowEdit) {
+ $actions[mt('director', 'Modify')] = Url::fromPath(
+ 'director/host/edit',
+ array('name' => $hostname)
+ );
+ }
+
+ return $actions;
+ }
+
+ protected function db()
+ {
+ $resourceName = Config::module('director')->get('db', 'resource');
+ if (! $resourceName) {
+ return false;
+ }
+
+ return Db::fromResourceName($resourceName);
+ }
+}
diff --git a/library/Director/ProvidedHook/Monitoring/ServiceActions.php b/library/Director/ProvidedHook/Monitoring/ServiceActions.php
new file mode 100644
index 0000000..b2e303a
--- /dev/null
+++ b/library/Director/ProvidedHook/Monitoring/ServiceActions.php
@@ -0,0 +1,87 @@
+<?php
+
+namespace Icinga\Module\Director\ProvidedHook\Monitoring;
+
+use Exception;
+use Icinga\Application\Config;
+use Icinga\Authentication\Auth;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Monitoring;
+use Icinga\Module\Director\Objects\IcingaHost;
+use Icinga\Module\Director\Util;
+use Icinga\Module\Monitoring\Hook\ServiceActionsHook;
+use Icinga\Module\Monitoring\Object\Service;
+use Icinga\Web\Url;
+
+class ServiceActions extends ServiceActionsHook
+{
+ public function getActionsForService(Service $service)
+ {
+ try {
+ return $this->getThem($service);
+ } catch (Exception $e) {
+ return [];
+ }
+ }
+
+ /**
+ * @param Service $service
+ * @return array
+ * @throws \Icinga\Exception\ProgrammingError
+ */
+ protected function getThem(Service $service)
+ {
+ $actions = [];
+ $db = $this->db();
+ if (! $db) {
+ return [];
+ }
+
+ $hostname = $service->host_name;
+ $serviceName = $service->service_description;
+ if (Util::hasPermission('director/inspect')) {
+ $actions[mt('director', 'Inspect')] = Url::fromPath('director/inspect/object', [
+ 'type' => 'service',
+ 'plural' => 'services',
+ 'name' => sprintf(
+ '%s!%s',
+ $hostname,
+ $serviceName
+ )
+ ]);
+ }
+
+ $title = null;
+ if (Util::hasPermission('director/hosts')) {
+ $title = mt('director', 'Modify');
+ } elseif (Util::hasPermission('director/monitoring/services')) {
+ $monitoring = new Monitoring();
+ if ($monitoring->isAvailable()
+ && $monitoring->authCanEditService(Auth::getInstance(), $hostname, $serviceName)
+ ) {
+ $title = mt('director', 'Modify');
+ }
+ } elseif (Util::hasPermission('director/monitoring/services-ro')) {
+ $title = mt('director', 'Configuration');
+ }
+
+ if ($title && IcingaHost::exists($hostname, $db)) {
+ $actions[$title] = Url::fromPath('director/host/findservice', [
+ 'name' => $hostname,
+ 'service' => $serviceName
+ ]);
+ }
+
+ return $actions;
+ }
+
+ protected function db()
+ {
+ $resourceName = Config::module('director')->get('db', 'resource');
+ if (! $resourceName) {
+ return false;
+ }
+
+ return Db::fromResourceName($resourceName);
+ }
+}
diff --git a/library/Director/Repository/IcingaTemplateRepository.php b/library/Director/Repository/IcingaTemplateRepository.php
new file mode 100644
index 0000000..ed3b1d0
--- /dev/null
+++ b/library/Director/Repository/IcingaTemplateRepository.php
@@ -0,0 +1,122 @@
+<?php
+
+namespace Icinga\Module\Director\Repository;
+
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Icinga\Module\Director\Resolver\TemplateTree;
+
+class IcingaTemplateRepository
+{
+ use RepositoryByObjectHelper;
+
+ /** @var TemplateTree */
+ protected $tree;
+
+ protected $loadedById = [];
+
+ /**
+ * @return TemplateTree
+ */
+ public function tree()
+ {
+ if ($this->tree === null) {
+ $this->tree = new TemplateTree($this->type, $this->connection);
+ }
+
+ return $this->tree;
+ }
+
+ /**
+ * @param IcingaObject $object
+ * @param bool $recursive
+ * @return IcingaObject[]
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function getTemplatesFor(IcingaObject $object, $recursive = false)
+ {
+ if ($recursive) {
+ $ids = $this->tree()->listAncestorIdsFor($object);
+ } else {
+ $ids = $this->tree()->listParentIdsFor($object);
+ }
+
+ return $this->getTemplatesForIds($ids, $object);
+ }
+
+ /**
+ * @param array $ids
+ * @param IcingaObject $object
+ * @return IcingaObject[]
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function getTemplatesForIds(array $ids, IcingaObject $object)
+ {
+ $templates = [];
+ foreach ($ids as $id) {
+ if (! array_key_exists($id, $this->loadedById)) {
+ // TODO: load only missing ones at once
+ $this->loadedById[$id] = $object::loadWithAutoIncId(
+ $id,
+ $this->connection
+ );
+ }
+
+ $templates[$id] = $this->loadedById[$id];
+ }
+
+ return $templates;
+ }
+
+ /**
+ * @param IcingaObject $object
+ * @param bool $recursive
+ * @return IcingaObject[]
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function getTemplatesIndexedByNameFor(
+ IcingaObject $object,
+ $recursive = false
+ ) {
+ $templates = [];
+ foreach ($this->getTemplatesFor($object, $recursive) as $template) {
+ $templates[$template->getObjectName()] = $template;
+ }
+
+ return $templates;
+ }
+
+ public function persistImportNames()
+ {
+ }
+
+ public function storeChances(Db $db)
+ {
+ }
+
+ public function listAllowedTemplateNames()
+ {
+ $type = $this->type;
+ $db = $this->connection->getDbAdapter();
+ $table = 'icinga_' . $this->type;
+
+ $query = $db->select()
+ ->from($table, 'object_name')
+ ->order('object_name');
+
+ if ($type !== 'command') {
+ $query->where('object_type = ?', 'template');
+ }
+
+ if (in_array($type, ['host', 'service'])) {
+ $query->where('template_choice_id IS NULL');
+ }
+
+ return $db->fetchCol($query);
+ }
+
+ public static function clear()
+ {
+ static::clearInstances();
+ }
+}
diff --git a/library/Director/Repository/RepositoryByObjectHelper.php b/library/Director/Repository/RepositoryByObjectHelper.php
new file mode 100644
index 0000000..0d1dda3
--- /dev/null
+++ b/library/Director/Repository/RepositoryByObjectHelper.php
@@ -0,0 +1,99 @@
+<?php
+
+namespace Icinga\Module\Director\Repository;
+
+use Icinga\Authentication\Auth;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Objects\IcingaObject;
+use RuntimeException;
+
+trait RepositoryByObjectHelper
+{
+ protected $type;
+
+ /** @var Db */
+ protected $connection;
+
+ /** @var Auth */
+ protected static $auth;
+
+ /** @var static[] */
+ protected static $instances = [];
+
+ protected function __construct($type, Db $connection)
+ {
+ $this->type = $type;
+ $this->connection = $connection;
+ }
+
+ /**
+ * @param string $type
+ * @return bool
+ */
+ public static function hasInstanceForType($type)
+ {
+ return array_key_exists($type, self::$instances);
+ }
+
+ /**
+ * @param string $type
+ * @param Db $connection
+ * @return static
+ */
+ public static function instanceByType($type, Db $connection)
+ {
+ if (! static::hasInstanceForType($type)) {
+ self::$instances[$type] = new static($type, $connection);
+ }
+
+ return self::$instances[$type];
+ }
+
+ /**
+ * @param IcingaObject $object
+ * @return bool
+ */
+ public static function hasInstanceForObject(IcingaObject $object)
+ {
+ return static::hasInstanceForType($object->getShortTableName());
+ }
+
+ /**
+ * @param IcingaObject $object
+ * @param Db|null $connection
+ * @return static
+ */
+ public static function instanceByObject(IcingaObject $object, Db $connection = null)
+ {
+ if (null === $connection) {
+ $connection = $object->getConnection();
+ }
+
+ if (! $connection) {
+ throw new RuntimeException(sprintf(
+ 'Cannot use repository for %s "%s" as it has no DB connection',
+ $object->getShortTableName(),
+ $object->getObjectName()
+ ));
+ }
+
+ return static::instanceByType(
+ $object->getShortTableName(),
+ $connection
+ );
+ }
+
+ protected static function auth()
+ {
+ if (self::$auth === null) {
+ self::$auth = Auth::getInstance();
+ }
+
+ return self::$auth;
+ }
+
+ protected static function clearInstances()
+ {
+ self::$instances = [];
+ }
+}
diff --git a/library/Director/Resolver/CommandUsage.php b/library/Director/Resolver/CommandUsage.php
new file mode 100644
index 0000000..7e3e0c5
--- /dev/null
+++ b/library/Director/Resolver/CommandUsage.php
@@ -0,0 +1,104 @@
+<?php
+
+namespace Icinga\Module\Director\Resolver;
+
+use gipfl\IcingaWeb2\Link;
+use gipfl\Translation\TranslationHelper;
+use Icinga\Module\Director\Objects\IcingaCommand;
+use InvalidArgumentException;
+
+class CommandUsage
+{
+ use TranslationHelper;
+
+ /** @var IcingaCommand */
+ protected $command;
+
+ /** @var \Zend_Db_Adapter_Abstract */
+ protected $db;
+
+ /**
+ * CommandUsageTable constructor.
+ * @param IcingaCommand $command
+ */
+ public function __construct(IcingaCommand $command)
+ {
+ if ($command->isTemplate()) {
+ throw new InvalidArgumentException(
+ 'CommandUsageTable expects object or external_object, got a template'
+ );
+ }
+
+ $this->command = $command;
+ $this->db = $command->getDb();
+ }
+
+ /**
+ * @return array
+ */
+ public function getLinks()
+ {
+ $name = $this->command->getObjectName();
+ $links = [];
+ $map = [
+ 'host' => ['check_command', 'event_command'],
+ 'service' => ['check_command', 'event_command'],
+ 'notification' => ['command'],
+ ];
+ $types = [
+ 'host' => [
+ 'object' => $this->translate('%d Host(s)'),
+ 'template' => $this->translate('%d Host Template(s)'),
+ ],
+ 'service' => [
+ 'object' => $this->translate('%d Service(s)'),
+ 'template' => $this->translate('%d Service Template(s)'),
+ 'apply' => $this->translate('%d Service Apply Rule(s)'),
+ ],
+ 'notification' => [
+ 'object' => $this->translate('%d Notification(s)'),
+ 'template' => $this->translate('%d Notification Template(s)'),
+ 'apply' => $this->translate('%d Notification Apply Rule(s)'),
+ ],
+ ];
+
+ $urlSuffix = [
+ 'object' => '',
+ 'template' => '/templates',
+ 'apply' => '/applyrules',
+ ];
+
+ foreach ($map as $type => $relations) {
+ $res = $this->fetchFor($type, $relations, array_keys($types[$type]));
+ foreach ($types[$type] as $objectType => $caption) {
+ if ($res->$objectType > 0) {
+ $suffix = $urlSuffix[$objectType];
+ $links[] = Link::create(
+ sprintf($caption, $res->$objectType),
+ "director/${type}s$suffix",
+ ['command' => $name]
+ );
+ }
+ }
+ }
+
+ return $links;
+ }
+
+ protected function fetchFor($table, $rels, $objectTypes)
+ {
+ $id = $this->command->getAutoincId();
+
+ $columns = [];
+ foreach ($objectTypes as $type) {
+ $columns[$type] = "COALESCE(SUM(CASE WHEN object_type = '$type' THEN 1 ELSE 0 END), 0)";
+ }
+ $query = $this->db->select()->from("icinga_$table", $columns);
+
+ foreach ($rels as $rel) {
+ $query->orWhere("${rel}_id = ?", $id);
+ }
+
+ return $this->db->fetchRow($query);
+ }
+}
diff --git a/library/Director/Resolver/HostServiceBlacklist.php b/library/Director/Resolver/HostServiceBlacklist.php
new file mode 100644
index 0000000..606855a
--- /dev/null
+++ b/library/Director/Resolver/HostServiceBlacklist.php
@@ -0,0 +1,91 @@
+<?php
+
+namespace Icinga\Module\Director\Resolver;
+
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Objects\IcingaService;
+
+class HostServiceBlacklist
+{
+ /** @var Db */
+ protected $db;
+
+ protected $table = 'icinga_host_service_blacklist';
+
+ protected $mappings;
+
+ public function __construct(Db $db)
+ {
+ $this->db = $db;
+ }
+
+ protected function loadMappings()
+ {
+ $db = $this->db->getDbAdapter();
+ $query = $db->select()->from(['hsb' => $this->table], [
+ 'host_name' => 'h.object_name',
+ 'service_id' => 'hsb.service_id'
+ ])->join(
+ ['h' => 'icinga_host'],
+ 'hsb.host_id = h.id',
+ []
+ );
+
+ $result = [];
+ foreach ($db->fetchAll($query) as $row) {
+ if (array_key_exists($row->service_id, $result)) {
+ $result[$row->service_id][] = $row->host_name;
+ } else {
+ $result[$row->service_id] = [$row->host_name];
+ }
+ }
+
+ return $result;
+ }
+
+ public function preloadMappings()
+ {
+ $this->mappings = $this->loadMappings();
+
+ return $this;
+ }
+
+ public function getBlacklistedHostnamesForService(IcingaService $service)
+ {
+ if ($this->mappings === null) {
+ return $this->fetchMappingsForService($service);
+ } else {
+ return $this->getPreLoadedMappingsForService($service);
+ }
+ }
+
+ public function fetchMappingsForService(IcingaService $service)
+ {
+ if (! $service->hasBeenLoadedFromDb() || $service->get('id') === null) {
+ return [];
+ }
+
+ $db = $this->db->getDbAdapter();
+ $query = $db->select()->from(['hsb' => $this->table], [
+ 'host_name' => 'h.object_name',
+ 'service_id' => 'hsb.service_id'
+ ])->join(
+ ['h' => 'icinga_host'],
+ 'hsb.host_id = h.id',
+ []
+ )->where('hsb.service_id = ?', $service->get('id'));
+
+ return $db->fetchCol($query);
+ }
+
+ public function getPreLoadedMappingsForService(IcingaService $service)
+ {
+ if ($this->mappings !== null
+ && array_key_exists($service->get('id'), $this->mappings)
+ ) {
+ return $this->mappings[$service->get('id')];
+ }
+
+ return [];
+ }
+}
diff --git a/library/Director/Resolver/IcingaHostObjectResolver.php b/library/Director/Resolver/IcingaHostObjectResolver.php
new file mode 100644
index 0000000..210645f
--- /dev/null
+++ b/library/Director/Resolver/IcingaHostObjectResolver.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace Icinga\Module\Director\Resolver;
+
+use Zend_Db_Adapter_Abstract as ZfDB;
+
+class IcingaHostObjectResolver extends IcingaObjectResolver
+{
+ /** @var ZfDB */
+ protected $db;
+
+ protected $nameMaps;
+
+ protected $baseTable = 'icinga_host';
+
+ protected $ignoredProperties = [
+ 'id',
+ 'object_type',
+ 'disabled',
+ 'has_agent',
+ 'master_should_connect',
+ 'accept_config',
+ 'api_key',
+ 'template_choice_id',
+ ];
+
+ protected $relatedTables = [
+ 'check_command_id' => 'icinga_command',
+ 'event_command_id' => 'icinga_command',
+ 'check_period_id' => 'icinga_timeperiod',
+ 'command_endpoint_id' => 'icinga_endpoint',
+ 'zone_id' => 'icinga_zone',
+ ];
+
+ protected $booleans = [
+ 'enable_notifications',
+ 'enable_active_checks',
+ 'enable_passive_checks',
+ 'enable_event_handler',
+ 'enable_flapping',
+ 'enable_perfdata',
+ 'volatile',
+ ];
+}
diff --git a/library/Director/Resolver/IcingaObjectResolver.php b/library/Director/Resolver/IcingaObjectResolver.php
new file mode 100644
index 0000000..540e2c2
--- /dev/null
+++ b/library/Director/Resolver/IcingaObjectResolver.php
@@ -0,0 +1,558 @@
+<?php
+
+namespace Icinga\Module\Director\Resolver;
+
+use Icinga\Application\Benchmark;
+use Icinga\Data\Filter\Filter;
+use Icinga\Module\Director\Data\AssignFilterHelper;
+use Icinga\Module\Director\Objects\DynamicApplyMatches;
+use Zend_Db_Adapter_Abstract as ZfDB;
+
+class IcingaObjectResolver
+{
+ /** @var ZfDB */
+ protected $db;
+
+ protected $nameMaps;
+
+ protected $baseTable = 'not_configured';
+
+ protected $ignoredProperties = [];
+
+ protected $relatedTables = [];
+
+ protected $booleans = [];
+
+ /**
+ * @var array[]
+ */
+ protected $templates;
+
+ /**
+ * @var array[]
+ */
+ protected $resolvedTemplateProperties;
+
+ /**
+ * @var array
+ */
+ protected $inheritancePaths;
+
+ protected $flatImports = [];
+
+ protected $templateVars;
+
+ protected $resolvedTemplateVars = [];
+
+ protected $groupMemberShips;
+
+ protected $resolvedGroupMemberShips;
+
+ public function __construct(ZfDb $db)
+ {
+ // TODO: loop detection. Not critical right now, as this isn't the main resolver
+ Benchmark::measure('Object Resolver for ' . $this->baseTable . ' warming up');
+ $this->db = $db;
+ // Fetch: ignore disabled?
+ $this->prepareNameMaps();
+ $this->templates = [];
+ foreach ($this->fetchPlainObjects($this->baseTable, 'template') as $template) {
+ $id = $template->id;
+ $this->stripIgnoredProperties($template);
+ $this->stripNullProperties($template);
+ $this->templates[$id] = (array) $template;
+ }
+ $this->templateVars = $this->fetchTemplateVars();
+ $this->inheritancePaths = $this->fetchInheritancePaths($this->baseTable, 'host_id');
+ foreach ($this->inheritancePaths as $path) {
+ $this->getResolvedImports($path);
+ }
+
+ // Using already resolved data, so this is unused right now:
+ // $this->groupMemberShips = $this->fetchAllGroups();
+ $this->resolvedGroupMemberShips = $this->fetchAllResolvedGroups();
+
+ foreach ($this->inheritancePaths as $path) {
+ if (! isset($this->resolvedTemplateProperties[$path])) {
+ $properties = (object) $this->getResolvedProperties($path);
+ $this->replaceRelatedNames($properties);
+ $this->convertBooleans($properties);
+ $this->resolvedTemplateProperties[$path] = $properties;
+ $this->resolvedTemplateVars[$path] = $this->getResolvedVars($path);
+ }
+ }
+
+ Benchmark::measure('Object Resolver for ' . $this->baseTable . ' is ready');
+
+ // Notes:
+ // default != null:
+ // most icinga objects: disabled => n
+ // Icinga(ScheduledDowntime|TimePeriod)Range: range_type => include, merge_behaviour => set
+ // IcingaTemplateChoice: min_required => 0, max_allowed => 1
+ // IcingaZone: is_global => n
+ // ImportSource: import_state => unknown
+ // SyncRule: sync_state => unknown
+ }
+
+ protected static function addUniqueMembers(&$list, $newMembers)
+ {
+ foreach (\array_reverse($newMembers) as $member) {
+ $pos = \array_search($member, $list);
+ if ($pos !== false) {
+ unset($list[$pos]);
+ }
+
+ \array_unshift($list, $member);
+ }
+ }
+
+ public function fetchResolvedObjects()
+ {
+ $objects = [];
+ $allVars = $this->fetchNonTemplateVars();
+ foreach ($this->fetchPlainObjects($this->baseTable, 'object') as $object) {
+ $id = $object->id; // id will be stripped
+ $objects[$id] = $this->enrichObject($object, $allVars);
+ }
+
+ return $objects;
+ }
+
+ public function fetchObjectsMatchingFilter(Filter $filter)
+ {
+ $filter = clone($filter);
+ DynamicApplyMatches::setType($this->getType());
+ DynamicApplyMatches::fixFilterColumns($filter);
+ $helper = new AssignFilterHelper($filter);
+ $objects = [];
+ $allVars = $this->fetchNonTemplateVars();
+ foreach ($this->fetchPlainObjects($this->baseTable, 'object') as $object) {
+ $id = $object->id; // id will be stripped
+ $object = $this->enrichObject($object, $allVars);
+ if ($helper->matches($object)) {
+ $objects[$id] = $object;
+ }
+ }
+
+ return $objects;
+ }
+
+ protected function enrichObject($object, $allVars)
+ {
+ $id = $object->id;
+ $this->stripIgnoredProperties($object);
+ if (isset($allVars[$id])) {
+ $vars = $allVars[$id];
+ } else {
+ $vars = [];
+ }
+ $vars += $this->getInheritedVarsById($id);
+
+ // There is no merge, +/-, not yet. Unused, as we use resolved groups:
+ // if (isset($this->groupMemberShips[$id])) {
+ // $groups = $this->groupMemberShips[$id];
+ // } else {
+ // $groups = $this->getInheritedGroupsById($id);
+ // }
+ if (isset($this->resolvedGroupMemberShips[$id])) {
+ $groups = $this->resolvedGroupMemberShips[$id];
+ } else {
+ $groups = [];
+ }
+
+ foreach ($this->getInheritedPropertiesById($id) as $property => $value) {
+ if (! isset($object->$property)) {
+ $object->$property = $value;
+ }
+ }
+ $this->replaceRelatedNames($object);
+ $this->convertBooleans($object);
+ $this->stripNullProperties($object);
+ if (! empty($vars)) {
+ $object->vars = (object) $vars;
+ static::flattenVars($object);
+ }
+ if (! empty($groups)) {
+ $object->groups = $groups;
+ }
+
+ $templates = $this->getTemplateNamesById($id);
+ if (! empty($templates)) {
+ $object->templates = \array_reverse($templates);
+ }
+
+ return $object;
+ }
+
+ /**
+ * @param string $baseTable e.g. icinga_host
+ * @param string $relColumn e.g. host_id
+ * @return array
+ */
+ protected function fetchInheritancePaths($baseTable, $relColumn)
+ {
+ if ($this->db instanceof \Zend_Db_Adapter_Pdo_Pgsql) {
+ $groupColumn = "ARRAY_TO_STRING(ARRAY_AGG(parent_$relColumn ORDER BY weight), ',')";
+ } else {
+ $groupColumn = "GROUP_CONCAT(parent_$relColumn ORDER BY weight SEPARATOR ',')";
+ }
+ $query = $this->db->select()
+ ->from([
+ 'oi' => "${baseTable}_inheritance"
+ ], [
+ $relColumn,
+ $groupColumn
+ ])
+ ->group($relColumn)
+ // Ordering by length increases the possibility to have less cycles afterwards
+ ->order("LENGTH($groupColumn)");
+
+ return $this->db->fetchPairs($query);
+ }
+
+ protected function getInheritedPropertiesById($objectId)
+ {
+ if (isset($this->inheritancePaths[$objectId])) {
+ return $this->getResolvedProperties($this->inheritancePaths[$objectId]);
+ } else {
+ return [];
+ }
+ }
+
+ protected function getInheritedVarsById($objectId)
+ {
+ if (isset($this->inheritancePaths[$objectId])) {
+ return $this->getResolvedVars($this->inheritancePaths[$objectId]);
+ } else {
+ return [];
+ }
+ }
+
+ protected function getInheritedGroupsById($objectId)
+ {
+ if (isset($this->inheritancePaths[$objectId])) {
+ return $this->getResolvedGroups($this->inheritancePaths[$objectId]);
+ } else {
+ return [];
+ }
+ }
+
+ protected function getTemplateNamesByID($objectId)
+ {
+ if (isset($this->inheritancePaths[$objectId])) {
+ return $this->translateTemplateIdsToNames(
+ $this->getResolvedImports($this->inheritancePaths[$objectId])
+ );
+ } else {
+ return [];
+ }
+ }
+
+ /**
+ * @param $path
+ * @return array[]
+ */
+ protected function getResolvedProperties($path)
+ {
+ $result = [];
+ // + adds only non existing members, so let's reverse our templates
+ foreach ($this->getResolvedImports($path) as $templateId) {
+ $result += $this->templates[$templateId];
+ }
+ unset($result['object_name']);
+
+ return $result;
+ }
+
+ protected function getResolvedVars($path)
+ {
+ $result = [];
+ foreach ($this->getResolvedImports($path) as $templateId) {
+ $result += $this->getTemplateVars($templateId);
+ }
+
+ return $result;
+ }
+
+ protected function getTemplateVars($templateId)
+ {
+ if (isset($this->templateVars[$templateId])) {
+ return $this->templateVars[$templateId];
+ } else {
+ return [];
+ }
+ }
+
+ protected function getResolvedGroups($path)
+ {
+ $pos = \strpos($path, ',');
+ if ($pos === false) {
+ if (isset($this->groupMemberShips[$path])) {
+ return $this->groupMemberShips[$path];
+ } else {
+ return [];
+ }
+ } else {
+ $first = \substr($path, 0, $pos);
+ $parentPath = \substr($path, $pos + 1);
+ $currentGroups = $this->getResolvedVars($first);
+
+ // There is no merging +/-, not yet
+ if (empty($currentGroups)) {
+ return $this->getResolvedVars($parentPath);
+ } else {
+ return $currentGroups;
+ }
+ }
+ }
+
+ /**
+ * Hint: this ships most important (last) imports first
+ *
+ * @param $path
+ * @return array
+ */
+ protected function getResolvedImports($path)
+ {
+ if (! isset($this->flatImports[$path])) {
+ $this->flatImports[$path] = $this->calculateFlatImports($path);
+ }
+
+ return $this->flatImports[$path];
+ }
+
+ protected function calculateFlatImports($path)
+ {
+ $imports = \preg_split('/,/', $path);
+ $ancestors = [];
+ foreach ($imports as $template) {
+ if (isset($this->inheritancePaths[$template])) {
+ $this->addUniqueMembers(
+ $ancestors,
+ $this->calculateFlatImports($this->inheritancePaths[$template])
+ );
+ }
+ $this->addUniqueMembers($ancestors, [$template]);
+ }
+
+ return $ancestors;
+ }
+
+ protected function fetchPlainObjects($table, $objectType = null)
+ {
+ $query = $this->db->select()
+ ->from(['o' => $table])
+ ->order('o.object_name');
+
+ if ($objectType !== null) {
+ $query->where('o.object_type = ?', $objectType);
+ }
+
+ return $this->db->fetchAll($query);
+ }
+
+
+ /**
+ * @param \stdClass $object
+ */
+ protected function replaceRelatedNames($object)
+ {
+ foreach ($this->nameMaps as $property => $map) {
+ if (\property_exists($object, $property)) {
+ // Hint: substr strips _id
+ if ($object->$property === null) {
+ $object->{\substr($property, 0, -3)} = null;
+ } else {
+ $object->{\substr($property, 0, -3)} = $map[$object->$property];
+ }
+ unset($object->$property);
+ }
+ }
+ }
+
+ protected function translateTemplateIdsToNames($ids)
+ {
+ $names = [];
+ foreach ($ids as $id) {
+ if (isset($this->templates[$id])) {
+ $names[] = $this->templates[$id]['object_name'];
+ } else {
+ throw new \RuntimeException("There is no template with ID $id");
+ }
+ }
+
+ return $names;
+ }
+
+ protected function stripIgnoredProperties($object)
+ {
+ foreach ($this->ignoredProperties as $key) {
+ unset($object->$key);
+ }
+ }
+
+ public function prepareNameMaps()
+ {
+ // TODO: fetch from dummy Object? How to ignore irrelevant ones like choices?
+ $relatedNames = [];
+ foreach ($this->relatedTables as $key => $relatedTable) {
+ $relatedNames[$key] = $this->fetchRelationMap($this->baseTable, $relatedTable, $key);
+ }
+
+ $this->nameMaps = $relatedNames;
+ }
+
+ protected function convertBooleans($object)
+ {
+ foreach ($this->booleans as $property) {
+ if (\property_exists($object, $property) && $object->$property !== null) {
+ // Hint: substr strips _id
+ $object->$property = $object->$property === 'y';
+ }
+ }
+ }
+
+ protected function stripNullProperties($object)
+ {
+ foreach (\array_keys((array) $object) as $property) {
+ if ($object->$property === null) {
+ unset($object->$property);
+ }
+ }
+ }
+
+ protected function fetchRelationMap($sourceTable, $destinationTable, $property)
+ {
+ $query = $this->db->select()
+ ->from(['d' => $destinationTable], ['d.id', 'd.object_name'])
+ ->join(['o' => $sourceTable], "d.id = o.$property", [])
+ ->order('d.object_name');
+
+ return $this->db->fetchPairs($query);
+ }
+
+ protected function fetchTemplateVars()
+ {
+ $query = $this->prepareVarsQuery()->where('o.object_type = ?', 'template');
+ return $this->fetchAndCombineVars($query);
+ }
+
+ protected function fetchNonTemplateVars()
+ {
+ $query = $this->prepareVarsQuery()->where('o.object_type != ?', 'template');
+ return $this->fetchAndCombineVars($query);
+ }
+
+ protected function fetchAndCombineVars($query)
+ {
+ $vars = [];
+ foreach ($this->db->fetchAll($query) as $var) {
+ $id = $var->object_id;
+ if (! isset($vars[$id])) {
+ $vars[$id] = [];
+ }
+ if ($var->format === 'json') {
+ $vars[$id][$var->varname] = \json_decode($var->varvalue);
+ } else {
+ $vars[$id][$var->varname] = $var->varvalue;
+ }
+ }
+
+ return $vars;
+ }
+
+ protected function fetchAllGroups()
+ {
+ $query = $this->prepareGroupsQuery();
+ return $this->fetchAndCombineGroups($query);
+ }
+
+ protected function fetchAllResolvedGroups()
+ {
+ $query = $this->prepareGroupsQuery(true);
+ return $this->fetchAndCombineGroups($query);
+ }
+
+ protected function fetchAndCombineGroups($query)
+ {
+ $groups = [];
+ foreach ($this->db->fetchAll($query) as $group) {
+ $id = $group->object_id;
+ if (isset($groups[$id])) {
+ $groups[$id][$group->group_id] = $group->group_name;
+ } else {
+ $groups[$id] = [
+ $group->group_id => $group->group_name
+ ];
+ }
+ }
+
+ return $groups;
+ }
+
+ protected function prepareGroupsQuery($resolved = false)
+ {
+ $type = $this->getType();
+ $groupsTable = $this->baseTable . 'group';
+ $groupMembershipTable = "${groupsTable}_$type";
+ if ($resolved) {
+ $groupMembershipTable .= '_resolved';
+ }
+ $oRef = "${type}_id";
+ $gRef = "${type}group_id";
+
+ return $this->db->select()
+ ->from(['gm' => $groupMembershipTable], [
+ 'object_id' => $oRef,
+ 'group_id' => $gRef,
+ 'group_name' => 'g.object_name'
+ ])
+ ->join(['g' => $groupsTable], "g.id = gm.$gRef", [])
+ ->order("gm.$oRef")
+ ->order('g.object_name');
+ }
+
+ protected function prepareVarsQuery()
+ {
+ $table = $this->baseTable . '_var';
+ $ref = $this->getType() . '_id';
+ return $this->db->select()
+ ->from(['v' => $table], [
+ 'object_id' => $ref,
+ 'v.varname',
+ 'v.varvalue',
+ 'v.format',
+ // 'v.checksum',
+ ])
+ ->join(['o' => $this->baseTable], "o.id = v.$ref", [])
+ ->order('o.id')
+ ->order('v.varname');
+ }
+
+ protected function getType()
+ {
+ return \preg_replace('/^icinga_/', '', $this->baseTable);
+ }
+
+ /**
+ * Helper, flattens all vars of a given object
+ *
+ * The object itself will be modified, and the 'vars' property will be
+ * replaced with corresponding 'vars.whatever' properties
+ *
+ * @param $object
+ * @param string $key
+ */
+ protected static function flattenVars(\stdClass $object, $key = 'vars')
+ {
+ if (\property_exists($object, $key)) {
+ foreach ($object->vars as $k => $v) {
+ if (\is_object($v)) {
+ static::flattenVars($v, $k);
+ }
+ $object->{$key . '.' . $k} = $v;
+ }
+ unset($object->$key);
+ }
+ }
+}
diff --git a/library/Director/Resolver/OverriddenVarsResolver.php b/library/Director/Resolver/OverriddenVarsResolver.php
new file mode 100644
index 0000000..4541244
--- /dev/null
+++ b/library/Director/Resolver/OverriddenVarsResolver.php
@@ -0,0 +1,74 @@
+<?php
+
+namespace Icinga\Module\Director\Resolver;
+
+use Icinga\Module\Director\Core\Json;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Objects\IcingaHost;
+
+class OverriddenVarsResolver
+{
+ /** @var \Zend_Db_Adapter_Abstract */
+ protected $db;
+
+ /** @var string */
+ protected $overrideVarName;
+
+ public function __construct(Db $connection)
+ {
+ $this->overrideVarName = $connection->settings()->get('override_services_varname');
+ $this->db = $connection->getDbAdapter();
+ }
+
+ public function fetchForHost(IcingaHost $host)
+ {
+ $overrides = [];
+ $parents = $host->listFlatResolvedImportNames();
+ if (empty($parents)) {
+ return $overrides;
+ }
+ $query = $this->db->select()
+ ->from(['hv' => 'icinga_host_var'], [
+ 'host_name' => 'h.object_name',
+ 'varvalue' => 'hv.varvalue',
+ ])
+ ->join(
+ ['h' => 'icinga_host'],
+ 'h.id = hv.host_id',
+ []
+ )
+ ->where('hv.varname = ?', $this->overrideVarName)
+ ->where('h.object_name IN (?)', $parents);
+
+ foreach ($this->db->fetchAll($query) as $row) {
+ if ($row->varvalue === null) {
+ continue;
+ }
+ foreach (Json::decode($row->varvalue) as $serviceName => $vars) {
+ $overrides[$serviceName][$row->host_name] = $vars;
+ }
+ }
+
+ return $overrides;
+ }
+
+ public function fetchForServiceName(IcingaHost $host, $serviceName)
+ {
+ $overrides = $this->fetchForHost($host);
+ if (isset($overrides[$serviceName])) {
+ return $overrides[$serviceName];
+ }
+
+ return [];
+ }
+
+ public function fetchVarForServiceName(IcingaHost $host, $serviceName, $varName)
+ {
+ $overrides = $this->fetchForHost($host);
+ if (isset($overrides[$serviceName][$varName])) {
+ return $overrides[$serviceName][$varName];
+ }
+
+ return null;
+ }
+}
diff --git a/library/Director/Resolver/OverrideHelper.php b/library/Director/Resolver/OverrideHelper.php
new file mode 100644
index 0000000..f911a4f
--- /dev/null
+++ b/library/Director/Resolver/OverrideHelper.php
@@ -0,0 +1,38 @@
+<?php
+
+namespace Icinga\Module\Director\Resolver;
+
+use Icinga\Module\Director\Objects\IcingaHost;
+use InvalidArgumentException;
+
+class OverrideHelper
+{
+ public static function applyOverriddenVars(IcingaHost $host, $serviceName, $properties)
+ {
+ static::assertVarsForOverrides($properties);
+ $current = $host->getOverriddenServiceVars($serviceName);
+ foreach ($properties as $key => $value) {
+ if ($key === 'vars') {
+ foreach ($value as $k => $v) {
+ $current->$k = $v;
+ }
+ } else {
+ $current->{substr($key, 5)} = $value;
+ }
+ }
+ $host->overrideServiceVars($serviceName, $current);
+ }
+
+ public static function assertVarsForOverrides($properties)
+ {
+ if (empty($properties)) {
+ return;
+ }
+
+ foreach ($properties as $key => $value) {
+ if ($key !== 'vars' && substr($key, 0, 5) !== 'vars.') {
+ throw new InvalidArgumentException("Only Custom Variables can be set based on Variable Overrides");
+ }
+ }
+ }
+}
diff --git a/library/Director/Resolver/TemplateTree.php b/library/Director/Resolver/TemplateTree.php
new file mode 100644
index 0000000..f8d8fed
--- /dev/null
+++ b/library/Director/Resolver/TemplateTree.php
@@ -0,0 +1,491 @@
+<?php
+
+namespace Icinga\Module\Director\Resolver;
+
+use Icinga\Application\Benchmark;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Exception\NestingError;
+use Icinga\Module\Director\Objects\IcingaObject;
+use InvalidArgumentException;
+use RuntimeException;
+
+class TemplateTree
+{
+ protected $connection;
+
+ protected $db;
+
+ protected $parents;
+
+ protected $children;
+
+ protected $rootNodes;
+
+ protected $tree;
+
+ protected $type;
+
+ protected $objectMaps;
+
+ protected $names;
+
+ protected $templateNameToId;
+
+ public function __construct($type, Db $connection)
+ {
+ $this->type = $type;
+ $this->connection = $connection;
+ $this->db = $connection->getDbAdapter();
+ }
+
+ public function getType()
+ {
+ return $this->type;
+ }
+
+ public function listParentNamesFor(IcingaObject $object)
+ {
+ $id = (int) $object->getProperty('id');
+ $this->requireTree();
+
+ if (array_key_exists($id, $this->parents)) {
+ return array_values($this->parents[$id]);
+ }
+
+ $this->requireObjectMaps();
+
+ $parents = [];
+ if (array_key_exists($id, $this->objectMaps)) {
+ foreach ($this->objectMaps[$id] as $pid) {
+ if (array_key_exists($pid, $this->names)) {
+ $parents[] = $this->names[$pid];
+ } else {
+ throw new RuntimeException(sprintf(
+ 'Got invalid parent id %d for %s "%s"',
+ $pid,
+ $this->type,
+ $object->getObjectName()
+ ));
+ }
+ }
+ }
+
+ return $parents;
+ }
+
+ protected function loadObjectMaps()
+ {
+ $this->requireTree();
+
+ $map = [];
+ $db = $this->db;
+ $type = $this->type;
+ $table = "icinga_${type}_inheritance";
+
+ $query = $db->select()->from(
+ ['i' => $table],
+ [
+ 'object' => "i.${type}_id",
+ 'parent' => "i.parent_${type}_id",
+ ]
+ )->order('i.weight');
+
+ foreach ($db->fetchAll($query) as $row) {
+ $id = (int) $row->object;
+ if (! array_key_exists($id, $map)) {
+ $map[$id] = [];
+ }
+ $map[$id][] = (int) $row->parent;
+ }
+
+ $this->objectMaps = $map;
+ }
+
+ public function listParentIdsFor(IcingaObject $object)
+ {
+ return array_keys($this->getParentsFor($object));
+ }
+
+ public function listAncestorIdsFor(IcingaObject $object)
+ {
+ return array_keys($this->getAncestorsFor($object));
+ }
+
+ public function listChildIdsFor(IcingaObject $object)
+ {
+ return array_keys($this->getChildrenFor($object));
+ }
+
+ public function listDescendantIdsFor(IcingaObject $object)
+ {
+ return array_keys($this->getDescendantsFor($object));
+ }
+
+ public function getParentsFor(IcingaObject $object)
+ {
+ // can not use hasBeenLoadedFromDb() when in onStore()
+ $id = $object->getProperty('id');
+ if ($id !== null) {
+ return $this->getParentsById($object->getProperty('id'));
+ } else {
+ throw new RuntimeException(
+ 'Loading parents for unstored objects has not been implemented yet'
+ );
+ // return $this->getParentsForUnstoredObject($object);
+ }
+ }
+
+ public function getAncestorsFor(IcingaObject $object)
+ {
+ if ($object->hasBeenModified()
+ && $object->gotImports()
+ && $object->imports()->hasBeenModified()
+ ) {
+ return $this->getAncestorsForUnstoredObject($object);
+ } else {
+ return $this->getAncestorsById($object->getProperty('id'));
+ }
+ }
+
+ protected function getAncestorsForUnstoredObject(IcingaObject $object)
+ {
+ $this->requireTree();
+ $ancestors = [];
+ foreach ($object->imports() as $import) {
+ $name = $import->getObjectName();
+ if ($import->hasBeenLoadedFromDb()) {
+ $pid = (int) $import->get('id');
+ } else {
+ if (! array_key_exists($name, $this->templateNameToId)) {
+ continue;
+ }
+ $pid = $this->templateNameToId[$name];
+ }
+
+ $this->getAncestorsById($pid, $ancestors);
+
+ // Hint: inheritance order matters
+ if (false !== ($key = array_search($name, $ancestors))) {
+ // Note: this used to be just unset($ancestors[$key]), and that
+ // broke Apply Rules inheriting from Templates with the same name
+ // in a way that related fields no longer showed up (#1602)
+ // This new if relaxes this and doesn't unset in case the name
+ // matches the original object name. However, I'm still unsure why
+ // this was required at all.
+ if ($name !== $object->getObjectName()) {
+ unset($ancestors[$key]);
+ }
+ }
+ $ancestors[$pid] = $name;
+ }
+
+ return $ancestors;
+ }
+
+ protected function requireObjectMaps()
+ {
+ if ($this->objectMaps === null) {
+ $this->loadObjectMaps();
+ }
+ }
+
+ public function getParentsById($id)
+ {
+ $this->requireTree();
+
+ if (array_key_exists($id, $this->parents)) {
+ return $this->parents[$id];
+ }
+
+ $this->requireObjectMaps();
+ if (array_key_exists($id, $this->objectMaps)) {
+ $parents = [];
+ foreach ($this->objectMaps[$id] as $pid) {
+ $parents[$pid] = $this->names[$pid];
+ }
+
+ return $parents;
+ } else {
+ return [];
+ }
+ }
+
+ /**
+ * @param $id
+ * @param $list
+ * @throws NestingError
+ */
+ protected function assertNotInList($id, &$list)
+ {
+ if (array_key_exists($id, $list)) {
+ $list = array_keys($list);
+ array_push($list, $id);
+
+ if (is_int($id)) {
+ throw new NestingError(
+ 'Loop detected: %s',
+ implode(' -> ', $this->getNamesForIds($list, true))
+ );
+ } else {
+ throw new NestingError(
+ 'Loop detected: %s',
+ implode(' -> ', $list)
+ );
+ }
+ }
+ }
+
+ protected function getNamesForIds($ids, $ignoreErrors = false)
+ {
+ $names = [];
+ foreach ($ids as $id) {
+ $names[] = $this->getNameForId($id, $ignoreErrors);
+ }
+
+ return $names;
+ }
+
+ protected function getNameForId($id, $ignoreErrors = false)
+ {
+ if (! array_key_exists($id, $this->names)) {
+ if ($ignoreErrors) {
+ return "id=$id";
+ } else {
+ throw new InvalidArgumentException("Got no name for $id");
+ }
+ }
+
+ return $this->names[$id];
+ }
+
+ /**
+ * @param $id
+ * @param array $ancestors
+ * @param array $path
+ * @return array
+ * @throws NestingError
+ */
+ public function getAncestorsById($id, &$ancestors = [], $path = [])
+ {
+ $path[$id] = true;
+ foreach ($this->getParentsById($id) as $pid => $name) {
+ $this->assertNotInList($pid, $path);
+ $path[$pid] = true;
+
+ $this->getAncestorsById($pid, $ancestors, $path);
+ unset($path[$pid]);
+
+ // Hint: inheritance order matters
+ if (false !== ($key = array_search($name, $ancestors))) {
+ unset($ancestors[$key]);
+ }
+ $ancestors[$pid] = $name;
+ }
+ unset($path[$id]);
+
+ return $ancestors;
+ }
+
+ public function getChildrenFor(IcingaObject $object)
+ {
+ // can not use hasBeenLoadedFromDb() when in onStore()
+ $id = $object->getProperty('id');
+ if ($id !== null) {
+ return $this->getChildrenById($id);
+ } else {
+ throw new RuntimeException(
+ 'Loading children for unstored objects has not been implemented yet'
+ );
+ // return $this->getChildrenForUnstoredObject($object);
+ }
+ }
+
+ public function getChildrenById($id)
+ {
+ $this->requireTree();
+
+ if (array_key_exists($id, $this->children)) {
+ return $this->children[$id];
+ } else {
+ return [];
+ }
+ }
+
+ public function getDescendantsFor(IcingaObject $object)
+ {
+ // can not use hasBeenLoadedFromDb() when in onStore()
+ $id = $object->getProperty('id');
+ if ($id !== null) {
+ return $this->getDescendantsById($id);
+ } else {
+ throw new RuntimeException(
+ 'Loading descendants for unstored objects has not been implemented yet'
+ );
+ // return $this->getDescendantsForUnstoredObject($object);
+ }
+ }
+
+ public function getDescendantsById($id, &$children = [], &$path = [])
+ {
+ $path[$id] = true;
+ foreach ($this->getChildrenById($id) as $pid => $name) {
+ $this->assertNotInList($pid, $path);
+ $path[$pid] = true;
+ $this->getDescendantsById($pid, $children, $path);
+ unset($path[$pid]);
+ $children[$pid] = $name;
+ }
+ unset($path[$id]);
+
+ return $children;
+ }
+
+ public function getTree($parentId = null)
+ {
+ if ($this->tree === null) {
+ $this->prepareTree();
+ }
+
+ if ($parentId === null) {
+ return $this->returnFullTree();
+ } else {
+ throw new RuntimeException(
+ 'Partial tree fetching has not been implemented yet'
+ );
+ // return $this->partialTree($parentId);
+ }
+ }
+
+ protected function returnFullTree()
+ {
+ $result = $this->rootNodes;
+ foreach ($result as $id => &$node) {
+ $this->addChildrenById($id, $node);
+ }
+
+ return $result;
+ }
+
+ protected function addChildrenById($pid, array &$base)
+ {
+ foreach ($this->getChildrenById($pid) as $id => $name) {
+ $base['children'][$id] = [
+ 'name' => $name,
+ 'children' => []
+ ];
+ $this->addChildrenById($id, $base['children'][$id]);
+ }
+ }
+
+ protected function prepareTree()
+ {
+ Benchmark::measure(sprintf('Prepare "%s" Template Tree', $this->type));
+ $templates = $this->fetchTemplates();
+ $parents = [];
+ $rootNodes = [];
+ $children = [];
+ $names = [];
+ foreach ($templates as $row) {
+ $id = (int) $row->id;
+ $pid = (int) $row->parent_id;
+ $names[$id] = $row->name;
+ if (! array_key_exists($id, $parents)) {
+ $parents[$id] = [];
+ }
+
+ if ($row->parent_id === null) {
+ $rootNodes[$id] = [
+ 'name' => $row->name,
+ 'children' => []
+ ];
+ continue;
+ }
+
+ $names[$pid] = $row->parent_name;
+ $parents[$id][$pid] = $row->parent_name;
+
+ if (! array_key_exists($pid, $children)) {
+ $children[$pid] = [];
+ }
+
+ $children[$pid][$id] = $row->name;
+ }
+
+ $this->parents = $parents;
+ $this->children = $children;
+ $this->rootNodes = $rootNodes;
+ $this->names = $names;
+ $this->templateNameToId = array_flip($names);
+ Benchmark::measure(sprintf('"%s" Template Tree ready', $this->type));
+ }
+
+ public function fetchObjects()
+ {
+ //??
+ }
+
+ protected function requireTree()
+ {
+ if ($this->parents === null) {
+ $this->prepareTree();
+ }
+ }
+
+ public function fetchTemplates()
+ {
+ $db = $this->db;
+ $type = $this->type;
+ $table = "icinga_$type";
+
+ if ($type === 'command') {
+ $joinCondition = $db->quoteInto(
+ "p.id = i.parent_${type}_id",
+ 'template'
+ );
+ } else {
+ $joinCondition = $db->quoteInto(
+ "p.id = i.parent_${type}_id AND p.object_type = ?",
+ 'template'
+ );
+ }
+
+ $query = $db->select()->from(
+ ['o' => $table],
+ [
+ 'id' => 'o.id',
+ 'name' => 'o.object_name',
+ 'object_type' => 'o.object_type',
+ 'parent_id' => 'p.id',
+ 'parent_name' => 'p.object_name',
+ ]
+ )->joinLeft(
+ ['i' => $table . '_inheritance'],
+ 'o.id = i.' . $type . '_id',
+ []
+ )->joinLeft(
+ ['p' => $table],
+ $joinCondition,
+ []
+ )->order('o.id')->order('i.weight');
+
+ if ($type !== 'command') {
+ $query->where(
+ 'o.object_type = ?',
+ 'template'
+ );
+ }
+
+ return $db->fetchAll($query);
+ }
+}
+
+/**
+ *
+SELECT o.id, o.object_name AS name, o.object_type, p.id AS parent_id,
+ p.object_name AS parent_name FROM icinga_service AS o
+RIGHT JOIN icinga_service_inheritance AS i ON o.id = i.service_id
+RIGHT JOIN icinga_service AS p ON p.id = i.parent_service_id
+ WHERE (p.object_type = 'template') AND (o.object_type = 'template')
+ ORDER BY o.id ASC, i.weight ASC
+
+ */
diff --git a/library/Director/RestApi/IcingaObjectHandler.php b/library/Director/RestApi/IcingaObjectHandler.php
new file mode 100644
index 0000000..7329be3
--- /dev/null
+++ b/library/Director/RestApi/IcingaObjectHandler.php
@@ -0,0 +1,196 @@
+<?php
+
+namespace Icinga\Module\Director\RestApi;
+
+use Exception;
+use Icinga\Exception\IcingaException;
+use Icinga\Exception\NotFoundError;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Module\Director\Core\CoreApi;
+use Icinga\Module\Director\Data\Exporter;
+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 RuntimeException;
+
+class IcingaObjectHandler extends RequestHandler
+{
+ /** @var IcingaObject */
+ protected $object;
+
+ /** @var CoreApi */
+ protected $api;
+
+ public function setObject(IcingaObject $object)
+ {
+ $this->object = $object;
+ return $this;
+ }
+
+ public function setApi(CoreApi $api)
+ {
+ $this->api = $api;
+ return $this;
+ }
+
+ /**
+ * @return IcingaObject
+ * @throws ProgrammingError
+ */
+ protected function requireObject()
+ {
+ if ($this->object === null) {
+ throw new ProgrammingError('Object is required');
+ }
+
+ return $this->object;
+ }
+
+ /**
+ * @return IcingaObject
+ */
+ protected function loadOptionalObject()
+ {
+ return $this->object;
+ }
+
+ protected function requireJsonBody()
+ {
+ $data = json_decode($this->request->getRawBody());
+
+ if ($data === null) {
+ $this->response->setHttpResponseCode(400);
+ throw new IcingaException(
+ 'Invalid JSON: %s',
+ $this->getLastJsonError()
+ );
+ }
+
+ return $data;
+ }
+
+ protected function getType()
+ {
+ return $this->request->getControllerName();
+ }
+
+ protected function processApiRequest()
+ {
+ try {
+ $this->handleApiRequest();
+ } catch (NotFoundError $e) {
+ $this->sendJsonError($e, 404);
+ return;
+ } catch (DuplicateKeyException $e) {
+ $this->sendJsonError($e, 422);
+ return;
+ } catch (Exception $e) {
+ $this->sendJsonError($e);
+ }
+
+ if ($this->request->getActionName() !== 'index') {
+ throw new NotFoundError('Not found');
+ }
+ }
+
+ protected function handleApiRequest()
+ {
+ $request = $this->request;
+ $db = $this->db;
+
+ // TODO: I hate doing this:
+ if ($this->request->getActionName() === 'ticket') {
+ $host = $this->requireObject();
+
+ if ($host->getResolvedProperty('has_agent') !== 'y') {
+ throw new NotFoundError('The host "%s" is not an agent', $host->getObjectName());
+ }
+
+ $this->sendJson($this->api->getTicket($host->getObjectName()));
+
+ // TODO: find a better way to shut down. Currently, this avoids
+ // "not found" errors:
+ exit;
+ }
+
+ switch ($request->getMethod()) {
+ case 'DELETE':
+ $object = $this->requireObject();
+ $object->delete();
+ $this->sendJson($object->toPlainObject(false, true));
+ 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));
+ }
+ $this->persistChanges($object);
+ $this->sendJson($object->toPlainObject(false, true));
+ } elseif ($allowsOverrides && $type === 'service') {
+ if ($request->getMethod() === 'PUT') {
+ throw new InvalidArgumentException('Overrides are not (yet) available for HTTP PUT');
+ }
+ $this->setServiceProperties($params->getRequired('host'), $params->getRequired('name'), $data);
+ } else {
+ $object = IcingaObject::createByType($type, $data, $db);
+ $this->persistChanges($object);
+ $this->sendJson($object->toPlainObject(false, true));
+ }
+ break;
+
+ case 'GET':
+ $object = $this->requireObject();
+ $exporter = new Exporter($this->db);
+ RestApiParams::applyParamsToExporter($exporter, $this->request, $object->getShortTableName());
+ $this->sendJson($exporter->export($object));
+ break;
+
+ default:
+ $request->getResponse()->setHttpResponseCode(400);
+ throw new IcingaException('Unsupported method ' . $request->getMethod());
+ }
+ }
+
+ protected function persistChanges(IcingaObject $object)
+ {
+ if ($object->hasBeenModified()) {
+ $status = $object->hasBeenLoadedFromDb() ? 200 : 201;
+ $object->store();
+ $this->response->setHttpResponseCode($status);
+ } else {
+ $this->response->setHttpResponseCode(304);
+ }
+ }
+
+ protected function setServiceProperties($hostname, $serviceName, $properties)
+ {
+ $host = IcingaHost::load($hostname, $this->db);
+ $service = ServiceFinder::find($host, $serviceName);
+ if ($service === false) {
+ throw new NotFoundError('Not found');
+ }
+ if ($service->requiresOverrides()) {
+ unset($properties['host']);
+ OverrideHelper::applyOverriddenVars($host, $serviceName, $properties);
+ $this->persistChanges($host);
+ $this->sendJson($host->toPlainObject(false, true));
+ } else {
+ throw new RuntimeException('Found a single service, which should have been found (and dealt with) before');
+ }
+ }
+}
diff --git a/library/Director/RestApi/IcingaObjectsHandler.php b/library/Director/RestApi/IcingaObjectsHandler.php
new file mode 100644
index 0000000..471987a
--- /dev/null
+++ b/library/Director/RestApi/IcingaObjectsHandler.php
@@ -0,0 +1,144 @@
+<?php
+
+namespace Icinga\Module\Director\RestApi;
+
+use Exception;
+use gipfl\Json\JsonString;
+use Icinga\Application\Benchmark;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Module\Director\Data\Exporter;
+use Icinga\Module\Director\Db\Cache\PrefetchCache;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Icinga\Module\Director\Web\Table\ApplyRulesTable;
+use Icinga\Module\Director\Web\Table\ObjectsTable;
+use Zend_Db_Select as ZfSelect;
+
+class IcingaObjectsHandler extends RequestHandler
+{
+ /** @var ObjectsTable */
+ protected $table;
+
+ public function processApiRequest()
+ {
+ try {
+ $this->streamJsonResult();
+ } catch (Exception $e) {
+ $this->sendJsonError($e);
+ }
+ }
+
+ /**
+ * @param ObjectsTable|ApplyRulesTable $table
+ * @return $this
+ */
+ public function setTable($table)
+ {
+ $this->table = $table;
+ return $this;
+ }
+
+ /**
+ * @return ObjectsTable
+ * @throws ProgrammingError
+ */
+ protected function getTable()
+ {
+ if ($this->table === null) {
+ throw new ProgrammingError('Table is required');
+ }
+
+ return $this->table;
+ }
+
+ /**
+ * @throws ProgrammingError
+ * @throws \Zend_Db_Select_Exception
+ * @throws \Zend_Db_Statement_Exception
+ */
+ protected function streamJsonResult()
+ {
+ $this->response->setHeader('Content-Type', 'application/json', true);
+ $this->response->sendHeaders();
+ $connection = $this->db;
+ Benchmark::measure('Ready to stream JSON result');
+ $db = $connection->getDbAdapter();
+ $table = $this->getTable();
+ $exporter = new Exporter($connection);
+ $type = $table->getType();
+ RestApiParams::applyParamsToExporter($exporter, $this->request, $type);
+ $query = $table
+ ->getQuery()
+ ->reset(ZfSelect::COLUMNS)
+ ->columns('*')
+ ->reset(ZfSelect::LIMIT_COUNT)
+ ->reset(ZfSelect::LIMIT_OFFSET);
+ if ($type === 'service' && $table instanceof ApplyRulesTable) {
+ $exporter->showIds();
+ }
+ echo '{ "objects": [ ';
+ $cnt = 0;
+ $objects = [];
+
+ $dummy = IcingaObject::createByType($type, [], $connection);
+ $dummy->prefetchAllRelatedTypes();
+
+ Benchmark::measure('Pre-fetching related objects');
+ PrefetchCache::initialize($this->db);
+ Benchmark::measure('Ready to query');
+ $stmt = $db->query($query);
+ $this->response->sendHeaders();
+ if (! ob_get_level()) {
+ ob_start();
+ }
+
+ $first = true;
+ $flushes = 0;
+ while ($row = $stmt->fetch()) {
+ /** @var IcingaObject $object */
+ if ($first) {
+ Benchmark::measure('Fetching first row');
+ }
+ $object = $dummy::fromDbRow($row, $connection);
+ $objects[] = JsonString::encode($exporter->export($object), JSON_PRETTY_PRINT);
+ if ($first) {
+ Benchmark::measure('Got first row');
+ $first = false;
+ }
+ $cnt++;
+ if ($cnt === 100) {
+ if ($flushes > 0) {
+ echo ', ';
+ }
+ echo implode(', ', $objects);
+ $cnt = 0;
+ $objects = [];
+ $flushes++;
+ ob_end_flush();
+ ob_start();
+ }
+ }
+
+ if ($cnt > 0) {
+ if ($flushes > 0) {
+ echo ', ';
+ }
+ echo implode(', ', $objects);
+ }
+
+ if ($this->request->getUrl()->getParams()->get('benchmark')) {
+ echo "],\n";
+ Benchmark::measure('All done');
+ echo '"benchmark_string": ' . json_encode(Benchmark::renderToText());
+ } else {
+ echo '] ';
+ }
+
+ echo "}\n";
+ if (ob_get_level()) {
+ ob_end_flush();
+ }
+
+ // TODO: can we improve this?
+ exit;
+ }
+}
diff --git a/library/Director/RestApi/RequestHandler.php b/library/Director/RestApi/RequestHandler.php
new file mode 100644
index 0000000..6f66889
--- /dev/null
+++ b/library/Director/RestApi/RequestHandler.php
@@ -0,0 +1,86 @@
+<?php
+
+namespace Icinga\Module\Director\RestApi;
+
+use Exception;
+use gipfl\Json\JsonString;
+use Icinga\Module\Director\Db;
+use Icinga\Web\Request;
+use Icinga\Web\Response;
+
+abstract class RequestHandler
+{
+ /** @var Request */
+ protected $request;
+
+ /** @var Response */
+ protected $response;
+
+ /** @var Db */
+ protected $db;
+
+ public function __construct(Request $request, Response $response, Db $db)
+ {
+ $this->request = $request;
+ $this->response = $response;
+ $this->db = $db;
+ }
+
+ abstract protected function processApiRequest();
+
+ public function dispatch()
+ {
+ $this->processApiRequest();
+ }
+
+ public function sendJson($object)
+ {
+ $this->response->setHeader('Content-Type', 'application/json', true);
+ $this->response->sendHeaders();
+ echo JsonString::encode($object, JSON_PRETTY_PRINT) . "\n";
+ }
+
+ public function sendJsonError($error, $code = null)
+ {
+ $response = $this->response;
+ if ($code === null) {
+ if ($response->getHttpResponseCode() === 200) {
+ $response->setHttpResponseCode(500);
+ }
+ } else {
+ $response->setHttpResponseCode((int) $code);
+ }
+
+ if ($error instanceof Exception) {
+ $message = $error->getMessage();
+ } else {
+ $message = $error;
+ }
+
+ $response->sendHeaders();
+ $result = ['error' => $message];
+ if ($this->request->getUrl()->getParam('showStacktrace')) {
+ $result['trace'] = $error->getTraceAsString();
+ }
+ $this->sendJson((object) $result);
+ }
+
+ // TODO: just return json_last_error_msg() for PHP >= 5.5.0
+ protected function getLastJsonError()
+ {
+ switch (json_last_error()) {
+ case JSON_ERROR_DEPTH:
+ return 'The maximum stack depth has been exceeded';
+ case JSON_ERROR_CTRL_CHAR:
+ return 'Control character error, possibly incorrectly encoded';
+ case JSON_ERROR_STATE_MISMATCH:
+ return 'Invalid or malformed JSON';
+ case JSON_ERROR_SYNTAX:
+ return 'Syntax error';
+ case JSON_ERROR_UTF8:
+ return 'Malformed UTF-8 characters, possibly incorrectly encoded';
+ default:
+ return 'An error occured when parsing a JSON string';
+ }
+ }
+}
diff --git a/library/Director/RestApi/RestApiClient.php b/library/Director/RestApi/RestApiClient.php
new file mode 100644
index 0000000..2ebc4d4
--- /dev/null
+++ b/library/Director/RestApi/RestApiClient.php
@@ -0,0 +1,311 @@
+<?php
+
+namespace Icinga\Module\Director\RestApi;
+
+use Icinga\Module\Director\Core\Json;
+use InvalidArgumentException;
+use RuntimeException;
+
+class RestApiClient
+{
+ /** @var resource */
+ private $curl;
+
+ /** @var string HTTP or HTTPS */
+ private $scheme;
+
+ /** @var string */
+ private $host;
+
+ /** @var int */
+ private $port;
+
+ /** @var string */
+ private $user;
+
+ /** @var string */
+ private $pass;
+
+ /** @var bool */
+ private $verifySslPeer = true;
+
+ /** @var bool */
+ private $verifySslHost = true;
+
+ /** @var string */
+ private $proxy;
+
+ /** @var string */
+ private $proxyType;
+
+ /** @var string */
+ private $proxyUser;
+
+ /** @var string */
+ private $proxyPass;
+
+ /** @var array */
+ private $proxyTypes = [
+ 'HTTP' => CURLPROXY_HTTP,
+ 'SOCKS5' => CURLPROXY_SOCKS5,
+ ];
+
+ /**
+ * RestApiClient constructor.
+ *
+ * Please note that only the host is required, user and pass are optional
+ *
+ * @param string $host
+ * @param string|null $user
+ * @param string|null $pass
+ */
+ public function __construct($host, $user = null, $pass = null)
+ {
+ $this->host = $host;
+ $this->user = $user;
+ $this->pass = $pass;
+ }
+
+ /**
+ * Use a proxy
+ *
+ * @param $url
+ * @param string $type Either HTTP or SOCKS5
+ * @return $this
+ */
+ public function setProxy($url, $type = 'HTTP')
+ {
+ $this->proxy = $url;
+ if (\is_int($type)) {
+ $this->proxyType = $type;
+ } else {
+ $this->proxyType = $this->proxyTypes[$type];
+ }
+ return $this;
+ }
+
+ /**
+ * @param string $user
+ * @param string $pass
+ * @return $this
+ */
+ public function setProxyAuth($user, $pass)
+ {
+ $this->proxyUser = $user;
+ $this->proxyPass = $pass;
+ return $this;
+ }
+
+ /**
+ * @return string
+ */
+ public function getScheme()
+ {
+ if ($this->scheme === null) {
+ return 'HTTPS';
+ } else {
+ return $this->scheme;
+ }
+ }
+
+ public function setScheme($scheme)
+ {
+ $scheme = \strtoupper($scheme);
+ if (! \in_array($scheme, ['HTTP', 'HTTPS'])) {
+ throw new InvalidArgumentException("Got invalid scheme: $scheme");
+ }
+
+ $this->scheme = $scheme;
+ return $this;
+ }
+
+ /**
+ * @return string
+ */
+ public function getPort()
+ {
+ if ($this->port === null) {
+ return $this->getScheme() === 'HTTPS' ? 443 : 80;
+ } else {
+ return $this->port;
+ }
+ }
+
+ /**
+ * @param int|string|null $port
+ * @return $this
+ */
+ public function setPort($port)
+ {
+ if ($port === null) {
+ $this->port = null;
+ return $this;
+ }
+ $port = (int) ($port);
+ if ($port < 1 || $port > 65535) {
+ throw new InvalidArgumentException("Got invalid port: $port");
+ }
+
+ $this->port = $port;
+ return $this;
+ }
+
+ /**
+ * @return bool
+ */
+ public function isDefaultPort()
+ {
+ return $this->port === null
+ || $this->getScheme() === 'HTTPS' && $this->getPort() === 443
+ || $this->getScheme() === 'HTTP' && $this->getPort() === 80;
+ }
+
+ /**
+ * @param bool $disable
+ * @return $this
+ */
+ public function disableSslPeerVerification($disable = true)
+ {
+ $this->verifySslPeer = ! $disable;
+ return $this;
+ }
+
+ /**
+ * @param bool $disable
+ * @return $this
+ */
+ public function disableSslHostVerification($disable = true)
+ {
+ $this->verifySslHost = ! $disable;
+ return $this;
+ }
+
+ /**
+ * @param string $url
+ * @return string
+ */
+ public function url($url)
+ {
+ return \sprintf(
+ '%s://%s%s/%s',
+ \strtolower($this->getScheme()),
+ $this->host,
+ $this->isDefaultPort() ? '' : ':' . $this->getPort(),
+ ltrim($url, '/')
+ );
+ }
+
+ /**
+ * @param string $url
+ * @param mixed $body
+ * @param array $headers
+ * @return mixed
+ */
+ public function get($url, $body = null, $headers = [])
+ {
+ return $this->request('get', $url, $body, $headers);
+ }
+
+ /**
+ * @param $url
+ * @param null $body
+ * @param array $headers
+ * @return mixed
+ */
+ public function post($url, $body = null, $headers = [])
+ {
+ return $this->request('post', $url, Json::encode($body), $headers);
+ }
+
+ /**
+ * @param $method
+ * @param $url
+ * @param null $body
+ * @param array $headers
+ * @return mixed
+ */
+ protected function request($method, $url, $body = null, $headers = [])
+ {
+ $sendHeaders = ['Host: ' . $this->host];
+ foreach ($headers as $key => $val) {
+ $sendHeaders[] = "$key: $val";
+ }
+
+ if (! \in_array('Accept', $headers)) {
+ $sendHeaders[] = 'Accept: application/json';
+ }
+
+ $url = $this->url($url);
+ $opts = [
+ CURLOPT_URL => $url,
+ CURLOPT_HTTPHEADER => $sendHeaders,
+ CURLOPT_CUSTOMREQUEST => \strtoupper($method),
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_CONNECTTIMEOUT => 5,
+ ];
+
+ if ($this->getScheme() === 'HTTPS') {
+ $opts[CURLOPT_SSL_VERIFYPEER] = $this->verifySslPeer;
+ $opts[CURLOPT_SSL_VERIFYHOST] = $this->verifySslHost ? 2 : 0;
+ }
+
+ if ($this->user !== null) {
+ $opts[CURLOPT_USERPWD] = \sprintf('%s:%s', $this->user, $this->pass);
+ }
+
+ if ($this->proxy) {
+ $opts[CURLOPT_PROXY] = $this->proxy;
+ $opts[CURLOPT_PROXYTYPE] = $this->proxyType;
+
+ if ($this->proxyUser) {
+ $opts['CURLOPT_PROXYUSERPWD'] = \sprintf(
+ '%s:%s',
+ $this->proxyUser,
+ $this->proxyPass
+ );
+ }
+ }
+
+ if ($body !== null) {
+ $opts[CURLOPT_POSTFIELDS] = $body;
+ }
+
+ $curl = $this->curl();
+ \curl_setopt_array($curl, $opts);
+
+ $res = \curl_exec($curl);
+ if ($res === false) {
+ throw new RuntimeException('CURL ERROR: ' . \curl_error($curl));
+ }
+
+ $statusCode = \curl_getinfo($curl, CURLINFO_HTTP_CODE);
+ if ($statusCode === 401) {
+ throw new RuntimeException(
+ 'Unable to authenticate, please check your REST API credentials'
+ );
+ }
+
+ if ($statusCode >= 400) {
+ throw new RuntimeException(
+ "Got $statusCode: " . \var_export($res, 1)
+ );
+ }
+
+ return Json::decode($res);
+ }
+
+ /**
+ * @return resource
+ */
+ protected function curl()
+ {
+ if ($this->curl === null) {
+ $this->curl = \curl_init(\sprintf('https://%s:%d', $this->host, $this->port));
+ if (! $this->curl) {
+ throw new RuntimeException('CURL INIT ERROR: ' . \curl_error($this->curl));
+ }
+ }
+
+ return $this->curl;
+ }
+}
diff --git a/library/Director/RestApi/RestApiParams.php b/library/Director/RestApi/RestApiParams.php
new file mode 100644
index 0000000..c237ac5
--- /dev/null
+++ b/library/Director/RestApi/RestApiParams.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace Icinga\Module\Director\RestApi;
+
+use Icinga\Module\Director\Data\Exporter;
+use Icinga\Web\Request;
+use InvalidArgumentException;
+
+class RestApiParams
+{
+ public static function applyParamsToExporter(Exporter $exporter, Request $request, $shortObjectType = null)
+ {
+ $params = $request->getUrl()->getParams();
+ $resolved = (bool) $params->get('resolved', false);
+ $withNull = $params->shift('withNull');
+ if ($params->get('withServices')) {
+ if ($shortObjectType !== 'host') {
+ throw new InvalidArgumentException('withServices is available for Hosts only');
+ }
+ $exporter->enableHostServices();
+ }
+ $properties = $params->shift('properties');
+ if ($properties !== null && strlen($properties)) {
+ $exporter->filterProperties(preg_split('/\s*,\s*/', $properties, -1, PREG_SPLIT_NO_EMPTY));
+ }
+ $exporter->resolveObjects($resolved);
+ $exporter->showDefaults($withNull);
+ }
+}
diff --git a/library/Director/Restriction/FilterByNameRestriction.php b/library/Director/Restriction/FilterByNameRestriction.php
new file mode 100644
index 0000000..8c3b256
--- /dev/null
+++ b/library/Director/Restriction/FilterByNameRestriction.php
@@ -0,0 +1,64 @@
+<?php
+
+namespace Icinga\Module\Director\Restriction;
+
+use gipfl\IcingaWeb2\Zf1\Db\FilterRenderer;
+use Icinga\Authentication\Auth;
+use Icinga\Data\Filter\Filter;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Zend_Db_Select as ZfSelect;
+
+class FilterByNameRestriction extends ObjectRestriction
+{
+ protected $type;
+
+ /** @var Filter */
+ protected $filter;
+
+ public function __construct(Db $connection, Auth $auth, $type)
+ {
+ parent::__construct($connection, $auth);
+ $this->setType($type);
+ }
+
+ protected function setType($type)
+ {
+ $this->type = $type;
+ $this->setNameForType($type);
+ }
+
+ protected function setNameForType($type)
+ {
+ $this->name = "director/${type}/filter-by-name";
+ }
+
+ public function allows(IcingaObject $object)
+ {
+ if (! $this->isRestricted()) {
+ return true;
+ }
+
+ return $this->getFilter()->matches([
+ (object) ['object_name' => $object->getObjectName()]
+ ]);
+ }
+
+ public function getFilter()
+ {
+ if ($this->filter === null) {
+ $this->filter = MatchingFilter::forUser(
+ $this->auth->getUser(),
+ $this->name,
+ 'object_name'
+ );
+ }
+
+ return $this->filter;
+ }
+
+ protected function filterQuery(ZfSelect $query, $tableAlias = 'o')
+ {
+ FilterRenderer::applyToQuery($this->getFilter(), $query);
+ }
+}
diff --git a/library/Director/Restriction/HostgroupRestriction.php b/library/Director/Restriction/HostgroupRestriction.php
new file mode 100644
index 0000000..1a6792b
--- /dev/null
+++ b/library/Director/Restriction/HostgroupRestriction.php
@@ -0,0 +1,171 @@
+<?php
+
+namespace Icinga\Module\Director\Restriction;
+
+use Icinga\Exception\ProgrammingError;
+use Icinga\Module\Director\Db\IcingaObjectFilterHelper;
+use Icinga\Module\Director\Objects\IcingaHost;
+use Icinga\Module\Director\Objects\IcingaHostGroup;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Zend_Db_Select as ZfSelect;
+
+class HostgroupRestriction extends ObjectRestriction
+{
+ protected $name = 'director/filter/hostgroups';
+
+ public function allows(IcingaObject $object)
+ {
+ if ($object instanceof IcingaHost) {
+ return $this->allowsHost($object);
+ } elseif ($object instanceof IcingaHostGroup) {
+ return $this->allowsHostGroup($object);
+ } else {
+ return $this;
+ }
+ }
+
+ protected function filterQuery(ZfSelect $query, $tableAlias = 'o')
+ {
+ $table = $this->getQueryTableByAlias($query, $tableAlias);
+ switch ($table) {
+ case 'icinga_host':
+ $this->filterHostsQuery($query, $tableAlias);
+ break;
+ case 'icinga_service':
+ // TODO: Alias is hardcoded
+ $this->filterHostsQuery($query, 'h');
+ break;
+ case 'icinga_hostgroup':
+ $this->filterHostGroupsQuery($query, $tableAlias);
+ break;
+ // Hint: other tables are ignored, so please take care!
+ }
+
+ return $query;
+ }
+
+ /**
+ * Whether access to the given host is allowed
+ *
+ * @param IcingaHost $host
+ * @return bool
+ */
+ public function allowsHost(IcingaHost $host)
+ {
+ if (! $this->isRestricted()) {
+ return true;
+ }
+
+ // Hint: branched hosts have no id
+ if (! $host->hasBeenLoadedFromDb() || $host->hasModifiedGroups() || $host->get('id') === null) {
+ foreach ($this->listRestrictedHostgroups() as $group) {
+ if ($host->hasGroup($group)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ $query = $this->db->select()->from(
+ ['o' => 'icinga_host'],
+ ['id']
+ )->where('o.id = ?', $host->id);
+
+ $this->filterHostsQuery($query);
+ return (int) $this->db->fetchOne($query) === (int) $host->get('id');
+ }
+
+ /**
+ * Whether access to the given hostgroup is allowed
+ *
+ * @param IcingaHostGroup $hostgroup
+ * @return bool
+ */
+ public function allowsHostGroup(IcingaHostGroup $hostgroup)
+ {
+ if (! $this->isRestricted()) {
+ return true;
+ }
+
+ $query = $this->db->select()->from(
+ ['h' => 'icinga_hostgroup'],
+ ['id']
+ )->where('id = ?', $hostgroup->id);
+
+ $this->filterHostGroupsQuery($query);
+ return (int) $this->db->fetchOne($query) === (int) $hostgroup->get('id');
+ }
+
+ /**
+ * Apply the restriction to the given Hosts Query
+ *
+ * We assume that the query wants to fetch hosts and that therefore the
+ * icinga_host table already exists in the given query, using the $tableAlias
+ * alias.
+ *
+ * @param ZfSelect $query
+ * @param string $tableAlias
+ */
+ public function filterHostsQuery(ZfSelect $query, $tableAlias = 'o')
+ {
+ if (! $this->isRestricted()) {
+ return;
+ }
+
+ IcingaObjectFilterHelper::filterByResolvedHostgroups(
+ $query,
+ 'host',
+ $this->listRestrictedHostgroups(),
+ $tableAlias
+ );
+ }
+
+ /**
+ * Apply the restriction to the given Hosts Query
+ *
+ * We assume that the query wants to fetch hosts and that therefore the
+ * icinga_host table already exists in the given query, using the $tableAlias
+ * alias.
+ *
+ * @param ZfSelect $query
+ * @param string $tableAlias
+ */
+ protected function filterHostGroupsQuery(ZfSelect $query, $tableAlias = 'o')
+ {
+ if (! $this->isRestricted()) {
+ return;
+ }
+ $groups = $this->listRestrictedHostgroups();
+
+ if (empty($groups)) {
+ $query->where('(1 = 0)');
+ } else {
+ $query->where("${tableAlias}.object_name IN (?)", $groups);
+ }
+ }
+
+ /**
+ * Give a list of allowed Hostgroups
+ *
+ * When not restricted, null is returned. This might eventually also give
+ * an empty list, and therefore not allow any access at all
+ *
+ * @return array|null
+ */
+ protected function listRestrictedHostgroups()
+ {
+ if ($restrictions = $this->auth->getRestrictions($this->getName())) {
+ $groups = array();
+ foreach ($restrictions as $restriction) {
+ foreach ($this->gracefullySplitOnComma($restriction) as $group) {
+ $groups[$group] = $group;
+ }
+ }
+
+ return array_keys($groups);
+ } else {
+ return null;
+ }
+ }
+}
diff --git a/library/Director/Restriction/MatchingFilter.php b/library/Director/Restriction/MatchingFilter.php
new file mode 100644
index 0000000..162840c
--- /dev/null
+++ b/library/Director/Restriction/MatchingFilter.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace Icinga\Module\Director\Restriction;
+
+use Icinga\Data\Filter\Filter;
+use Icinga\User;
+
+class MatchingFilter
+{
+ public static function forPatterns(array $restrictions, $columnName)
+ {
+ $filters = [];
+ foreach ($restrictions as $restriction) {
+ foreach (preg_split('/\|/', $restriction) as $pattern) {
+ $filters[] = Filter::expression(
+ $columnName,
+ '=',
+ $pattern
+ );
+ }
+ }
+
+ if (count($filters) === 1) {
+ return $filters[0];
+ } else {
+ return Filter::matchAny($filters);
+ }
+ }
+
+ public static function forUser(
+ User $user,
+ $restrictionName,
+ $columnName
+ ) {
+ return static::forPatterns(
+ $user->getRestrictions($restrictionName),
+ $columnName
+ );
+ }
+}
diff --git a/library/Director/Restriction/ObjectRestriction.php b/library/Director/Restriction/ObjectRestriction.php
new file mode 100644
index 0000000..9161ebb
--- /dev/null
+++ b/library/Director/Restriction/ObjectRestriction.php
@@ -0,0 +1,84 @@
+<?php
+
+namespace Icinga\Module\Director\Restriction;
+
+use Icinga\Authentication\Auth;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Zend_Db_Select as ZfSelect;
+
+abstract class ObjectRestriction
+{
+ /** @var string */
+ protected $name;
+
+ /** @var \Zend_Db_Adapter_Abstract */
+ protected $db;
+
+ /** @var Auth */
+ protected $auth;
+
+ public function __construct(Db $connection, Auth $auth)
+ {
+ $this->db = $connection->getDbAdapter();
+ $this->auth = $auth;
+ }
+
+ abstract public function allows(IcingaObject $object);
+
+ /**
+ * Apply the restriction to the given Hosts Query
+ *
+ * We assume that the query wants to fetch hosts and that therefore the
+ * icinga_host table already exists in the given query, using the $tableAlias
+ * alias.
+ *
+ * @param ZfSelect $query
+ * @param string $tableAlias
+ */
+ abstract protected function filterQuery(ZfSelect $query, $tableAlias = 'o');
+
+ public function applyToQuery(ZfSelect $query, $tableAlias = 'o')
+ {
+ if ($this->isRestricted()) {
+ $this->filterQuery($query, $tableAlias);
+ }
+
+ return $query;
+ }
+
+ public function getName()
+ {
+ if ($this->name === null) {
+ throw new ProgrammingError('ObjectRestriction has no name');
+ }
+
+ return $this->name;
+ }
+
+ public function isRestricted()
+ {
+ $restrictions = $this->auth->getRestrictions($this->getName());
+ return ! empty($restrictions);
+ }
+
+ protected function getQueryTableByAlias(ZfSelect $query, $tableAlias)
+ {
+ $from = $query->getPart(ZfSelect::FROM);
+ if (! array_key_exists($tableAlias, $from)) {
+ throw new ProgrammingError(
+ 'Cannot restrict query with alias "%s", got %s',
+ $tableAlias,
+ json_encode($from)
+ );
+ }
+
+ return $from[$tableAlias]['tableName'];
+ }
+
+ protected function gracefullySplitOnComma($string)
+ {
+ return preg_split('/\s*,\s*/', $string, -1, PREG_SPLIT_NO_EMPTY);
+ }
+}
diff --git a/library/Director/Settings.php b/library/Director/Settings.php
new file mode 100644
index 0000000..d3e0987
--- /dev/null
+++ b/library/Director/Settings.php
@@ -0,0 +1,219 @@
+<?php
+
+namespace Icinga\Module\Director;
+
+class Settings
+{
+ protected $connection;
+
+ protected $db;
+
+ protected $cache;
+
+ protected $defaults = [
+ 'default_global_zone' => 'director-global',
+ 'icinga_package_name' => 'director',
+ 'config_format' => 'v2',
+ 'override_services_varname' => '_override_servicevars',
+ 'override_services_templatename' => 'host var overrides (Director)',
+ 'disable_all_jobs' => 'n', // 'y'
+ 'enable_audit_log' => 'n',
+ 'deployment_mode_v1' => 'active-passive',
+ 'deployment_path_v1' => null,
+ 'activation_script_v1' => null,
+ 'self-service/agent_name' => 'fqdn',
+ 'self-service/transform_hostname' => '0',
+ 'self-service/resolve_parent_host' => '0',
+ 'self-service/global_zones' => ['director-global'],
+ 'ignore_bug7530' => 'n',
+ 'feature_custom_endpoint' => 'n',
+ // 'experimental_features' => null, // 'allow'
+ // 'master_zone' => null,
+ ];
+
+ protected $jsonEncode = [
+ 'self-service/global_zones',
+ 'self-service/installer_hashes',
+ ];
+
+ public function __construct(Db $connection)
+ {
+ $this->connection = $connection;
+ $this->db = $connection->getDbAdapter();
+ }
+
+ /**
+ * @return Db
+ */
+ public function getConnection()
+ {
+ return $this->connection;
+ }
+
+ /**
+ * @return \Zend_Db_Adapter_Abstract
+ */
+ public function getDb()
+ {
+ return $this->db;
+ }
+
+ /**
+ * @param $key
+ * @param null $default
+ * @return mixed|null
+ */
+ public function get($key, $default = null)
+ {
+ if (null === ($value = $this->getStoredValue($key, $default))) {
+ return $this->getDefaultValue($key);
+ } else {
+ return $value;
+ }
+ }
+
+ /**
+ * @param $key
+ * @param null $default
+ * @return mixed|null
+ */
+ public function getStoredValue($key, $default = null)
+ {
+ if (null === ($value = $this->getSetting($key))) {
+ return $default;
+ } else {
+ if (in_array($key, $this->jsonEncode)) {
+ $value = json_decode($value);
+ }
+ return $value;
+ }
+ }
+
+ public function getDefaultValue($key)
+ {
+ if (array_key_exists($key, $this->defaults)) {
+ return $this->defaults[$key];
+ } else {
+ return null;
+ }
+ }
+
+ public function getStoredOrDefaultValue($key)
+ {
+ $value = $this->getStoredValue($key);
+ if ($value === null) {
+ return $this->getDefaultValue($key);
+ } else {
+ return $value;
+ }
+ }
+
+ /**
+ * @param $name
+ * @param $value
+ * @return $this
+ * @throws \Zend_Db_Adapter_Exception
+ */
+ public function set($name, $value)
+ {
+ $db = $this->db;
+
+ if ($value === null) {
+ $db->delete(
+ 'director_setting',
+ $db->quoteInto('setting_name = ?', $name)
+ );
+
+ unset($this->cache[$name]);
+
+ return $this;
+ }
+
+ if (in_array($name, $this->jsonEncode)) {
+ $value = json_encode(array_values($value));
+ }
+
+ if ($this->getSetting($name) === $value) {
+ return $this;
+ }
+
+ $updated = $db->update(
+ 'director_setting',
+ array('setting_value' => $value),
+ $db->quoteInto('setting_name = ?', $name)
+ );
+
+ if ($updated === 0) {
+ $db->insert(
+ 'director_setting',
+ array(
+ 'setting_name' => $name,
+ 'setting_value' => $value,
+ )
+ );
+ }
+
+ if ($this->cache !== null) {
+ $this->cache[$name] = $value;
+ }
+
+ return $this;
+ }
+
+ public function clearCache()
+ {
+ $this->cache = null;
+ return $this;
+ }
+
+ protected function getSetting($name, $default = null)
+ {
+ if ($this->cache === null) {
+ $this->refreshCache();
+ }
+
+ if (array_key_exists($name, $this->cache)) {
+ return $this->cache[$name];
+ }
+
+ return $default;
+ }
+
+ protected function refreshCache()
+ {
+ $db = $this->db;
+
+ $query = $db->select()->from(
+ array('s' => 'director_setting'),
+ array('setting_name', 'setting_value')
+ );
+
+ $this->cache = (array) $db->fetchPairs($query);
+ }
+
+ /**
+ * @param $key
+ * @return mixed|null
+ */
+ public function __get($key)
+ {
+ return $this->get($key);
+ }
+
+ /**
+ * @param $key
+ * @param $value
+ * @throws \Zend_Db_Adapter_Exception
+ */
+ public function __set($key, $value)
+ {
+ $this->set($key, $value);
+ }
+
+ public function __destruct()
+ {
+ $this->clearCache();
+ unset($this->db);
+ unset($this->connection);
+ }
+}
diff --git a/library/Director/StartupLogRenderer.php b/library/Director/StartupLogRenderer.php
new file mode 100644
index 0000000..bc7b3ea
--- /dev/null
+++ b/library/Director/StartupLogRenderer.php
@@ -0,0 +1,126 @@
+<?php
+
+namespace Icinga\Module\Director;
+
+use ipl\Html\Html;
+use Icinga\Module\Director\Objects\DirectorDeploymentLog;
+use gipfl\IcingaWeb2\Link;
+use ipl\Html\ValidHtml;
+
+class StartupLogRenderer implements ValidHtml
+{
+ /** @var DirectorDeploymentLog */
+ protected $deployment;
+
+ public function __construct(DirectorDeploymentLog $deployment)
+ {
+ $this->deployment = $deployment;
+ }
+
+ public function render()
+ {
+ $deployment = $this->deployment;
+ $log = Html::escape($deployment->get('startup_log'));
+ $lines = array();
+ $severity = 'information';
+ $sevPattern = '/^(debug|notice|information|warning|critical)\/(\w+)/';
+ $settings = new Settings($this->deployment->getConnection());
+ $package = $settings->get('icinga_package_name');
+ $pathPattern = '~(/[\w/]+/api/packages/' . $package . '/[^/]+/)';
+ $filePatternHint = $pathPattern . '([^:]+\.conf)(: (\d+))~';
+ $filePatternDetail = $pathPattern . '([^:]+\.conf)(\((\d+)\))~';
+ $markPattern = null;
+ // len [stage] + 1
+ $markReplace = ' ^';
+
+ foreach (preg_split('/\n/', $log) as $line) {
+ if (preg_match('/^\[([\d\s\:\+\-]+)\]\s/', $line, $m)) {
+ $time = $m[1];
+ // TODO: we might use new DateTime($time) and show a special "timeAgo"
+ // format - but for now this should suffice.
+ $line = substr($line, strpos($line, ']') + 2);
+ } else {
+ $time = null;
+ }
+
+ if (preg_match($sevPattern, $line, $m)) {
+ $severity = $m[1];
+ $line = preg_replace(
+ $sevPattern,
+ '<span class="loglevel \1">\1</span>/<span class="application">\2</span>',
+ $line
+ );
+ }
+
+ if ($markPattern !== null) {
+ $line = preg_replace($markPattern, $markReplace, $line);
+ }
+ $line = preg_replace('/([\^]{2,})/', '<span class="error-hint">\1</span>', $line);
+ $markPattern = null;
+
+ $self = $this;
+ if (preg_match($filePatternHint, $line, $m)) {
+ $line = preg_replace_callback(
+ $filePatternHint,
+ function ($matches) use ($severity, $self) {
+ return $self->logLink($matches, $severity);
+ },
+ $line
+ );
+ $line = preg_replace('/\(in/', "\n (in", $line);
+ $line = preg_replace('/\), new declaration/', "),\n new declaration", $line);
+ } elseif (preg_match($filePatternDetail, $line, $m)) {
+ $markIndent = strlen($m[1]);
+ $markPattern = '/\s{' . $markIndent . '}\^/';
+
+ $line = preg_replace_callback(
+ $filePatternDetail,
+ function ($matches) use ($severity, $self) {
+ return $self->logLink($matches, $severity);
+ },
+ $line
+ );
+ }
+
+ if ($time === null) {
+ $lines[] .= $line;
+ } else {
+ $lines[] .= "[$time] $line";
+ }
+ }
+ return implode("\n", $lines);
+ }
+
+ protected function logLink($match, $severity)
+ {
+ $stageDir = $match[1];
+ $filename = $match[2];
+ $suffix = $match[3];
+ if (preg_match('/(\d+).*/', $suffix, $m)) {
+ $lineNumber = $m[1];
+ } else {
+ $lineNumber = null;
+ }
+
+ $deployment = $this->deployment;
+ $params = array(
+ 'config_checksum' => $deployment->getConfigHexChecksum(),
+ 'deployment_id' => $deployment->get('id'),
+ 'file_path' => $filename,
+ 'backTo' => 'deployment'
+ );
+ if ($lineNumber !== null) {
+ $params['highlight'] = $lineNumber;
+ $params['highlightSeverity'] = $severity;
+ }
+
+ return Link::create(
+ '[stage]/' . $filename,
+ 'director/config/file',
+ $params,
+ [
+ 'title' => $stageDir . $filename
+ ]
+ ) . $suffix;
+ }
+}
diff --git a/library/Director/Test/BaseTestCase.php b/library/Director/Test/BaseTestCase.php
new file mode 100644
index 0000000..611805b
--- /dev/null
+++ b/library/Director/Test/BaseTestCase.php
@@ -0,0 +1,127 @@
+<?php
+
+namespace Icinga\Module\Director\Test;
+
+use Icinga\Application\Icinga;
+use Icinga\Application\Config;
+use Icinga\Data\ResourceFactory;
+use Icinga\Exception\ConfigurationError;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Db\Migrations;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Icinga\Module\Director\Objects\IcingaZone;
+use PHPUnit_Framework_TestCase;
+
+abstract class BaseTestCase extends PHPUnit_Framework_TestCase
+{
+ private static $app;
+
+ /** @var Db */
+ private static $db;
+
+ public function setUp()
+ {
+ $this->app();
+ }
+
+ protected function skipForMissingDb()
+ {
+ if ($this->hasDb()) {
+ return false;
+ }
+
+ $this->markTestSkipped('Test db resource has not been configured');
+
+ return true;
+ }
+
+ protected function hasDb()
+ {
+ return $this->getDbResourceName() !== null;
+ }
+
+ protected static function getDbResourceName()
+ {
+ if (array_key_exists('DIRECTOR_TESTDB_RES', $_SERVER)) {
+ return $_SERVER['DIRECTOR_TESTDB_RES'];
+ } else {
+ return Config::module('director')->get('testing', 'db_resource');
+ }
+ }
+
+ /**
+ * @return Db
+ * @throws ConfigurationError
+ */
+ protected static function getDb()
+ {
+ if (self::$db === null) {
+ $resourceName = self::getDbResourceName();
+ if (! $resourceName) {
+ throw new ConfigurationError(
+ 'Could not run DB-based tests, please configure a testing db resource'
+ );
+ }
+ $dbConfig = ResourceFactory::getResourceConfig($resourceName);
+ if (array_key_exists('DIRECTOR_TESTDB', $_SERVER)) {
+ $dbConfig->dbname = $_SERVER['DIRECTOR_TESTDB'];
+ }
+ if (array_key_exists('DIRECTOR_TESTDB_HOST', $_SERVER)) {
+ $dbConfig->host = $_SERVER['DIRECTOR_TESTDB_HOST'];
+ }
+ if (array_key_exists('DIRECTOR_TESTDB_USER', $_SERVER)) {
+ $dbConfig->username = $_SERVER['DIRECTOR_TESTDB_USER'];
+ }
+ if (array_key_exists('DIRECTOR_TESTDB_PASSWORD', $_SERVER)) {
+ $dbConfig->password = $_SERVER['DIRECTOR_TESTDB_PASSWORD'];
+ }
+ self::$db = new Db($dbConfig);
+ $migrations = new Migrations(self::$db);
+ $migrations->applyPendingMigrations();
+ IcingaZone::create([
+ 'object_name' => 'director-global',
+ 'object_type' => 'external_object',
+ 'is_global' => 'y'
+ ])->store(self::$db);
+ }
+
+ return self::$db;
+ }
+
+ protected function newObject($type, $name, $properties = array())
+ {
+ if (! array_key_exists('object_type', $properties)) {
+ $properties['object_type'] = 'object';
+ }
+ $properties['object_name'] = $name;
+
+ return IcingaObject::createByType($type, $properties, $this->getDb());
+ }
+
+ protected function app()
+ {
+ if (self::$app === null) {
+ self::$app = Icinga::app();
+ }
+
+ return self::$app;
+ }
+
+ /**
+ * Call a protected function for a class during testing
+ *
+ * @param $obj
+ * @param $name
+ * @param array $args
+ *
+ * @return mixed
+ * @throws \ReflectionException
+ */
+ public static function callMethod($obj, $name, array $args)
+ {
+ $class = new \ReflectionClass($obj);
+ $method = $class->getMethod($name);
+ $method->setAccessible(true);
+ return $method->invokeArgs($obj, $args);
+ }
+}
diff --git a/library/Director/Test/Bootstrap.php b/library/Director/Test/Bootstrap.php
new file mode 100644
index 0000000..56bd85a
--- /dev/null
+++ b/library/Director/Test/Bootstrap.php
@@ -0,0 +1,28 @@
+<?php
+
+namespace Icinga\Module\Director\Test;
+
+use Icinga\Application\Cli;
+
+class Bootstrap
+{
+ public static function cli($basedir = null)
+ {
+ error_reporting(E_ALL | E_STRICT);
+ if ($basedir === null) {
+ $basedir = dirname(dirname(dirname(__DIR__)));
+ }
+ $testsDir = $basedir . '/test';
+ require_once 'Icinga/Application/Cli.php';
+
+ if (array_key_exists('ICINGAWEB_CONFIGDIR', $_SERVER)) {
+ $configDir = $_SERVER['ICINGAWEB_CONFIGDIR'];
+ } else {
+ $configDir = $testsDir . '/config';
+ }
+
+ Cli::start($testsDir, $configDir)
+ ->getModuleManager()
+ ->loadModule('director', $basedir);
+ }
+}
diff --git a/library/Director/Test/IcingaObjectTestCase.php b/library/Director/Test/IcingaObjectTestCase.php
new file mode 100644
index 0000000..a37fced
--- /dev/null
+++ b/library/Director/Test/IcingaObjectTestCase.php
@@ -0,0 +1,92 @@
+<?php
+
+namespace Icinga\Module\Director\Test;
+
+use Icinga\Module\Director\Objects\IcingaObject;
+
+/**
+ * Icinga Object test helper class
+ */
+abstract class IcingaObjectTestCase extends BaseTestCase
+{
+ protected $table;
+ protected $testObjectName = '___TEST___';
+
+ /** @var IcingaObject */
+ protected $subject = null;
+
+ protected $createdObjects = array();
+
+ /**
+ * Creates a fresh object to play with and prepares for tearDown()
+ *
+ * @param string $type table to load from
+ * @param string $object_name of the object
+ * @param array $properties
+ * @param bool $storeIt
+ *
+ * @return IcingaObject
+ */
+ protected function createObject($object_name, $type = null, $properties = array(), $storeIt = true)
+ {
+ if ($type === null) {
+ $type = $this->table;
+ }
+ $properties['object_name'] = '___TEST___' . $type . '_' . $object_name;
+ $obj = IcingaObject::createByType($type, $properties, $this->getDb());
+
+ if ($storeIt === true) {
+ $obj->store();
+ $this->prepareObjectTearDown($obj);
+ }
+
+ return $obj;
+ }
+
+ /**
+ * Helper method for loading an object
+ *
+ * @param string $name
+ * @param null $type
+ * @return IcingaObject
+ */
+ protected function loadObject($name, $type = null)
+ {
+ if ($type === null) {
+ $type = $this->table;
+ }
+ $realName = '___TEST___' . $type . '_' . $name;
+ return IcingaObject::loadByType($type, $realName, $this->getDb());
+ }
+
+ /**
+ * Store the object in a list for deletion on tearDown()
+ *
+ * @param IcingaObject $object
+ *
+ * @return $this
+ */
+ protected function prepareObjectTearDown(IcingaObject $object)
+ {
+ $this->assertTrue($object->hasBeenLoadedFromDb());
+ $this->createdObjects[] = $object;
+ return $this;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function tearDown()
+ {
+ if ($this->hasDb()) {
+ /** @var IcingaObject $object */
+ foreach (array_reverse($this->createdObjects) as $object) {
+ $object->delete();
+ }
+
+ if ($this->subject !== null) {
+ $this->subject->delete();
+ }
+ }
+ }
+}
diff --git a/library/Director/Test/ImportSourceDummy.php b/library/Director/Test/ImportSourceDummy.php
new file mode 100644
index 0000000..4ac1d09
--- /dev/null
+++ b/library/Director/Test/ImportSourceDummy.php
@@ -0,0 +1,52 @@
+<?php
+
+namespace Icinga\Module\Director\Test;
+
+use Icinga\Module\Director\Hook\ImportSourceHook;
+
+class ImportSourceDummy extends ImportSourceHook
+{
+ protected static $rows = array();
+
+ /**
+ * Returns an array containing importable objects
+ *
+ * @return array
+ */
+ public function fetchData()
+ {
+ return self::$rows;
+ }
+
+ /**
+ * Returns a list of all available columns
+ *
+ * @return array
+ */
+ public function listColumns()
+ {
+ $keys = array();
+ foreach (self::$rows as $row) {
+ $keys = array_merge($keys, array_keys($row));
+ }
+ return $keys;
+ }
+
+ public static function clearRows()
+ {
+ self::$rows = array();
+ }
+
+ public static function setRows($rows)
+ {
+ static::clearRows();
+ foreach ($rows as $row) {
+ static::addRow($row);
+ }
+ }
+
+ public static function addRow($row)
+ {
+ self::$rows[] = (object) $row;
+ }
+}
diff --git a/library/Director/Test/SyncTest.php b/library/Director/Test/SyncTest.php
new file mode 100644
index 0000000..7614ff9
--- /dev/null
+++ b/library/Director/Test/SyncTest.php
@@ -0,0 +1,105 @@
+<?php
+
+namespace Icinga\Module\Director\Test;
+
+use Icinga\Exception\IcingaException;
+use Icinga\Module\Director\Data\Db\DbObject;
+use Icinga\Module\Director\Db\Cache\PrefetchCache;
+use Icinga\Module\Director\Import\Sync;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Icinga\Module\Director\Objects\ImportSource;
+use Icinga\Module\Director\Objects\SyncProperty;
+use Icinga\Module\Director\Objects\SyncRule;
+
+abstract class SyncTest extends BaseTestCase
+{
+ protected $objectType;
+
+ protected $keyColumn;
+
+ /** @var ImportSource */
+ protected $source;
+
+ /** @var SyncRule */
+ protected $rule;
+
+ /** @var SyncProperty[] */
+ protected $properties = array();
+
+ /** @var Sync */
+ protected $sync;
+
+ public function setUp()
+ {
+ $this->source = ImportSource::create(array(
+ 'source_name' => 'testimport',
+ 'provider_class' => 'Icinga\\Module\\Director\\Test\\ImportSourceDummy',
+ 'key_column' => $this->keyColumn,
+ ));
+ $this->source->store($this->getDb());
+
+ $this->rule = SyncRule::create(array(
+ 'rule_name' => 'testrule',
+ 'object_type' => $this->objectType,
+ 'update_policy' => 'merge',
+ 'purge_existing' => 'n'
+ ));
+ $this->rule->store($this->getDb());
+
+ $this->sync = new Sync($this->rule);
+ }
+
+ public function tearDown()
+ {
+ // properties should be deleted automatically
+ if ($this->rule !== null && $this->rule->hasBeenLoadedFromDb()) {
+ $this->rule->delete();
+ }
+
+ if ($this->source !== null && $this->source->hasBeenLoadedFromDb()) {
+ $this->source->delete();
+ }
+
+ // find objects created by this class and delete them
+ $db = $this->getDb();
+ $dummy = IcingaObject::createByType($this->objectType, array(), $db);
+ $query = $db->getDbAdapter()->select()
+ ->from($dummy->getTableName())
+ ->where('object_name LIKE ?', 'SYNCTEST_%');
+
+ /** @var IcingaObject $object */
+ foreach (IcingaObject::loadAllByType($this->objectType, $db, $query) as $object) {
+ $object->delete();
+ }
+
+ // make sure cache is clean for other tests
+ PrefetchCache::forget();
+ DbObject::clearAllPrefetchCaches();
+ }
+
+ /**
+ * @param array $rows
+ *
+ * @throws IcingaException
+ */
+ protected function runImport($rows)
+ {
+ ImportSourceDummy::setRows($rows);
+ $this->source->runImport();
+ if ($this->source->get('import_state') !== 'in-sync') {
+ throw new IcingaException('Import failed: %s', $this->source->get('last_error_message'));
+ }
+ }
+
+ protected function setUpProperty($properties = array())
+ {
+ $properties = array_merge(array(
+ 'rule_id' => $this->rule->id,
+ 'source_id' => $this->source->id,
+ 'merge_policy' => 'override',
+ ), $properties);
+
+ $this->properties[] = $property = SyncProperty::create($properties);
+ $property->store($this->getDb());
+ }
+}
diff --git a/library/Director/Test/TestProcess.php b/library/Director/Test/TestProcess.php
new file mode 100644
index 0000000..b2399b7
--- /dev/null
+++ b/library/Director/Test/TestProcess.php
@@ -0,0 +1,116 @@
+<?php
+
+namespace Icinga\Module\Director\Test;
+
+use Closure;
+
+class TestProcess
+{
+ protected $command;
+
+ protected $identifier;
+
+ protected $exitCode;
+
+ protected $output;
+
+ protected $onSuccess;
+
+ protected $onFailure;
+
+ protected $expectedExitCode = 0;
+
+ public function __construct($command, $identifier = null)
+ {
+ $this->command = $command;
+ $this->identifier = $identifier;
+ }
+
+ public function getIdentifier()
+ {
+ return $this->identifier;
+ }
+
+ public function expectExitCode($code)
+ {
+ $this->expectedExitCode = $code;
+ return $this;
+ }
+
+ public function onSuccess($func)
+ {
+ $this->onSuccess = $this->makeClosure($func);
+ return $this;
+ }
+
+ public function onFailure($func)
+ {
+ $this->onSuccess = $this->makeClosure($func);
+ return $this;
+ }
+
+ protected function makeClosure($func)
+ {
+ if ($func instanceof Closure) {
+ return $func;
+ }
+
+ if (is_array($func)) {
+ return function ($process) use ($func) {
+ return $func[0]->{$func[1]}($process);
+ };
+ }
+ }
+
+ public function onFailureThrow($message, $class = 'Exception')
+ {
+ return $this->onFailure(function () use ($message, $class) {
+ throw new $class($message);
+ });
+ }
+
+ public function run()
+ {
+ exec($this->command, $this->output, $this->exitCode);
+
+ if ($this->succeeded()) {
+ $this->triggerSuccess();
+ } else {
+ $this->triggerFailure();
+ }
+ }
+
+ public function succeeded()
+ {
+ return $this->exitCode === $this->expectedExitCode;
+ }
+
+ public function failed()
+ {
+ return $this->exitCode !== $this->expectedExitCode;
+ }
+
+ protected function triggerSuccess()
+ {
+ if (($func = $this->onSuccess) !== null) {
+ $func($this);
+ }
+ }
+
+ protected function triggerFailure()
+ {
+ if (($func = $this->onFailure) !== null) {
+ $func($this);
+ }
+ }
+
+ public function getExitCode()
+ {
+ return $this->exitCode;
+ }
+
+ public function getOutput()
+ {
+ return implode("\n", $this->output) . "\n";
+ }
+}
diff --git a/library/Director/Test/TestSuite.php b/library/Director/Test/TestSuite.php
new file mode 100644
index 0000000..131b974
--- /dev/null
+++ b/library/Director/Test/TestSuite.php
@@ -0,0 +1,68 @@
+<?php
+
+namespace Icinga\Module\Director\Test;
+
+use Icinga\Application\Icinga;
+use RecursiveDirectoryIterator;
+use RecursiveIteratorIterator;
+
+abstract class TestSuite
+{
+ private $basedir;
+
+ abstract public function run();
+
+ public static function newTempfile()
+ {
+ return tempnam(sys_get_temp_dir(), 'DirectorTest-');
+ }
+
+ public function process($command, $identifier = null)
+ {
+ return new TestProcess($command, $identifier);
+ }
+
+ protected function filesByExtension($base, $extensions)
+ {
+ $files = array();
+
+ if (! is_array($extensions)) {
+ $extensions = array($extensions);
+ }
+
+ $basedir = $this->getBaseDir() . '/' . $base;
+ $dir = new RecursiveDirectoryIterator($basedir);
+ $iterator = new RecursiveIteratorIterator(
+ $dir,
+ RecursiveIteratorIterator::SELF_FIRST
+ );
+
+ foreach ($iterator as $file) {
+ if (! $file->isFile()) {
+ continue;
+ }
+
+ if (in_array($file->getExtension(), $extensions)) {
+ $files[] = $file->getPathname();
+ }
+ }
+
+ return $files;
+ }
+
+ public function getBaseDir($file = null)
+ {
+ if ($this->basedir === null) {
+ $this->basedir = Icinga::app()
+ ->getModuleManager()
+ ->getModule('director')
+ ->getBaseDir();
+ }
+
+ if ($file === null) {
+ return $this->basedir;
+ } else {
+ return $this->basedir . '/' . $file;
+ }
+ }
+}
diff --git a/library/Director/Test/TestSuiteLint.php b/library/Director/Test/TestSuiteLint.php
new file mode 100644
index 0000000..41941eb
--- /dev/null
+++ b/library/Director/Test/TestSuiteLint.php
@@ -0,0 +1,56 @@
+<?php
+
+namespace Icinga\Module\Director\Test;
+
+use Icinga\Application\Logger;
+
+class TestSuiteLint extends TestSuite
+{
+ protected $checked;
+
+ protected $failed;
+
+ public function run()
+ {
+ $this->checked = $this->failed = array();
+
+ foreach ($this->listFiles() as $file) {
+ $checked[] = $file;
+ $cmd = "php -l '$file'";
+ $this->result[$file] = $this
+ ->process($cmd, $file)
+ ->onFailure(array($this, 'failedCheck'))
+ ->run();
+ }
+ }
+
+ public function failedCheck($process)
+ {
+ Logger::error($process->getOutput());
+ $this->failed[] = $process->getIdentifier();
+ }
+
+ public function hasFailures()
+ {
+ return ! empty($this->failed);
+ }
+
+ protected function listFiles()
+ {
+ $basedir = $this->getBaseDir();
+ $files = array(
+ $basedir . '/run.php',
+ $basedir . '/configuration.php'
+ );
+
+ foreach ($this->filesByExtension('library/Director', 'php') as $file) {
+ $files[] = $file;
+ }
+
+ foreach ($this->filesByExtension('application', array('php', 'phtml')) as $file) {
+ $files[] = $file;
+ }
+
+ return $files;
+ }
+}
diff --git a/library/Director/Test/TestSuiteStyle.php b/library/Director/Test/TestSuiteStyle.php
new file mode 100644
index 0000000..babd43c
--- /dev/null
+++ b/library/Director/Test/TestSuiteStyle.php
@@ -0,0 +1,66 @@
+<?php
+
+namespace Icinga\Module\Director\Test;
+
+class TestSuiteStyle extends TestSuite
+{
+ public function run()
+ {
+ $out = static::newTempFile();
+ $check = array(
+ 'library/Director/',
+ 'application/',
+ 'configuration.php',
+ 'run.php',
+ );
+
+ /*
+ $options = array();
+ if ($this->isVerbose) {
+ $options[] = '-v';
+ }
+ */
+
+ /*
+ $phpcs = exec('which phpcs');
+ if (!file_exists($phpcs)) {
+ $this->fail(
+ 'PHP_CodeSniffer not found. Please install PHP_CodeSniffer to be able to run code style tests.'
+ );
+ }
+ */
+
+ $cmd = sprintf(
+ "phpcs -p --standard=PSR2 --extensions=php --encoding=utf-8 -w -s --report-checkstyle=%s '%s'",
+ $out,
+ implode("' '", $check)
+ );
+
+ $proc = $this
+ ->process($cmd);
+
+ // ->onFailure(array($this, 'failedCheck'))
+ $proc->run();
+
+ echo $proc->getOutput();
+
+ echo file_get_contents($out);
+ unlink($out);
+ // /usr/bin/phpcs --standard=PSR2 --extensions=php --encoding=utf-8 application/
+ // library/Director/ --report=full
+
+ /*
+ $options[] = '--log-junit';
+ $options[] = $reportPath . '/phpunit_results.xml';
+ $options[] = '--coverage-html';
+ $options[] = $reportPath . '/php_html_coverage';
+ */
+ return;
+
+ `$cmd`;
+ echo $cmd . "\n";
+ echo $out ."\n";
+ echo file_get_contents($out);
+ unlink($out);
+ }
+}
diff --git a/library/Director/Test/TestSuiteUnit.php b/library/Director/Test/TestSuiteUnit.php
new file mode 100644
index 0000000..8156eba
--- /dev/null
+++ b/library/Director/Test/TestSuiteUnit.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace Icinga\Module\Director\Test;
+
+abstract class TestSuiteUnit
+{
+ public function run()
+ {
+ }
+ public function __construct()
+ {
+ $this->testdoxFile = $this->newTempfile();
+ }
+
+ public function __destruct()
+ {
+ if ($this->testdoxFile && file_exists($this->testdoxFile)) {
+ unlink($this->testDoxfile);
+ }
+ }
+
+ public function getPhpunitCommand()
+ {
+ // return phpunit --bootstrap test/bootstrap.php --testdox-text /tmp/testdox.txt .
+ }
+}
diff --git a/library/Director/Test/Web/Form/TestDirectorObjectForm.php b/library/Director/Test/Web/Form/TestDirectorObjectForm.php
new file mode 100644
index 0000000..0722e78
--- /dev/null
+++ b/library/Director/Test/Web/Form/TestDirectorObjectForm.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace Icinga\Module\Director\Test\Web;
+
+use Icinga\Module\Director\Web\Form\DirectorObjectForm;
+
+class TestDirectorObjectForm extends DirectorObjectForm
+{
+ protected function getActionFromRequest()
+ {
+ $this->setAction('director/test/url');
+ return $this;
+ }
+
+ public function regenerateCsrfToken()
+ {
+ return $this;
+ }
+}
diff --git a/library/Director/TranslationDummy.php b/library/Director/TranslationDummy.php
new file mode 100644
index 0000000..937cc0b
--- /dev/null
+++ b/library/Director/TranslationDummy.php
@@ -0,0 +1,20 @@
+<?php
+
+namespace Icinga\Module\Director;
+
+use gipfl\Translation\TranslationHelper;
+
+class TranslationDummy
+{
+ use TranslationHelper;
+
+ protected function dummyForTranslation()
+ {
+ $this->translate('Host');
+ $this->translate('Service');
+ $this->translate('Zone');
+ $this->translate('Command');
+ $this->translate('User');
+ $this->translate('Notification');
+ }
+}
diff --git a/library/Director/Util.php b/library/Director/Util.php
new file mode 100644
index 0000000..22ad5fc
--- /dev/null
+++ b/library/Director/Util.php
@@ -0,0 +1,175 @@
+<?php
+
+namespace Icinga\Module\Director;
+
+use Icinga\Authentication\Auth;
+use Icinga\Data\ResourceFactory;
+use Icinga\Module\Director\Web\Form\QuickForm;
+use Icinga\Exception\NotImplementedError;
+use Icinga\Exception\ProgrammingError;
+use ipl\Html\Html;
+use gipfl\IcingaWeb2\Link;
+
+class Util
+{
+ protected static $auth;
+
+ protected static $allowedResources;
+
+ /**
+ * PBKDF2 - Password-Based Cryptography Specification (RFC2898)
+ *
+ * This method strictly follows examples in php.net's documentation
+ * comments. Hint: RFC6070 would be a good source for related tests
+ *
+ * @param string $alg Desired hash algorythm (sha1, sha256...)
+ * @param string $secret Shared secret, password
+ * @param string $salt Hash salt
+ * @param int $iterations How many iterations to perform. Please use at
+ * least 1000+. More iterations make it slower
+ * but more secure.
+ * @param int $length Desired key length
+ * @param bool $raw Returns the binary key if true, hex string otherwise
+ *
+ * @throws NotImplementedError when asking for an unsupported algorightm
+ * @throws ProgrammingError when passing invalid parameters
+ *
+ * @return string A $length byte long key, derived from secret and salt
+ */
+ public static function pbkdf2($alg, $secret, $salt, $iterations, $length, $raw = false)
+ {
+ if (! in_array($alg, hash_algos(), true)) {
+ throw new NotImplementedError('No such hash algorithm found: "%s"', $alg);
+ }
+
+ if ($iterations <= 0 || $length <= 0) {
+ throw new ProgrammingError('Positive iterations and length required');
+ }
+
+ $hashLength = strlen(hash($alg, '', true));
+ $blocks = ceil($length / $hashLength);
+
+ $out = '';
+
+ for ($i = 1; $i <= $blocks; $i++) {
+ // $i encoded as 4 bytes, big endian.
+ $last = $salt . pack('N', $i);
+ // first iteration
+ $last = $xorsum = hash_hmac($alg, $last, $secret, true);
+ // perform the other $iterations - 1 iterations
+ for ($j = 1; $j < $iterations; $j++) {
+ $xorsum ^= ($last = hash_hmac($alg, $last, $secret, true));
+ }
+ $out .= $xorsum;
+ }
+
+ if ($raw) {
+ return substr($out, 0, $length);
+ }
+
+ return bin2hex(substr($out, 0, $length));
+ }
+
+ public static function auth()
+ {
+ if (self::$auth === null) {
+ self::$auth = Auth::getInstance();
+ }
+ return self::$auth;
+ }
+
+ public static function hasPermission($name)
+ {
+ return self::auth()->hasPermission($name);
+ }
+
+ public static function getRestrictions($name)
+ {
+ return self::auth()->getRestrictions($name);
+ }
+
+ public static function resourceIsAllowed($name)
+ {
+ if (self::$allowedResources === null) {
+ $restrictions = self::getRestrictions('director/resources/use');
+ $list = array();
+ foreach ($restrictions as $restriction) {
+ foreach (preg_split('/\s*,\s*/', $restriction, -1, PREG_SPLIT_NO_EMPTY) as $key) {
+ $list[$key] = $key;
+ }
+ }
+
+ self::$allowedResources = $list;
+ } else {
+ $list = self::$allowedResources;
+ }
+
+ if (empty($list) || array_key_exists($name, $list)) {
+ return true;
+ }
+
+ return false;
+ }
+
+ public static function enumDbResources()
+ {
+ return self::enumResources('db');
+ }
+
+ public static function enumLdapResources()
+ {
+ return self::enumResources('ldap');
+ }
+
+ protected static function enumResources($type)
+ {
+ $resources = array();
+ foreach (ResourceFactory::getResourceConfigs() as $name => $resource) {
+ if ($resource->get('type') === $type && self::resourceIsAllowed($name)) {
+ $resources[$name] = $name;
+ }
+ }
+
+ return $resources;
+ }
+
+ public static function addDbResourceFormElement(QuickForm $form, $name)
+ {
+ static::addResourceFormElement($form, $name, 'db');
+ }
+
+ public static function addLdapResourceFormElement(QuickForm $form, $name)
+ {
+ static::addResourceFormElement($form, $name, 'ldap');
+ }
+
+ protected static function addResourceFormElement(QuickForm $form, $name, $type)
+ {
+ $list = self::enumResources($type);
+
+ $form->addElement('select', $name, array(
+ 'label' => 'Resource name',
+ 'multiOptions' => $form->optionalEnum($list),
+ 'required' => true,
+ ));
+
+ if (empty($list)) {
+ if (self::hasPermission('config/application/resources')) {
+ $form->addHtmlHint(Html::sprintf(
+ $form->translate('Please click %s to create new resources'),
+ Link::create(
+ $form->translate('here'),
+ 'config/resource',
+ null,
+ ['data-base-target' => '_main']
+ )
+ ));
+ $msg = sprintf($form->translate('No %s resource available'), $type);
+ } else {
+ $msg = $form->translate('Please ask an administrator to grant you access to resources');
+ }
+
+ $form->getElement($name)->addError($msg);
+ }
+ }
+}
diff --git a/library/Director/Web/ActionBar/AutomationObjectActionBar.php b/library/Director/Web/ActionBar/AutomationObjectActionBar.php
new file mode 100644
index 0000000..247677f
--- /dev/null
+++ b/library/Director/Web/ActionBar/AutomationObjectActionBar.php
@@ -0,0 +1,65 @@
+<?php
+
+namespace Icinga\Module\Director\Web\ActionBar;
+
+use gipfl\IcingaWeb2\Link;
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Widget\ActionBar;
+use Icinga\Web\Request;
+
+class AutomationObjectActionBar extends ActionBar
+{
+ use TranslationHelper;
+
+ /** @var Request */
+ protected $request;
+
+ protected $label;
+
+ public function __construct(Request $request)
+ {
+ $this->request = $request;
+ }
+
+ protected function assemble()
+ {
+ $request = $this->request;
+ $action = $request->getActionName();
+ $controller = $request->getControllerName();
+ $params = ['id' => $request->getParam('id')];
+ $links = [
+ 'index' => Link::create(
+ $this->translate('Overview'),
+ "director/$controller",
+ $params,
+ ['class' => 'icon-info']
+ ),
+ 'edit' => Link::create(
+ $this->translate('Modify'),
+ "director/$controller/edit",
+ $params,
+ ['class' => 'icon-edit']
+ ),
+ 'clone' => Link::create(
+ $this->translate('Clone'),
+ "director/$controller/clone",
+ $params,
+ ['class' => 'icon-paste']
+ ),
+ /*
+ // TODO: enable once handled in the controller
+ 'export' => Link::create(
+ $this->translate('Download JSON'),
+ $this->request->getUrl()->with('format', 'json'),
+ null,
+ [
+ 'data-base-target' => '_blank',
+ ]
+ )
+ */
+
+ ];
+ unset($links[$action]);
+ $this->add($links);
+ }
+}
diff --git a/library/Director/Web/ActionBar/ChoicesActionBar.php b/library/Director/Web/ActionBar/ChoicesActionBar.php
new file mode 100644
index 0000000..7b59d2c
--- /dev/null
+++ b/library/Director/Web/ActionBar/ChoicesActionBar.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace Icinga\Module\Director\Web\ActionBar;
+
+use gipfl\IcingaWeb2\Link;
+
+class ChoicesActionBar extends DirectorBaseActionBar
+{
+ protected function assemble()
+ {
+ $type = $this->type;
+ $this->add(
+ $this->getBackToDashboardLink()
+ )->add(
+ Link::create(
+ $this->translate('Add'),
+ "director/templatechoice/$type",
+ ['type' => 'object'],
+ [
+ 'title' => $this->translate('Create a new template choice'),
+ 'class' => 'icon-plus',
+ 'data-base-target' => '_next'
+ ]
+ )
+ );
+ }
+}
diff --git a/library/Director/Web/ActionBar/DirectorBaseActionBar.php b/library/Director/Web/ActionBar/DirectorBaseActionBar.php
new file mode 100644
index 0000000..8612a0d
--- /dev/null
+++ b/library/Director/Web/ActionBar/DirectorBaseActionBar.php
@@ -0,0 +1,67 @@
+<?php
+
+namespace Icinga\Module\Director\Web\ActionBar;
+
+use Icinga\Module\Director\Dashboard\Dashboard;
+use gipfl\IcingaWeb2\Link;
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Widget\ActionBar;
+use gipfl\IcingaWeb2\Url;
+
+class DirectorBaseActionBar extends ActionBar
+{
+ use TranslationHelper;
+
+ /** @var Url */
+ protected $url;
+
+ /** @var string */
+ protected $type;
+
+ public function __construct($type, Url $url)
+ {
+ $this->type = $type;
+ $this->url = $url;
+ }
+
+ protected function getBackToDashboardLink()
+ {
+ $name = $this->getPluralBaseType();
+ if (! Dashboard::exists($name)) {
+ return null;
+ }
+
+ return Link::create(
+ $this->translate('back'),
+ 'director/dashboard',
+ ['name' => $name],
+ [
+ 'title' => sprintf(
+ $this->translate('Go back to "%s" Dashboard'),
+ $this->translate(ucfirst($this->type))
+ ),
+ 'class' => 'icon-left-big',
+ 'data-base-target' => '_main'
+ ]
+ );
+ }
+
+ protected function getBaseType()
+ {
+ if (substr($this->type, -5) === 'Group') {
+ return substr($this->type, 0, -5);
+ } else {
+ return $this->type;
+ }
+ }
+
+ protected function getPluralType()
+ {
+ return $this->type . 's';
+ }
+
+ protected function getPluralBaseType()
+ {
+ return $this->getBaseType() . 's';
+ }
+}
diff --git a/library/Director/Web/ActionBar/ObjectsActionBar.php b/library/Director/Web/ActionBar/ObjectsActionBar.php
new file mode 100644
index 0000000..5f86949
--- /dev/null
+++ b/library/Director/Web/ActionBar/ObjectsActionBar.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace Icinga\Module\Director\Web\ActionBar;
+
+use gipfl\IcingaWeb2\Link;
+
+class ObjectsActionBar extends DirectorBaseActionBar
+{
+ protected function assemble()
+ {
+ $type = $this->type;
+ $this->add(
+ $this->getBackToDashboardLink()
+ )->add(
+ Link::create(
+ $this->translate('Add'),
+ "director/$type/add",
+ ['type' => 'object'],
+ [
+ 'title' => $this->translate('Create a new object'),
+ 'class' => 'icon-plus',
+ 'data-base-target' => '_next'
+ ]
+ )
+ );
+ }
+}
diff --git a/library/Director/Web/ActionBar/TemplateActionBar.php b/library/Director/Web/ActionBar/TemplateActionBar.php
new file mode 100644
index 0000000..53e65ed
--- /dev/null
+++ b/library/Director/Web/ActionBar/TemplateActionBar.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace Icinga\Module\Director\Web\ActionBar;
+
+use gipfl\IcingaWeb2\Link;
+
+class TemplateActionBar extends DirectorBaseActionBar
+{
+ protected function assemble()
+ {
+ $type = str_replace('_', '-', $this->type);
+ $plType = preg_replace('/cys$/', 'cies', $type . 's');
+ $renderTree = $this->url->getParam('render') === 'tree';
+ $renderParams = $renderTree ? null : ['render' => 'tree'];
+ $this->add(
+ $this->getBackToDashboardLink()
+ )->add(
+ Link::create(
+ $this->translate('Add'),
+ "director/$type/add",
+ ['type' => 'template'],
+ [
+ 'title' => $this->translate('Create a new Template'),
+ 'class' => 'icon-plus',
+ 'data-base-target' => '_next'
+ ]
+ )
+ )->add(
+ Link::create(
+ $renderTree ? $this->translate('Table') : $this->translate('Tree'),
+ "director/$plType/templates",
+ $renderParams,
+ [
+ 'class' => 'icon-' . ($renderTree ? 'doc-text' : 'sitemap'),
+ 'title' => $renderTree
+ ? $this->translate('Switch to Tree view')
+ : $this->translate('Switch to Table view')
+ ]
+ )
+ );
+ }
+}
diff --git a/library/Director/Web/Controller/ActionController.php b/library/Director/Web/Controller/ActionController.php
new file mode 100644
index 0000000..6282a16
--- /dev/null
+++ b/library/Director/Web/Controller/ActionController.php
@@ -0,0 +1,253 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Controller;
+
+use gipfl\Translation\StaticTranslator;
+use Icinga\Application\Benchmark;
+use Icinga\Data\Paginatable;
+use Icinga\Exception\NotFoundError;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Module\Director\Monitoring;
+use Icinga\Module\Director\Web\Controller\Extension\CoreApi;
+use Icinga\Module\Director\Web\Controller\Extension\DirectorDb;
+use Icinga\Module\Director\Web\Controller\Extension\RestApi;
+use Icinga\Module\Director\Web\Window;
+use Icinga\Security\SecurityException;
+use Icinga\Web\Controller;
+use Icinga\Web\UrlParams;
+use InvalidArgumentException;
+use gipfl\IcingaWeb2\Translator;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Widget\ControlsAndContent;
+use gipfl\IcingaWeb2\Controller\Extension\ControlsAndContentHelper;
+use gipfl\IcingaWeb2\Zf1\SimpleViewRenderer;
+use GuzzleHttp\Psr7\ServerRequest;
+use Psr\Http\Message\ServerRequestInterface;
+
+abstract class ActionController extends Controller implements ControlsAndContent
+{
+ use DirectorDb;
+ use CoreApi;
+ use RestApi;
+ use ControlsAndContentHelper;
+
+ protected $isApified = false;
+
+ /** @var UrlParams Hint for IDE, somehow does not work in web */
+ protected $params;
+
+ /** @var Monitoring */
+ private $monitoring;
+
+ /**
+ * @throws SecurityException
+ * @throws \Icinga\Exception\AuthenticationException
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function init()
+ {
+ if (! $this->getRequest()->isApiRequest()
+ && $this->Config()->get('frontend', 'disabled', 'no') === 'yes'
+ ) {
+ throw new NotFoundError('Not found');
+ }
+ $this->initializeTranslator();
+ Benchmark::measure('Director base Controller init()');
+ $this->checkForRestApiRequest();
+ $this->checkDirectorPermissions();
+ $this->checkSpecialDirectorPermissions();
+ }
+
+ protected function initializeTranslator()
+ {
+ StaticTranslator::set(new Translator('director'));
+ }
+
+ public function getAuth()
+ {
+ return $this->Auth();
+ }
+
+ /**
+ * @codingStandardsIgnoreStart
+ * @return Window
+ */
+ public function Window()
+ {
+ // @codingStandardsIgnoreEnd
+ if ($this->window === null) {
+ $this->window = new Window(
+ $this->_request->getHeader('X-Icinga-WindowId', Window::UNDEFINED)
+ );
+ }
+ return $this->window;
+ }
+
+ /**
+ * @throws SecurityException
+ */
+ protected function checkDirectorPermissions()
+ {
+ $this->assertPermission('director/admin');
+ }
+
+ /**
+ * @throws SecurityException
+ */
+ protected function checkSpecialDirectorPermissions()
+ {
+ if ($this->params->get('format') === 'sql') {
+ $this->assertPermission('director/showsql');
+ }
+ }
+
+ /**
+ * Assert that the current user has one of the given permission
+ *
+ * @param array $permissions Permission name list
+ *
+ * @return $this
+ * @throws SecurityException If the current user lacks the given permission
+ */
+ protected function assertOneOfPermissions($permissions)
+ {
+ $auth = $this->Auth();
+
+ foreach ($permissions as $permission) {
+ if ($auth->hasPermission($permission)) {
+ return $this;
+ }
+ }
+
+ throw new SecurityException(
+ 'Got none of the following permissions: %s',
+ implode(', ', $permissions)
+ );
+ }
+
+ /**
+ * @param int $interval
+ * @return $this
+ */
+ public function setAutorefreshInterval($interval)
+ {
+ if (! $this->getRequest()->isApiRequest()) {
+ try {
+ parent::setAutorefreshInterval($interval);
+ } catch (ProgrammingError $e) {
+ throw new InvalidArgumentException($e->getMessage());
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * @return ServerRequestInterface
+ */
+ protected function getServerRequest()
+ {
+ return ServerRequest::fromGlobals();
+ }
+
+ protected function applyPaginationLimits(Paginatable $paginatable, $limit = 25, $offset = null)
+ {
+ $limit = $this->params->get('limit', $limit);
+ $page = $this->params->get('page', $offset);
+
+ $paginatable->limit($limit, $page > 0 ? ($page - 1) * $limit : 0);
+
+ return $paginatable;
+ }
+
+ protected function addAddLink($title, $url, $urlParams = null, $target = '_next')
+ {
+ $this->actions()->add(Link::create(
+ $this->translate('Add'),
+ $url,
+ $urlParams,
+ [
+ 'class' => 'icon-plus',
+ 'title' => $title,
+ 'data-base-target' => $target
+ ]
+ ));
+
+ return $this;
+ }
+
+ protected function addBackLink($url, $urlParams = null)
+ {
+ $this->actions()->add(new Link(
+ $this->translate('back'),
+ $url,
+ $urlParams,
+ ['class' => 'icon-left-big']
+ ));
+
+ return $this;
+ }
+
+ protected function sendUnsupportedMethod()
+ {
+ $method = strtoupper($this->getRequest()->getMethod()) ;
+ $response = $this->getResponse();
+ $this->sendJsonError($response, sprintf(
+ 'Method %s is not supported',
+ $method
+ ), 422); // TODO: check response code
+ }
+
+ /**
+ * @param string $permission
+ * @return $this
+ * @throws SecurityException
+ */
+ public function assertPermission($permission)
+ {
+ parent::assertPermission($permission);
+ return $this;
+ }
+
+ public function postDispatch()
+ {
+ Benchmark::measure('Director postDispatch');
+ if ($this->view->content || $this->view->controls) {
+ $viewRenderer = new SimpleViewRenderer();
+ $viewRenderer->replaceZendViewRenderer();
+ $this->view = $viewRenderer->view;
+ // Hint -> $this->view->compact is the only way since v2.8.0
+ if ($this->view->compact || $this->getOriginalUrl()->getParam('view') === 'compact') {
+ if ($this->view->controls) {
+ $this->controls()->getAttributes()->add('style', 'display: none;');
+ }
+ }
+ } else {
+ $viewRenderer = null;
+ }
+
+ $cType = $this->getResponse()->getHeader('Content-Type', true);
+ if ($this->getRequest()->isApiRequest() || ($cType !== null && $cType !== 'text/html')) {
+ $this->_helper->layout()->disableLayout();
+ if ($viewRenderer) {
+ $viewRenderer->disable();
+ } else {
+ $this->_helper->viewRenderer->setNoRender(true);
+ }
+ }
+
+ parent::postDispatch(); // TODO: Change the autogenerated stub
+ }
+
+ /**
+ * @return Monitoring
+ */
+ protected function monitoring()
+ {
+ if ($this->monitoring === null) {
+ $this->monitoring = new Monitoring;
+ }
+
+ return $this->monitoring;
+ }
+}
diff --git a/library/Director/Web/Controller/BranchHelper.php b/library/Director/Web/Controller/BranchHelper.php
new file mode 100644
index 0000000..ac2a480
--- /dev/null
+++ b/library/Director/Web/Controller/BranchHelper.php
@@ -0,0 +1,76 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Controller;
+
+use Icinga\Module\Director\Data\Db\DbObjectStore;
+use Icinga\Module\Director\Data\Db\DbObjectTypeRegistry;
+use Icinga\Module\Director\Db\Branch\Branch;
+use Icinga\Module\Director\Db\Branch\BranchStore;
+use Icinga\Module\Director\Db\Branch\BranchSupport;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Icinga\Module\Director\Web\Widget\NotInBranchedHint;
+
+trait BranchHelper
+{
+ /** @var Branch */
+ protected $branch;
+
+ /** @var BranchStore */
+ protected $branchStore;
+
+ /**
+ * @return false|\Ramsey\Uuid\UuidInterface
+ */
+ protected function getBranchUuid()
+ {
+ return $this->getBranch()->getUuid();
+ }
+
+ protected function getBranch()
+ {
+ if ($this->branch === null) {
+ /** @var ActionController $this */
+ $this->branch = Branch::forRequest($this->getRequest(), $this->getBranchStore(), $this->Auth());
+ }
+
+ return $this->branch;
+ }
+
+ /**
+ * @return BranchStore
+ */
+ protected function getBranchStore()
+ {
+ if ($this->branchStore === null) {
+ $this->branchStore = new BranchStore($this->db());
+ }
+
+ return $this->branchStore;
+ }
+
+ protected function hasBranch()
+ {
+ return $this->getBranchUuid() !== null;
+ }
+
+ protected function enableStaticObjectLoader($table)
+ {
+ if (BranchSupport::existsForTableName($table)) {
+ IcingaObject::setDbObjectStore(new DbObjectStore($this->db(), $this->getBranch()));
+ }
+ }
+
+ /**
+ * @param string $subject
+ * @return bool
+ */
+ protected function showNotInBranch($subject)
+ {
+ if ($this->getBranch()->isBranch()) {
+ $this->content()->add(new NotInBranchedHint($subject, $this->getBranch(), $this->Auth()));
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/library/Director/Web/Controller/Extension/CoreApi.php b/library/Director/Web/Controller/Extension/CoreApi.php
new file mode 100644
index 0000000..75cba50
--- /dev/null
+++ b/library/Director/Web/Controller/Extension/CoreApi.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Controller\Extension;
+
+use Icinga\Module\Director\Objects\IcingaEndpoint;
+use Icinga\Module\Director\Core\CoreApi as Api;
+
+trait CoreApi
+{
+ /** @var Api */
+ private $api;
+
+ /**
+ * @return Api|null
+ */
+ public function getApiIfAvailable()
+ {
+ if ($this->api === null) {
+ if ($this->db()->hasDeploymentEndpoint()) {
+ $endpoint = $this->db()->getDeploymentEndpoint();
+ $this->api = $endpoint->api();
+ }
+ }
+
+ return $this->api;
+ }
+
+ /**
+ * @param string $endpointName
+ * @return Api
+ */
+ public function api($endpointName = null)
+ {
+ if ($this->api === null) {
+ if ($endpointName === null) {
+ $endpoint = $this->db()->getDeploymentEndpoint();
+ } else {
+ $endpoint = IcingaEndpoint::load($endpointName, $this->db());
+ }
+
+ $this->api = $endpoint->api();
+ }
+
+ return $this->api;
+ }
+}
diff --git a/library/Director/Web/Controller/Extension/DirectorDb.php b/library/Director/Web/Controller/Extension/DirectorDb.php
new file mode 100644
index 0000000..03bec81
--- /dev/null
+++ b/library/Director/Web/Controller/Extension/DirectorDb.php
@@ -0,0 +1,160 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Controller\Extension;
+
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Web\Controller\ActionController;
+use Icinga\Module\Director\Web\Window;
+use RuntimeException;
+
+trait DirectorDb
+{
+ /** @var Db */
+ private $db;
+
+ protected function getDbResourceName()
+ {
+ if ($name = $this->getDbResourceNameFromRequest()) {
+ return $name;
+ } elseif ($name = $this->getPreferredDbResourceName()) {
+ return $name;
+ } else {
+ return $this->getFirstDbResourceName();
+ }
+ }
+
+ protected function getDbResourceNameFromRequest()
+ {
+ $param = 'dbResourceName';
+ // We shouldn't access _POST and _GET. However, this trait is used
+ // in various places - and our Request is going to be replaced anyways.
+ // So, let's not over-engineer things, this is quick & dirty:
+ if (isset($_POST[$param])) {
+ $name = $_POST[$param];
+ } elseif (isset($_GET[$param])) {
+ $name = $_GET[$param];
+ } else {
+ return null;
+ }
+
+ if (in_array($name, $this->listAllowedDbResourceNames())) {
+ return $name;
+ } else {
+ return null;
+ }
+ }
+
+ protected function getPreferredDbResourceName()
+ {
+ return $this->getWindowSessionValue('db_resource');
+ }
+
+ protected function getFirstDbResourceName()
+ {
+ $names = $this->listAllowedDbResourceNames();
+ if (empty($names)) {
+ return null;
+ } else {
+ return array_shift($names);
+ }
+ }
+
+ protected function listAllowedDbResourceNames()
+ {
+ /** @var \Icinga\Authentication\Auth $auth */
+ $auth = $this->Auth();
+
+ $available = $this->listAvailableDbResourceNames();
+ if ($resourceNames = $auth->getRestrictions('director/db_resource')) {
+ $names = [];
+ foreach ($resourceNames as $rNames) {
+ foreach ($this->splitList($rNames) as $name) {
+ if (array_key_exists($name, $available)) {
+ $names[] = $name;
+ }
+ }
+ }
+
+ return $names;
+ } else {
+ return $available;
+ }
+ }
+
+ /**
+ * @param string $string
+ * @return array
+ */
+ protected function splitList($string)
+ {
+ return preg_split('/\s*,\s*/', $string, -1, PREG_SPLIT_NO_EMPTY);
+ }
+
+ protected function isMultiDbSetup()
+ {
+ return count($this->listAvailableDbResourceNames()) > 1;
+ }
+
+ /**
+ * @return array
+ */
+ protected function listAvailableDbResourceNames()
+ {
+ /** @var \Icinga\Application\Config $config */
+ $config = $this->Config();
+ $resources = $config->get('db', 'resources');
+ if ($resources === null) {
+ $resource = $config->get('db', 'resource');
+ if ($resource === null) {
+ return [];
+ } else {
+ return [$resource => $resource];
+ }
+ } else {
+ $resources = $this->splitList($resources);
+ $resources = array_combine($resources, $resources);
+ // natsort doesn't work!?
+ ksort($resources, SORT_NATURAL);
+ if ($resource = $config->get('db', 'resource')) {
+ unset($resources[$resource]);
+ $resources = [$resource => $resource] + $resources;
+ }
+
+ return $resources;
+ }
+ }
+
+ protected function getWindowSessionValue($value, $default = null)
+ {
+ /** @var Window $window */
+ $window = $this->Window();
+ /** @var \Icinga\Web\Session\SessionNamespace $session */
+ $session = $window->getSessionNamespace('director');
+
+ return $session->get($value, $default);
+ }
+
+ /**
+ *
+ * @return Db
+ */
+ public function db()
+ {
+ if ($this->db === null) {
+ $resourceName = $this->getDbResourceName();
+ if ($resourceName) {
+ $this->db = Db::fromResourceName($resourceName);
+ } elseif ($this instanceof ActionController) {
+ if ($this->getRequest()->isApiRequest()) {
+ throw new RuntimeException('Icinga Director is not correctly configured');
+ } else {
+ $this->redirectNow('director');
+ }
+ } else {
+ throw new RuntimeException('Icinga Director is not correctly configured');
+ }
+ }
+
+ return $this->db;
+ }
+}
diff --git a/library/Director/Web/Controller/Extension/ObjectRestrictions.php b/library/Director/Web/Controller/Extension/ObjectRestrictions.php
new file mode 100644
index 0000000..bedb3f1
--- /dev/null
+++ b/library/Director/Web/Controller/Extension/ObjectRestrictions.php
@@ -0,0 +1,48 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Controller\Extension;
+
+use Icinga\Authentication\Auth;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Icinga\Module\Director\Restriction\HostgroupRestriction;
+use Icinga\Module\Director\Restriction\ObjectRestriction;
+
+trait ObjectRestrictions
+{
+ /** @var ObjectRestriction[] */
+ private $objectRestrictions;
+
+ /**
+ * @return ObjectRestriction[]
+ */
+ public function getObjectRestrictions()
+ {
+ if ($this->objectRestrictions === null) {
+ $this->objectRestrictions = $this->loadObjectRestrictions($this->db(), $this->Auth());
+ }
+
+ return $this->objectRestrictions;
+ }
+
+ /**
+ * @return ObjectRestriction[]
+ */
+ protected function loadObjectRestrictions(Db $db, Auth $auth)
+ {
+ return [
+ new HostgroupRestriction($db, $auth)
+ ];
+ }
+
+ public function allowsObject(IcingaObject $object)
+ {
+ foreach ($this->getObjectRestrictions() as $restriction) {
+ if (! $restriction->allows($object)) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/library/Director/Web/Controller/Extension/RestApi.php b/library/Director/Web/Controller/Extension/RestApi.php
new file mode 100644
index 0000000..3158f49
--- /dev/null
+++ b/library/Director/Web/Controller/Extension/RestApi.php
@@ -0,0 +1,114 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Controller\Extension;
+
+use Icinga\Exception\AuthenticationException;
+use Icinga\Exception\NotFoundError;
+use Icinga\Module\Director\Exception\JsonException;
+use Icinga\Web\Response;
+use InvalidArgumentException;
+use Zend_Controller_Response_Exception;
+
+trait RestApi
+{
+ protected function isApified()
+ {
+ if (property_exists($this, 'isApified')) {
+ return $this->isApified;
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * @return bool
+ */
+ protected function sendNotFoundForRestApi()
+ {
+ /** @var \Icinga\Web\Request $request */
+ $request = $this->getRequest();
+ if ($request->isApiRequest()) {
+ $this->sendJsonError($this->getResponse(), 'Not found', 404);
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ /**
+ * @return bool
+ */
+ protected function sendNotFoundUnlessRestApi()
+ {
+ /** @var \Icinga\Web\Request $request */
+ $request = $this->getRequest();
+ if ($request->isApiRequest()) {
+ return false;
+ } else {
+ $this->sendJsonError($this->getResponse(), 'Not found', 404);
+ return true;
+ }
+ }
+
+ /**
+ * @throws AuthenticationException
+ */
+ protected function assertApiPermission()
+ {
+ if (! $this->hasPermission('director/api')) {
+ throw new AuthenticationException('You are not allowed to access this API');
+ }
+ }
+
+ /**
+ * @throws AuthenticationException
+ * @throws NotFoundError
+ */
+ protected function checkForRestApiRequest()
+ {
+ /** @var \Icinga\Web\Request $request */
+ $request = $this->getRequest();
+ if ($request->isApiRequest()) {
+ $this->assertApiPermission();
+ if (! $this->isApified()) {
+ throw new NotFoundError('No such API endpoint found');
+ }
+ }
+ }
+
+ /**
+ * @param Response $response
+ * @param $object
+ */
+ protected function sendJson(Response $response, $object)
+ {
+ $response->setHeader('Content-Type', 'application/json', true);
+ echo json_encode($object, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n";
+ }
+
+ /**
+ * @param Response $response
+ * @param string $message
+ * @param int|null $code
+ */
+ protected function sendJsonError(Response $response, $message, $code = null)
+ {
+ if ($code !== null) {
+ try {
+ $response->setHttpResponseCode((int) $code);
+ } catch (Zend_Controller_Response_Exception $e) {
+ throw new InvalidArgumentException($e->getMessage(), 0, $e);
+ }
+ }
+
+ $this->sendJson($response, (object) ['error' => $message]);
+ }
+
+ /**
+ * @return string
+ */
+ protected function getLastJsonError()
+ {
+ return JsonException::getJsonErrorMessage(json_last_error());
+ }
+}
diff --git a/library/Director/Web/Controller/Extension/SingleObjectApiHandler.php b/library/Director/Web/Controller/Extension/SingleObjectApiHandler.php
new file mode 100644
index 0000000..bc51548
--- /dev/null
+++ b/library/Director/Web/Controller/Extension/SingleObjectApiHandler.php
@@ -0,0 +1,236 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Controller\Extension;
+
+use Exception;
+use Icinga\Exception\IcingaException;
+use Icinga\Exception\InvalidPropertyException;
+use Icinga\Exception\NotFoundError;
+use Icinga\Module\Director\Forms\IcingaDeleteObjectForm;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Icinga\Web\Request;
+use Icinga\Web\Response;
+
+class SingleObjectApiHandler
+{
+ use DirectorDb;
+
+ /** @var IcingaObject */
+ private $object;
+
+ /** @var string */
+ private $type;
+
+ /** @var Request */
+ private $request;
+
+ /** @var Response */
+ private $response;
+
+ /** @var \Icinga\Web\UrlParams */
+ private $params;
+
+ public function __construct($type, Request $request, Response $response)
+ {
+ $this->type = $type;
+ $this->request = $request;
+ $this->response = $response;
+ $this->params = $request->getUrl()->getParams();
+ }
+
+ public function runFailSafe()
+ {
+ try {
+ $this->loadObject();
+ $this->run();
+ } catch (NotFoundError $e) {
+ $this->sendJsonError($e->getMessage(), 404);
+ } catch (Exception $e) {
+ $response = $this->response;
+ if ($response->getHttpResponseCode() === 200) {
+ $response->setHttpResponseCode(500);
+ }
+
+ $this->sendJsonError($e->getMessage());
+ }
+ }
+
+ protected function retrieveObject()
+ {
+ $this->requireObject();
+ $this->sendJson(
+ $this->object->toPlainObject(
+ $this->params->shift('resolved'),
+ ! $this->params->shift('withNull'),
+ $this->params->shift('properties')
+ )
+ );
+ }
+
+ protected function deleteObject()
+ {
+ $this->requireObject();
+ $obj = $this->object->toPlainObject(false, true);
+ $form = new IcingaDeleteObjectForm();
+ $form->setObject($this->object)
+ ->setRequest($this->request)
+ ->onSuccess();
+
+ $this->sendJson($obj);
+ }
+
+ protected function storeObject()
+ {
+ $data = json_decode($this->request->getRawBody());
+
+ if ($data === null) {
+ $this->response->setHttpResponseCode(400);
+ throw new IcingaException(
+ 'Invalid JSON: %s' . $this->request->getRawBody(),
+ $this->getLastJsonError()
+ );
+ } else {
+ $data = (array) $data;
+ }
+
+ if ($object = $this->object) {
+ if ($this->request->getMethod() === 'POST') {
+ $object->setProperties($data);
+ } else {
+ $data = array_merge([
+ 'object_type' => $object->object_type,
+ 'object_name' => $object->object_name
+ ], $data);
+ $object->replaceWith(
+ IcingaObject::createByType($this->type, $data, $db)
+ );
+ }
+ } else {
+ $object = IcingaObject::createByType($this->type, $data, $db);
+ }
+
+ if ($object->hasBeenModified()) {
+ $status = $object->hasBeenLoadedFromDb() ? 200 : 201;
+ $object->store();
+ $this->response->setHttpResponseCode($status);
+ } else {
+ $this->response->setHttpResponseCode(304);
+ }
+
+ $this->sendJson($object->toPlainObject(false, true));
+ }
+
+ public function run()
+ {
+ switch ($this->request->getMethod()) {
+ case 'DELETE':
+ $this->deleteObject();
+ break;
+
+ case 'POST':
+ case 'PUT':
+ $this->storeObject();
+ break;
+
+ case 'GET':
+ $this->retrieveObject();
+ break;
+
+ default:
+ $this->response->setHttpResponseCode(400);
+ throw new IcingaException(
+ 'Unsupported method: %s',
+ $this->request->getMethod()
+ );
+ }
+ }
+
+ protected function requireObject()
+ {
+ if (! $this->object) {
+ $this->response->setHttpResponseCode(404);
+ if (! $this->params->get('name')) {
+ throw new NotFoundError('You need to pass a "name" parameter to access a specific object');
+ } else {
+ throw new NotFoundError('No such object available');
+ }
+ }
+ }
+
+ // TODO: just return json_last_error_msg() for PHP >= 5.5.0
+ protected function getLastJsonError()
+ {
+ switch (json_last_error()) {
+ case JSON_ERROR_DEPTH:
+ return 'The maximum stack depth has been exceeded';
+ case JSON_ERROR_CTRL_CHAR:
+ return 'Control character error, possibly incorrectly encoded';
+ case JSON_ERROR_STATE_MISMATCH:
+ return 'Invalid or malformed JSON';
+ case JSON_ERROR_SYNTAX:
+ return 'Syntax error';
+ case JSON_ERROR_UTF8:
+ return 'Malformed UTF-8 characters, possibly incorrectly encoded';
+ default:
+ return 'An error occured when parsing a JSON string';
+ }
+ }
+
+ protected function sendJson($object)
+ {
+ $this->response->setHeader('Content-Type', 'application/json', true);
+ $this->_helper->layout()->disableLayout();
+ $this->_helper->viewRenderer->setNoRender(true);
+ echo json_encode($object, JSON_PRETTY_PRINT) . "\n";
+ }
+
+ protected function sendJsonError($message, $code = null)
+ {
+ $response = $this->response;
+
+ if ($code !== null) {
+ $response->setHttpResponseCode((int) $code);
+ }
+
+ $this->sendJson((object) ['error' => $message]);
+ }
+
+ protected function loadObject()
+ {
+ if ($this->object === null) {
+ if ($name = $this->params->get('name')) {
+ $this->object = IcingaObject::loadByType(
+ $this->type,
+ $name,
+ $this->db()
+ );
+
+ if (! $this->allowsObject($this->object)) {
+ $this->object = null;
+ throw new NotFoundError('No such object available');
+ }
+ } elseif ($id = $this->params->get('id')) {
+ $this->object = IcingaObject::loadByType(
+ $this->type,
+ (int) $id,
+ $this->db()
+ );
+ } elseif ($this->request->isApiRequest()) {
+ if ($this->request->isGet()) {
+ $this->response->setHttpResponseCode(422);
+
+ throw new InvalidPropertyException(
+ 'Cannot load object, missing parameters'
+ );
+ }
+ }
+ }
+
+ return $this->object;
+ }
+
+ protected function allowsObject(IcingaObject $object)
+ {
+ return true;
+ }
+}
diff --git a/library/Director/Web/Controller/ObjectController.php b/library/Director/Web/Controller/ObjectController.php
new file mode 100644
index 0000000..0c06937
--- /dev/null
+++ b/library/Director/Web/Controller/ObjectController.php
@@ -0,0 +1,733 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Controller;
+
+use gipfl\Web\Widget\Hint;
+use Icinga\Exception\IcingaException;
+use Icinga\Exception\InvalidPropertyException;
+use Icinga\Exception\NotFoundError;
+use Icinga\Exception\ProgrammingError;
+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\UuidLookup;
+use Icinga\Module\Director\Deployment\DeploymentInfo;
+use Icinga\Module\Director\DirectorObject\Automation\ExportInterface;
+use Icinga\Module\Director\Exception\NestingError;
+use Icinga\Module\Director\Forms\DeploymentLinkForm;
+use Icinga\Module\Director\Forms\IcingaCloneObjectForm;
+use Icinga\Module\Director\Forms\IcingaObjectFieldForm;
+use Icinga\Module\Director\Objects\IcingaCommand;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Icinga\Module\Director\Objects\IcingaObjectGroup;
+use Icinga\Module\Director\Objects\IcingaService;
+use Icinga\Module\Director\Objects\IcingaServiceSet;
+use Icinga\Module\Director\RestApi\IcingaObjectHandler;
+use Icinga\Module\Director\Web\Controller\Extension\ObjectRestrictions;
+use Icinga\Module\Director\Web\Form\DirectorObjectForm;
+use Icinga\Module\Director\Web\ObjectPreview;
+use Icinga\Module\Director\Web\Table\ActivityLogTable;
+use Icinga\Module\Director\Web\Table\BranchActivityTable;
+use Icinga\Module\Director\Web\Table\GroupMemberTable;
+use Icinga\Module\Director\Web\Table\IcingaObjectDatafieldTable;
+use Icinga\Module\Director\Web\Tabs\ObjectTabs;
+use Icinga\Module\Director\Web\Widget\BranchedObjectHint;
+use gipfl\IcingaWeb2\Link;
+use ipl\Html\Html;
+use Ramsey\Uuid\Uuid;
+use Ramsey\Uuid\UuidInterface;
+
+abstract class ObjectController extends ActionController
+{
+ use ObjectRestrictions;
+ use BranchHelper;
+
+ /** @var IcingaObject */
+ protected $object;
+
+ /** @var bool This controller handles REST API requests */
+ protected $isApified = true;
+
+ /** @var array Allowed object types we are allowed to edit anyways */
+ protected $allowedExternals = array(
+ 'apiuser',
+ 'endpoint'
+ );
+
+ protected $type;
+
+ /** @var string|null */
+ protected $objectBaseUrl;
+
+ public function init()
+ {
+ parent::init();
+ $this->enableStaticObjectLoader($this->getTableName());
+ if ($this->getRequest()->isApiRequest()) {
+ $this->initializeRestApi();
+ } else {
+ $this->initializeWebRequest();
+ }
+ }
+
+ protected function initializeRestApi()
+ {
+ $handler = new IcingaObjectHandler($this->getRequest(), $this->getResponse(), $this->db());
+ try {
+ $this->loadOptionalObject();
+ } catch (NotFoundError $e) {
+ // Silently ignore the error, the handler will complain
+ $handler->sendJsonError($e, 404);
+ // TODO: nice shutdown
+ exit;
+ }
+
+ $handler->setApi($this->api());
+ if ($this->object) {
+ $handler->setObject($this->object);
+ }
+ $handler->dispatch();
+ // Hint: also here, hard exit. There is too much magic going on.
+ // Letting this bubble up smoothly would be "correct", but proved
+ // to be too fragile. Web 2, all kinds of pre/postDispatch magic,
+ // different view renderers - hard exit is the only safe bet right
+ // now.
+ exit;
+ }
+
+ protected function initializeWebRequest()
+ {
+ $this->loadOptionalObject();
+ if ($this->getRequest()->getActionName() === 'add') {
+ $this->addSingleTab(
+ sprintf($this->translate('Add %s'), ucfirst($this->getType())),
+ null,
+ 'add'
+ );
+ } else {
+ $this->tabs(new ObjectTabs(
+ $this->getRequest()->getControllerName(),
+ $this->getAuth(),
+ $this->object
+ ));
+ }
+ if ($this->object !== null) {
+ $this->addDeploymentLink();
+ }
+ }
+
+ /**
+ * @throws NotFoundError
+ */
+ public function indexAction()
+ {
+ if (! $this->getRequest()->isApiRequest()) {
+ $this->redirectToPreviewForExternals()
+ ->editAction();
+ }
+ }
+
+ public function addAction()
+ {
+ $this->tabs()->activate('add');
+ $url = sprintf('director/%ss', $this->getPluralType());
+
+ $imports = $this->params->get('imports');
+ $form = $this->loadObjectForm()
+ ->presetImports($imports)
+ ->setSuccessUrl($url);
+
+ if ($oType = $this->params->get('type', 'object')) {
+ $form->setPreferredObjectType($oType);
+ }
+ if ($oType === 'template') {
+ if ($this->showNotInBranch($this->translate('Creating Templates'))) {
+ $this->addTitle($this->translate('Create a new Template'));
+ return;
+ }
+
+ $this->addTemplate();
+ } else {
+ $this->addObject();
+ }
+ $branch = $this->getBranch();
+ if ($branch->isBranch() && ! $this->getRequest()->isApiRequest()) {
+ $this->content()->add(new BranchedObjectHint($branch, $this->Auth()));
+ }
+
+ $form->handleRequest();
+ $this->content()->add($form);
+ }
+
+ /**
+ * @throws NotFoundError
+ */
+ public function editAction()
+ {
+ $object = $this->requireObject();
+ $this->tabs()->activate('modify');
+ $this->addObjectTitle();
+ // Hint: Service Sets are 'templates' (as long as not being assigned to a host
+ if ($this->getTableName() !== 'icinga_service_set'
+ && $object->isTemplate()
+ && $this->showNotInBranch($this->translate('Modifying Templates'))
+ ) {
+ return;
+ }
+ if ($object->isApplyRule() && $this->showNotInBranch($this->translate('Modifying Apply Rules'))) {
+ return;
+ }
+
+ $this->addObjectForm($object)
+ ->addActionClone()
+ ->addActionUsage()
+ ->addActionBasket();
+ }
+
+ /**
+ * @throws NotFoundError
+ * @throws \Icinga\Security\SecurityException
+ */
+ public function renderAction()
+ {
+ $this->assertTypePermission()
+ ->assertPermission('director/showconfig');
+ $this->tabs()->activate('render');
+ $preview = new ObjectPreview($this->requireObject(), $this->getRequest());
+ if ($this->object->isExternal()) {
+ $this->addActionClone();
+ }
+ $this->addActionBasket();
+ $preview->renderTo($this);
+ }
+
+ /**
+ * @throws NotFoundError
+ */
+ public function cloneAction()
+ {
+ $this->assertTypePermission();
+ $object = $this->requireObject();
+ $this->addTitle($this->translate('Clone: %s'), $object->getObjectName())
+ ->addBackToObjectLink();
+
+ if ($object->isTemplate() && $this->showNotInBranch($this->translate('Cloning Templates'))) {
+ return;
+ }
+
+ if ($object->isTemplate() && $this->showNotInBranch($this->translate('Cloning Apply Rules'))) {
+ return;
+ }
+
+ $form = IcingaCloneObjectForm::load()
+ ->setBranch($this->getBranch())
+ ->setObject($object)
+ ->setObjectBaseUrl($this->getObjectBaseUrl())
+ ->handleRequest();
+
+ if ($object->isExternal()) {
+ $this->tabs()->activate('render');
+ } else {
+ $this->tabs()->activate('modify');
+ }
+ $this->content()->add($form);
+ }
+
+ /**
+ * @throws NotFoundError
+ * @throws \Icinga\Security\SecurityException
+ */
+ public function fieldsAction()
+ {
+ $this->assertPermission('director/admin');
+ $object = $this->requireObject();
+ $type = $this->getType();
+
+ $this->addTitle(
+ $this->translate('Custom fields: %s'),
+ $object->getObjectName()
+ );
+ $this->tabs()->activate('fields');
+ if ($this->showNotInBranch($this->translate('Managing Fields'))) {
+ return;
+ }
+
+ try {
+ $this->addFieldsFormAndTable($object, $type);
+ } catch (NestingError $e) {
+ $this->content()->add(Hint::error($e->getMessage()));
+ }
+ }
+
+ protected function addFieldsFormAndTable($object, $type)
+ {
+ $form = IcingaObjectFieldForm::load()
+ ->setDb($this->db())
+ ->setIcingaObject($object);
+
+ if ($id = $this->params->get('field_id')) {
+ $form->loadObject([
+ "${type}_id" => $object->id,
+ 'datafield_id' => $id
+ ]);
+
+ $this->actions()->add(Link::create(
+ $this->translate('back'),
+ $this->url()->without('field_id'),
+ null,
+ ['class' => 'icon-left-big']
+ ));
+ }
+ $form->handleRequest();
+ $this->content()->add($form);
+ $table = new IcingaObjectDatafieldTable($object);
+ $table->getAttributes()->set('data-base-target', '_self');
+ $table->renderTo($this);
+ }
+
+ /**
+ * @throws NotFoundError
+ * @throws \Icinga\Security\SecurityException
+ */
+ public function historyAction()
+ {
+ $this
+ ->assertTypePermission()
+ ->assertPermission('director/audit')
+ ->setAutorefreshInterval(10)
+ ->tabs()->activate('history');
+
+ $name = $this->requireObject()->getObjectName();
+ $this->addTitle($this->translate('Activity Log: %s'), $name);
+
+ $db = $this->db();
+ $objectTable = $this->object->getTableName();
+ $table = (new ActivityLogTable($db))
+ ->setLastDeployedId($db->getLastDeploymentActivityLogId())
+ ->filterObject($objectTable, $name);
+ if ($host = $this->params->get('host')) {
+ $table->filterHost($host);
+ }
+ $this->showOptionalBranchActivity($table);
+ $table->renderTo($this);
+ }
+
+ /**
+ * @throws NotFoundError
+ */
+ public function membershipAction()
+ {
+ $object = $this->requireObject();
+ if (! $object instanceof IcingaObjectGroup) {
+ throw new NotFoundError('Not Found');
+ }
+
+ $this
+ ->addTitle($this->translate('Group membership: %s'), $object->getObjectName())
+ ->setAutorefreshInterval(15)
+ ->tabs()->activate('membership');
+
+ $type = substr($this->getType(), 0, -5);
+ GroupMemberTable::create($type, $this->db())
+ ->setGroup($object)
+ ->renderTo($this);
+ }
+
+ /**
+ * @return $this
+ * @throws NotFoundError
+ */
+ protected function addObjectTitle()
+ {
+ $object = $this->requireObject();
+ $name = $object->getObjectName();
+ if ($object->isTemplate()) {
+ $this->addTitle($this->translate('Template: %s'), $name);
+ } else {
+ $this->addTitle($name);
+ }
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ * @throws NotFoundError
+ */
+ protected function addActionUsage()
+ {
+ $type = $this->getType();
+ $object = $this->requireObject();
+ if ($object->isTemplate() && $type !== 'serviceSet') {
+ $this->actions()->add([
+ Link::create(
+ $this->translate('Usage'),
+ "director/${type}template/usage",
+ ['name' => $object->getObjectName()],
+ ['class' => 'icon-sitemap']
+ )
+ ]);
+ }
+
+ return $this;
+ }
+
+ protected function addActionClone()
+ {
+ $this->actions()->add(Link::create(
+ $this->translate('Clone'),
+ $this->getObjectBaseUrl() . '/clone',
+ $this->object->getUrlParams(),
+ array('class' => 'icon-paste')
+ ));
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ */
+ protected function addActionBasket()
+ {
+ if ($this->hasBasketSupport()) {
+ $object = $this->object;
+ if ($object instanceof ExportInterface) {
+ if ($object instanceof IcingaCommand) {
+ if ($object->isExternal()) {
+ $type = 'ExternalCommand';
+ } elseif ($object->isTemplate()) {
+ $type = 'CommandTemplate';
+ } else {
+ $type = 'Command';
+ }
+ } elseif ($object instanceof IcingaServiceSet) {
+ $type = 'ServiceSet';
+ } elseif ($object->isTemplate()) {
+ $type = ucfirst($this->getType()) . 'Template';
+ } elseif ($object->isGroup()) {
+ $type = ucfirst($this->getType());
+ } else {
+ // Command? Sure?
+ $type = ucfirst($this->getType());
+ }
+ $this->actions()->add(Link::create(
+ $this->translate('Add to Basket'),
+ 'director/basket/add',
+ [
+ 'type' => $type,
+ 'names' => $object->getUniqueIdentifier()
+ ],
+ ['class' => 'icon-tag']
+ ));
+ }
+ }
+
+ return $this;
+ }
+
+ protected function addTemplate()
+ {
+ $this->assertPermission('director/admin');
+ $this->addTitle(
+ $this->translate('Add new Icinga %s template'),
+ $this->getTranslatedType()
+ );
+ }
+
+ protected function addObject()
+ {
+ $this->assertTypePermission();
+ $imports = $this->params->get('imports');
+ if (is_string($imports) && strlen($imports)) {
+ $this->addTitle(
+ $this->translate('Add %s: %s'),
+ $this->getTranslatedType(),
+ $imports
+ );
+ } else {
+ $this->addTitle(
+ $this->translate('Add new Icinga %s'),
+ $this->getTranslatedType()
+ );
+ }
+ }
+
+ protected function redirectToPreviewForExternals()
+ {
+ if ($this->object
+ && $this->object->isExternal()
+ && ! in_array($this->object->getShortTableName(), $this->allowedExternals)
+ ) {
+ $this->redirectNow(
+ $this->getRequest()->getUrl()->setPath(sprintf('director/%s/render', $this->getType()))
+ );
+ }
+
+ return $this;
+ }
+
+ protected function getType()
+ {
+ if ($this->type === null) {
+ // Strip final 's' and upcase an eventual 'group'
+ $this->type = preg_replace(
+ array('/group$/', '/period$/', '/argument$/', '/apiuser$/', '/set$/'),
+ array('Group', 'Period', 'Argument', 'ApiUser', 'Set'),
+ $this->getRequest()->getControllerName()
+ );
+ }
+
+ return $this->type;
+ }
+
+ protected function getPluralType()
+ {
+ return $this->getType() . 's';
+ }
+
+ protected function getTranslatedType()
+ {
+ return $this->translate(ucfirst($this->getType()));
+ }
+
+ protected function assertTypePermission()
+ {
+ $type = strtolower($this->getPluralType());
+ // TODO: Check getPluralType usage, fix it there.
+ if ($type === 'scheduleddowntimes') {
+ $type = 'scheduled-downtimes';
+ }
+
+ return $this->assertPermission("director/$type");
+ }
+
+ protected function loadOptionalObject()
+ {
+ if ($this->params->get('uuid') || null !== $this->params->get('name') || $this->params->get('id')) {
+ $this->loadObject();
+ }
+ }
+
+ /**
+ * @return ?UuidInterface
+ * @throws InvalidPropertyException
+ * @throws NotFoundError
+ */
+ protected function getUuidFromUrl()
+ {
+ $key = null;
+ if ($uuid = $this->params->get('uuid')) {
+ $key = Uuid::fromString($uuid);
+ } elseif ($id = $this->params->get('id')) {
+ $key = (int) $id;
+ } elseif (null !== ($name = $this->params->get('name'))) {
+ $key = $name;
+ }
+ if ($key === null) {
+ $request = $this->getRequest();
+ if ($request->isApiRequest() && $request->isGet()) {
+ $this->getResponse()->setHttpResponseCode(422);
+
+ throw new InvalidPropertyException(
+ 'Cannot load object, missing parameters'
+ );
+ }
+
+ return null;
+ }
+
+ return $this->requireUuid($key);
+ }
+
+ protected function loadObject()
+ {
+ if ($this->object) {
+ throw new ProgrammingError('Loading an object twice is not very efficient');
+ }
+
+ $this->object = $this->loadSpecificObject($this->getTableName(), $this->getUuidFromUrl(), true);
+ }
+
+ protected function loadSpecificObject($tableName, $key, $showHint = false)
+ {
+ $branch = $this->getBranch();
+ $branchedObject = BranchedObject::load($this->db(), $tableName, $key, $branch);
+ $object = $branchedObject->getBranchedDbObject($this->db());
+ assert($object instanceof IcingaObject);
+ $object->setBeingLoadedFromDb();
+ if (! $this->allowsObject($object)) {
+ throw new NotFoundError('No such object available');
+ }
+ if ($showHint && $branch->isBranch() && $object->isObject() && ! $this->getRequest()->isApiRequest()) {
+ $this->content()->add(new BranchedObjectHint($branch, $this->Auth(), $branchedObject));
+ }
+
+ return $object;
+ }
+
+ protected function requireUuid($key)
+ {
+ if (! $key instanceof UuidInterface) {
+ $key = UuidLookup::findUuidForKey($key, $this->getTableName(), $this->db(), $this->getBranch());
+ if ($key === null) {
+ throw new NotFoundError('No such object available');
+ }
+ }
+
+ return $key;
+ }
+
+ protected function getTableName()
+ {
+ return DbObjectTypeRegistry::tableNameByType($this->getType());
+ }
+
+ protected function addDeploymentLink()
+ {
+ try {
+ $info = new DeploymentInfo($this->db());
+ $info->setObject($this->object);
+
+ if (! $this->getRequest()->isApiRequest()) {
+ if ($this->getBranch()->isBranch()) {
+ $this->actions()->add($this->linkToMergeBranch($this->getBranch()));
+ } else {
+ $this->actions()->add(
+ DeploymentLinkForm::create(
+ $this->db(),
+ $info,
+ $this->Auth(),
+ $this->api()
+ )->handleRequest()
+ );
+ }
+ }
+ } catch (IcingaException $e) {
+ // pass (deployment may not be set up yet)
+ }
+ }
+
+ protected function linkToMergeBranch(Branch $branch)
+ {
+ $link = Branch::requireHook()->linkToBranch($branch, $this->Auth(), $this->translate('Merge'));
+ if ($link instanceof Link) {
+ $link->addAttributes(['class' => 'icon-flapping']);
+ }
+
+ return $link;
+ }
+
+ protected function addBackToObjectLink()
+ {
+ $params = [
+ 'uuid' => $this->object->getUniqueId()->toString(),
+ ];
+
+ if ($this->object instanceof IcingaService) {
+ if (($host = $this->object->get('host')) !== null) {
+ $params['host'] = $host;
+ } elseif (($set = $this->object->get('service_set')) !== null) {
+ $params['set'] = $set;
+ }
+ }
+
+ $this->actions()->add(Link::create(
+ $this->translate('back'),
+ $this->getObjectBaseUrl(),
+ $params,
+ ['class' => 'icon-left-big']
+ ));
+
+ return $this;
+ }
+
+ protected function addObjectForm(IcingaObject $object = null)
+ {
+ $form = $this->loadObjectForm($object);
+ $this->content()->add($form);
+ $form->handleRequest();
+ return $this;
+ }
+
+ protected function loadObjectForm(IcingaObject $object = null)
+ {
+ /** @var DirectorObjectForm $class */
+ $class = sprintf(
+ 'Icinga\\Module\\Director\\Forms\\Icinga%sForm',
+ ucfirst($this->getType())
+ );
+
+ $form = $class::load()
+ ->setDb($this->db())
+ ->setAuth($this->Auth());
+
+ if ($object !== null) {
+ $form->setObject($object);
+ }
+ if (true || $form->supportsBranches()) {
+ $form->setBranch($this->getBranch());
+ }
+
+ $this->onObjectFormLoaded($form);
+
+ return $form;
+ }
+
+ protected function getObjectBaseUrl()
+ {
+ return $this->objectBaseUrl ?: 'director/' . strtolower($this->getType());
+ }
+
+ protected function hasBasketSupport()
+ {
+ return $this->object->isTemplate() || $this->object->isGroup();
+ }
+
+ protected function onObjectFormLoaded(DirectorObjectForm $form)
+ {
+ }
+
+ /**
+ * @return IcingaObject
+ * @throws NotFoundError
+ */
+ protected function requireObject()
+ {
+ if (! $this->object) {
+ $this->getResponse()->setHttpResponseCode(404);
+ if (null === $this->params->get('name')) {
+ throw new NotFoundError('You need to pass a "name" parameter to access a specific object');
+ } else {
+ throw new NotFoundError('No such object available');
+ }
+ }
+
+ return $this->object;
+ }
+
+ protected function showOptionalBranchActivity($activityTable)
+ {
+ $branch = $this->getBranch();
+ if ($branch->isBranch() && (int) $this->params->get('page', '1') === 1) {
+ $table = new BranchActivityTable($branch->getUuid(), $this->db(), $this->object->getUniqueId());
+ if (count($table) > 0) {
+ $this->content()->add(Hint::info(Html::sprintf($this->translate(
+ 'The following modifications are visible in this %s only...'
+ ), Branch::requireHook()->linkToBranch(
+ $branch,
+ $this->Auth(),
+ $this->translate('configuration branch')
+ ))));
+ $this->content()->add($table);
+ if (count($activityTable) === 0) {
+ return;
+ }
+ $this->content()->add(Html::tag('br'));
+ $this->content()->add(Hint::ok($this->translate(
+ '...and the modifications below are already in the main branch:'
+ )));
+ $this->content()->add(Html::tag('br'));
+ }
+ }
+ }
+}
diff --git a/library/Director/Web/Controller/ObjectsController.php b/library/Director/Web/Controller/ObjectsController.php
new file mode 100644
index 0000000..8c10b44
--- /dev/null
+++ b/library/Director/Web/Controller/ObjectsController.php
@@ -0,0 +1,548 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Controller;
+
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+use Icinga\Data\Filter\FilterChain;
+use Icinga\Data\Filter\FilterExpression;
+use Icinga\Exception\NotFoundError;
+use Icinga\Data\Filter\Filter;
+use Icinga\Module\Director\Data\Db\DbObjectStore;
+use Icinga\Module\Director\Data\Db\DbObjectTypeRegistry;
+use Icinga\Module\Director\Forms\IcingaMultiEditForm;
+use Icinga\Module\Director\Objects\IcingaCommand;
+use Icinga\Module\Director\Objects\IcingaHost;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Icinga\Module\Director\RestApi\IcingaObjectsHandler;
+use Icinga\Module\Director\Web\ActionBar\ObjectsActionBar;
+use Icinga\Module\Director\Web\ActionBar\TemplateActionBar;
+use Icinga\Module\Director\Web\Form\FormLoader;
+use Icinga\Module\Director\Web\Table\ApplyRulesTable;
+use Icinga\Module\Director\Web\Table\ObjectSetTable;
+use Icinga\Module\Director\Web\Table\ObjectsTable;
+use Icinga\Module\Director\Web\Table\TemplatesTable;
+use Icinga\Module\Director\Web\Tabs\ObjectsTabs;
+use Icinga\Module\Director\Web\Tree\TemplateTreeRenderer;
+use gipfl\IcingaWeb2\Link;
+use Icinga\Module\Director\Web\Widget\AdditionalTableActions;
+use Icinga\Module\Director\Web\Widget\BranchedObjectsHint;
+use InvalidArgumentException;
+use Ramsey\Uuid\Uuid;
+
+abstract class ObjectsController extends ActionController
+{
+ use BranchHelper;
+
+ protected $isApified = true;
+
+ /** @var ObjectsTable */
+ protected $table;
+
+ protected function checkDirectorPermissions()
+ {
+ if ($this->getRequest()->getActionName() !== 'sets') {
+ $this->assertPermission('director/' . $this->getPluralBaseType());
+ }
+ }
+
+ /**
+ * @return $this
+ * @throws \Icinga\Exception\Http\HttpNotFoundException
+ */
+ protected function addObjectsTabs()
+ {
+ $tabName = $this->getRequest()->getActionName();
+ if (substr($this->getType(), -5) === 'Group') {
+ $tabName = 'groups';
+ }
+ $this->tabs(new ObjectsTabs(
+ $this->getBaseType(),
+ $this->Auth(),
+ $this->getBaseObjectUrl()
+ ))->activate($tabName);
+
+ return $this;
+ }
+
+ /**
+ * @return IcingaObjectsHandler
+ * @throws NotFoundError
+ */
+ protected function apiRequestHandler()
+ {
+ $request = $this->getRequest();
+ $table = $this->getTable();
+ if ($request->getControllerName() === 'services'
+ && $host = $this->params->get('host')
+ ) {
+ $host = IcingaHost::load($host, $this->db());
+ $table->getQuery()->where('o.host_id = ?', $host->get('id'));
+ }
+
+ if ($request->getActionName() === 'templates') {
+ $table->filterObjectType('template');
+ } elseif ($request->getActionName() === 'applyrules') {
+ $table->filterObjectType('apply');
+ }
+ $search = $this->params->get('q');
+ if ($search !== null && \strlen($search) > 0) {
+ $table->search($search);
+ }
+
+ return (new IcingaObjectsHandler(
+ $request,
+ $this->getResponse(),
+ $this->db()
+ ))->setTable($table);
+ }
+
+ /**
+ * @throws \Icinga\Exception\Http\HttpNotFoundException
+ * @throws NotFoundError
+ */
+ public function indexAction()
+ {
+ if ($this->getRequest()->isApiRequest()) {
+ $this->apiRequestHandler()->dispatch();
+ return;
+ }
+
+ $type = $this->getType();
+ if ($this->params->get('format') === 'json') {
+ $filename = sprintf(
+ "director-${type}_%s.json",
+ date('YmdHis')
+ );
+ $this->getResponse()->setHeader('Content-disposition', "attachment; filename=$filename", true);
+ $this->apiRequestHandler()->dispatch();
+ return;
+ }
+
+ $this
+ ->addObjectsTabs()
+ ->setAutorefreshInterval(10)
+ ->addTitle($this->translate(ucfirst($this->getPluralType())))
+ ->actions(new ObjectsActionBar($this->getBaseObjectUrl(), $this->url()));
+
+ $this->content()->add(new BranchedObjectsHint($this->getBranch(), $this->Auth()));
+
+ if ($type === 'command' && $this->params->get('type') === 'external_object') {
+ $this->tabs()->activate('external');
+ }
+
+ // Hint: might be used in controllers extending this
+ $this->table = $this->eventuallyFilterCommand($this->getTable());
+
+ $this->table->renderTo($this);
+ (new AdditionalTableActions($this->getAuth(), $this->url(), $this->table))
+ ->appendTo($this->actions());
+ }
+
+ /**
+ * @return ObjectsTable
+ */
+ protected function getTable()
+ {
+ $table = ObjectsTable::create($this->getType(), $this->db())
+ ->setAuth($this->getAuth())
+ ->setBranchUuid($this->getBranchUuid())
+ ->setBaseObjectUrl($this->getBaseObjectUrl());
+
+ return $table;
+ }
+
+ /**
+ * @return ApplyRulesTable
+ * @throws NotFoundError
+ */
+ protected function getApplyRulesTable()
+ {
+ $table = new ApplyRulesTable($this->db());
+ $table->setType($this->getType())
+ ->setBaseObjectUrl($this->getBaseObjectUrl());
+ $this->eventuallyFilterCommand($table);
+
+ return $table;
+ }
+
+ /**
+ * @throws NotFoundError
+ */
+ public function edittemplatesAction()
+ {
+ $this->commonForEdit();
+ }
+
+ /**
+ * @throws NotFoundError
+ */
+ public function editAction()
+ {
+ $this->commonForEdit();
+ }
+
+ /**
+ * @throws NotFoundError
+ */
+ public function commonForEdit()
+ {
+ $type = ucfirst($this->getType());
+
+ if (empty($this->multiEdit)) {
+ throw new NotFoundError('Cannot edit multiple "%s" instances', $type);
+ }
+
+ $objects = $this->loadMultiObjectsFromParams();
+ if (empty($objects)) {
+ throw new NotFoundError('No "%s" instances have been loaded', $type);
+ }
+ $formName = 'icinga' . $type;
+ $form = IcingaMultiEditForm::load()
+ ->setBranch($this->getBranch())
+ ->setObjects($objects)
+ ->pickElementsFrom($this->loadForm($formName), $this->multiEdit);
+ if ($type === 'Service') {
+ $form->setListUrl('director/services');
+ } elseif ($type === 'Host') {
+ $form->setListUrl('director/hosts');
+ }
+
+ $form->handleRequest();
+
+ $this
+ ->addSingleTab($this->translate('Multiple objects'))
+ ->addTitle(
+ $this->translate('Modify %d objects'),
+ count($objects)
+ )->content()->add($form);
+ }
+
+ /**
+ * Loads the TemplatesTable or the TemplateTreeRenderer
+ *
+ * Passing render=tree switches to the tree view.
+ * @throws \Icinga\Exception\Http\HttpNotFoundException
+ * @throws \Icinga\Security\SecurityException
+ * @throws NotFoundError
+ */
+ public function templatesAction()
+ {
+ if ($this->getRequest()->isApiRequest()) {
+ $this->apiRequestHandler()->dispatch();
+ return;
+ }
+ $type = $this->getType();
+
+ if ($this->params->get('format') === 'json') {
+ $filename = sprintf(
+ "director-${type}-templates_%s.json",
+ date('YmdHis')
+ );
+ $this->getResponse()->setHeader('Content-disposition', "attachment; filename=$filename", true);
+ $this->apiRequestHandler()->dispatch();
+ return;
+ }
+
+ $shortType = IcingaObject::createByType($type)->getShortTableName();
+ $this
+ ->assertPermission('director/admin')
+ ->addObjectsTabs()
+ ->setAutorefreshInterval(10)
+ ->addTitle(
+ $this->translate('All your %s Templates'),
+ $this->translate(ucfirst($type))
+ )
+ ->actions(new TemplateActionBar($shortType, $this->url()));
+
+ if ($this->params->get('render') === 'tree') {
+ TemplateTreeRenderer::showType($shortType, $this, $this->db());
+ } else {
+ $table = TemplatesTable::create($shortType, $this->db());
+ $this->eventuallyFilterCommand($table);
+ $table->renderTo($this);
+ (new AdditionalTableActions($this->getAuth(), $this->url(), $table))
+ ->appendTo($this->actions());
+ }
+ }
+
+ /**
+ * @return $this
+ * @throws \Icinga\Security\SecurityException
+ */
+ protected function assertApplyRulePermission()
+ {
+ return $this->assertPermission('director/admin');
+ }
+
+ /**
+ * @throws \Icinga\Exception\Http\HttpNotFoundException
+ * @throws \Icinga\Security\SecurityException
+ * @throws NotFoundError
+ */
+ public function applyrulesAction()
+ {
+ if ($this->getRequest()->isApiRequest()) {
+ $this->apiRequestHandler()->dispatch();
+ return;
+ }
+
+ $type = $this->getType();
+
+ if ($this->params->get('format') === 'json') {
+ $filename = sprintf(
+ "director-${type}-applyrules_%s.json",
+ date('YmdHis')
+ );
+ $this->getResponse()->setHeader('Content-disposition', "attachment; filename=$filename", true);
+ $this->apiRequestHandler()->dispatch();
+ return;
+ }
+
+ $tType = $this->translate(ucfirst($type));
+ $this
+ ->assertApplyRulePermission()
+ ->addObjectsTabs()
+ ->setAutorefreshInterval(10)
+ ->addTitle(
+ $this->translate('All your %s Apply Rules'),
+ $tType
+ );
+ $baseUrl = 'director/' . $this->getBaseObjectUrl();
+ $this->actions()
+ //->add($this->getBackToDashboardLink())
+ ->add(
+ Link::create(
+ $this->translate('Add'),
+ "${baseUrl}/add",
+ ['type' => 'apply'],
+ [
+ 'title' => sprintf(
+ $this->translate('Create a new %s Apply Rule'),
+ $tType
+ ),
+ 'class' => 'icon-plus',
+ 'data-base-target' => '_next'
+ ]
+ )
+ );
+
+ $table = $this->getApplyRulesTable();
+ $table->renderTo($this);
+ (new AdditionalTableActions($this->getAuth(), $this->url(), $table))
+ ->appendTo($this->actions());
+ }
+
+ /**
+ * @throws NotFoundError
+ * @throws \Icinga\Exception\Http\HttpNotFoundException
+ * @throws \Icinga\Security\SecurityException
+ */
+ public function setsAction()
+ {
+ $type = $this->getType();
+ $tType = $this->translate(ucfirst($type));
+ $this
+ ->assertPermission('director/' . $this->getBaseType() . 'sets')
+ ->addObjectsTabs()
+ ->requireSupportFor('Sets')
+ ->setAutorefreshInterval(10)
+ ->addTitle(
+ $this->translate('Icinga %s Sets'),
+ $tType
+ );
+
+ $this->actions()->add(
+ Link::create(
+ $this->translate('Add'),
+ "director/${type}set/add",
+ null,
+ [
+ 'title' => sprintf(
+ $this->translate('Create a new %s Set'),
+ $tType
+ ),
+ 'class' => 'icon-plus',
+ 'data-base-target' => '_next'
+ ]
+ )
+ );
+
+ ObjectSetTable::create($type, $this->db(), $this->getAuth())
+ ->setBranch($this->getBranch())
+ ->renderTo($this);
+ }
+
+ /**
+ * @return array
+ * @throws NotFoundError
+ */
+ protected function loadMultiObjectsFromParams()
+ {
+ $filter = Filter::fromQueryString($this->params->toString());
+ $type = $this->getType();
+ $objects = array();
+ $db = $this->db();
+ $class = DbObjectTypeRegistry::classByType($type);
+ $table = DbObjectTypeRegistry::tableNameByType($type);
+ $store = new DbObjectStore($db, $this->getBranch());
+
+ /** @var $filter FilterChain */
+ foreach ($filter->filters() as $sub) {
+ /** @var $sub FilterChain */
+ foreach ($sub->filters() as $ex) {
+ /** @var $ex FilterChain|FilterExpression */
+ $col = $ex->getColumn();
+ if ($ex->isExpression()) {
+ if ($col === 'name') {
+ $name = $ex->getExpression();
+ if ($type === 'service') {
+ $key = [
+ 'object_type' => 'template',
+ 'object_name' => $name
+ ];
+ } else {
+ $key = $name;
+ }
+ $objects[$name] = $class::load($key, $db);
+ } elseif ($col === 'id') {
+ $name = $ex->getExpression();
+ $objects[$name] = $class::load($name, $db);
+ } elseif ($col === 'uuid') {
+ $object = $store->load($table, Uuid::fromString($ex->getExpression()));
+ $objects[$object->getObjectName()] = $object;
+ } else {
+ throw new InvalidArgumentException("'$col' is no a valid key component for '$type'");
+ }
+ }
+ }
+ }
+
+ return $objects;
+ }
+
+ /**
+ * @param string $name
+ *
+ * @return \Icinga\Module\Director\Web\Form\QuickForm
+ */
+ public function loadForm($name)
+ {
+ $form = FormLoader::load($name, $this->Module());
+ if ($this->getRequest()->isApiRequest()) {
+ // TODO: Ask form for API support?
+ $form->setApiRequest();
+ }
+
+ return $form;
+ }
+
+ /**
+ * @param ZfQueryBasedTable $table
+ * @return ZfQueryBasedTable
+ * @throws NotFoundError
+ */
+ protected function eventuallyFilterCommand(ZfQueryBasedTable $table)
+ {
+ if ($this->params->get('command')) {
+ $command = IcingaCommand::load($this->params->get('command'), $this->db());
+ switch ($this->getBaseType()) {
+ case 'host':
+ case 'service':
+ $table->getQuery()->where(
+ $this->db()->getDbAdapter()->quoteInto(
+ '(o.check_command_id = ? OR o.event_command_id = ?)',
+ $command->getAutoincId()
+ )
+ );
+ break;
+ case 'notification':
+ $table->getQuery()->where(
+ 'o.command_id = ?',
+ $command->getAutoincId()
+ );
+ break;
+ }
+ }
+
+ return $table;
+ }
+
+ /**
+ * @param $feature
+ * @return $this
+ * @throws NotFoundError
+ */
+ protected function requireSupportFor($feature)
+ {
+ if ($this->supports($feature) !== true) {
+ throw new NotFoundError(
+ '%s does not support %s',
+ $this->getType(),
+ $feature
+ );
+ }
+
+ return $this;
+ }
+
+ /**
+ * @param $feature
+ * @return bool
+ */
+ protected function supports($feature)
+ {
+ $func = "supports$feature";
+ return IcingaObject::createByType($this->getType())->$func();
+ }
+
+ /**
+ * @return string
+ */
+ protected function getBaseType()
+ {
+ $type = $this->getType();
+ if (substr($type, -5) === 'Group') {
+ return substr($type, 0, -5);
+ } else {
+ return $type;
+ }
+ }
+
+ protected function getBaseObjectUrl()
+ {
+ return $this->getType();
+ }
+
+ /**
+ * @return string
+ */
+ protected function getType()
+ {
+ // Strip final 's' and upcase an eventual 'group'
+ return preg_replace(
+ array('/group$/', '/period$/', '/argument$/', '/apiuser$/', '/dependencie$/', '/set$/'),
+ array('Group', 'Period', 'Argument', 'ApiUser', 'dependency', 'Set'),
+ str_replace(
+ 'template',
+ '',
+ substr($this->getRequest()->getControllerName(), 0, -1)
+ )
+ );
+ }
+
+ /**
+ * @return string
+ */
+ protected function getPluralType()
+ {
+ return preg_replace('/cys$/', 'cies', $this->getType() . 's');
+ }
+
+ /**
+ * @return string
+ */
+ protected function getPluralBaseType()
+ {
+ return preg_replace('/cys$/', 'cies', $this->getBaseType() . 's');
+ }
+}
diff --git a/library/Director/Web/Controller/TemplateController.php b/library/Director/Web/Controller/TemplateController.php
new file mode 100644
index 0000000..c368a82
--- /dev/null
+++ b/library/Director/Web/Controller/TemplateController.php
@@ -0,0 +1,243 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Controller;
+
+use gipfl\Web\Widget\Hint;
+use Icinga\Module\Director\DirectorObject\Automation\ExportInterface;
+use Icinga\Module\Director\Exception\NestingError;
+use Icinga\Module\Director\Objects\IcingaCommand;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Icinga\Module\Director\Web\Controller\Extension\DirectorDb;
+use Icinga\Module\Director\Web\Table\ApplyRulesTable;
+use Icinga\Module\Director\Web\Table\ObjectsTable;
+use Icinga\Module\Director\Web\Table\TemplatesTable;
+use Icinga\Module\Director\Web\Table\TemplateUsageTable;
+use Icinga\Module\Director\Web\Tabs\ObjectTabs;
+use Icinga\Module\Director\Web\Widget\UnorderedList;
+use ipl\Html\FormattedString;
+use ipl\Html\Html;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\CompatController;
+
+abstract class TemplateController extends CompatController
+{
+ use DirectorDb;
+
+ /** @var IcingaObject */
+ protected $template;
+
+ public function objectsAction()
+ {
+ $template = $this->requireTemplate();
+ $plural = $this->getTranslatedPluralType();
+ $this
+ ->addSingleTab($plural)
+ ->setAutorefreshInterval(10)
+ ->addTitle(
+ $this->translate('%s based on %s'),
+ $plural,
+ $template->getObjectName()
+ )->addBackToUsageLink($template);
+
+ ObjectsTable::create($this->getType(), $this->db())
+ ->setAuth($this->Auth())
+ ->setBaseObjectUrl($this->getBaseObjectUrl())
+ ->filterTemplate($template, $this->getInheritance())
+ ->renderTo($this);
+ }
+
+ public function applyrulesAction()
+ {
+ $type = $this->getType();
+ $template = $this->requireTemplate();
+ $this
+ ->addSingleTab(sprintf($this->translate('Applied %s'), $this->getTranslatedPluralType()))
+ ->setAutorefreshInterval(10)
+ ->addTitle(
+ $this->translate('Notification Apply Rules based on %s'),
+ $template->getObjectName()
+ )->addBackToUsageLink($template);
+
+ ApplyRulesTable::create($type, $this->db())
+ ->setBaseObjectUrl($this->getBaseObjectUrl())
+ ->filterTemplate($template, $this->params->get('inheritance', 'direct'))
+ ->renderTo($this);
+ }
+
+ public function templatesAction()
+ {
+ $template = $this->requireTemplate();
+ $typeName = $this->getTranslatedType();
+ $this
+ ->addSingleTab(sprintf($this->translate('%s Templates'), $typeName))
+ ->setAutorefreshInterval(10)
+ ->addTitle(
+ $this->translate('%s templates based on %s'),
+ $typeName,
+ $template->getObjectName()
+ )->addBackToUsageLink($template);
+
+ TemplatesTable::create($this->getType(), $this->db())
+ ->filterTemplate($template, $this->getInheritance())
+ ->renderTo($this);
+ }
+
+ protected function getInheritance()
+ {
+ return $this->params->get('inheritance', 'direct');
+ }
+
+ protected function addBackToUsageLink(IcingaObject $template)
+ {
+ $type = $this->getType();
+ $this->actions()->add(
+ Link::create(
+ $this->translate('Back'),
+ "director/${type}template/usage",
+ ['name' => $template->getObjectName()],
+ ['class' => 'icon-left-big']
+ )
+ );
+
+ return $this;
+ }
+
+ public function usageAction()
+ {
+ $template = $this->requireTemplate();
+ if (! $template->isTemplate() && $template instanceof IcingaCommand) {
+ $this->redirectNow($this->url()->setPath('director/command'));
+ }
+ $templateName = $template->getObjectName();
+
+ $type = $this->getType();
+ $this->tabs(new ObjectTabs($type, $this->Auth(), $template))->activate('modify');
+ $this
+ ->addTitle($this->translate('Template: %s'), $templateName)
+ ->setAutorefreshInterval(10);
+
+ $this->actions()->add([
+ Link::create(
+ $this->translate('Modify'),
+ "director/$type/edit",
+ ['uuid' => $template->getUniqueId()->toString()],
+ ['class' => 'icon-edit']
+ )
+ ]);
+ if ($template instanceof ExportInterface) {
+ $this->actions()->add(Link::create(
+ $this->translate('Add to Basket'),
+ 'director/basket/add',
+ [
+ 'type' => ucfirst($this->getType()) . 'Template',
+ 'names' => $template->getUniqueIdentifier()
+ ],
+ ['class' => 'icon-tag']
+ ));
+ }
+
+ $list = new UnorderedList([], [
+ 'class' => 'vertical-action-list'
+ ]);
+
+ $auth = $this->Auth();
+
+ if ($type !== 'notification') {
+ $list->addItem(new FormattedString(
+ $this->translate('Create a new %s inheriting from this template'),
+ [Link::create(
+ $this->translate('Object'),
+ "director/$type/add",
+ ['imports' => $templateName, 'type' => 'object']
+ )]
+ ));
+ }
+ if ($auth->hasPermission('director/admin')) {
+ $list->addItem(new FormattedString(
+ $this->translate('Create a new %s inheriting from this one'),
+ [Link::create(
+ $this->translate('Template'),
+ "director/$type/add",
+ ['imports' => $templateName, 'type' => 'template']
+ )]
+ ));
+ }
+ if ($template->supportsApplyRules()) {
+ $list->addItem(new FormattedString(
+ $this->translate('Create a new %s inheriting from this template'),
+ [Link::create(
+ $this->translate('Apply Rule'),
+ "director/$type/add",
+ ['imports' => $templateName, 'type' => 'apply']
+ )]
+ ));
+ }
+
+ $typeName = $this->getTranslatedType();
+ $this->content()->add(Html::sprintf(
+ $this->translate(
+ 'This is the "%s" %s Template. Based on this, you might want to:'
+ ),
+ $typeName,
+ $templateName
+ ))->add(
+ $list
+ )->add(
+ Html::tag('h2', null, $this->translate('Current Template Usage'))
+ );
+
+ try {
+ $this->content()->add(
+ TemplateUsageTable::forTemplate($template)
+ );
+ } catch (NestingError $e) {
+ $this->content()->add(Hint::error($e->getMessage()));
+ }
+ }
+
+ protected function getType()
+ {
+ return $this->template()->getShortTableName();
+ }
+
+ protected function getPluralType()
+ {
+ return preg_replace(
+ '/cys$/',
+ 'cies',
+ $this->template()->getShortTableName() . 's'
+ );
+ }
+
+ protected function getTranslatedType()
+ {
+ return $this->translate(ucfirst($this->getType()));
+ }
+
+ protected function getTranslatedPluralType()
+ {
+ return $this->translate(ucfirst($this->getPluralType()));
+ }
+
+ protected function getBaseObjectUrl()
+ {
+ return $this->getType();
+ }
+
+ /**
+ * @return IcingaObject
+ */
+ protected function template()
+ {
+ if ($this->template === null) {
+ $this->template = $this->requireTemplate();
+ }
+
+ return $this->template;
+ }
+
+ /**
+ * @return IcingaObject
+ */
+ abstract protected function requireTemplate();
+}
diff --git a/library/Director/Web/Form/ClickHereForm.php b/library/Director/Web/Form/ClickHereForm.php
new file mode 100644
index 0000000..abba9d7
--- /dev/null
+++ b/library/Director/Web/Form/ClickHereForm.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form;
+
+use gipfl\Translation\TranslationHelper;
+use gipfl\Web\InlineForm;
+
+class ClickHereForm extends InlineForm
+{
+ use TranslationHelper;
+
+ protected $hasBeenClicked = false;
+
+ protected function assemble()
+ {
+ $this->addElement('submit', 'submit', [
+ 'label' => $this->translate('here'),
+ 'class' => 'link-button'
+ ]);
+ }
+
+ public function hasBeenClicked()
+ {
+ return $this->hasBeenClicked;
+ }
+
+ public function onSuccess()
+ {
+ $this->hasBeenClicked = true;
+ }
+}
diff --git a/library/Director/Web/Form/CloneImportSourceForm.php b/library/Director/Web/Form/CloneImportSourceForm.php
new file mode 100644
index 0000000..0849dd4
--- /dev/null
+++ b/library/Director/Web/Form/CloneImportSourceForm.php
@@ -0,0 +1,72 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form;
+
+use Icinga\Module\Director\Data\Exporter;
+use ipl\Html\Form;
+use ipl\Html\FormDecorator\DdDtDecorator;
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Url;
+use Icinga\Module\Director\Objects\ImportSource;
+
+class CloneImportSourceForm extends Form
+{
+ use TranslationHelper;
+
+ /** @var ImportSource */
+ protected $source;
+
+ /** @var ImportSource|null */
+ protected $newSource;
+
+ public function __construct(ImportSource $source)
+ {
+ $this->setDefaultElementDecorator(new DdDtDecorator());
+ $this->source = $source;
+ }
+
+ protected function assemble()
+ {
+ $this->addElement('text', 'source_name', [
+ 'label' => $this->translate('New name'),
+ 'value' => $this->source->get('source_name'),
+ ]);
+ $this->addElement('submit', 'submit', [
+ 'label' => $this->translate('Clone')
+ ]);
+ }
+
+ /**
+ * @return \Icinga\Module\Director\Db
+ */
+ protected function getTargetDb()
+ {
+ return $this->source->getConnection();
+ }
+
+ /**
+ * @throws \Icinga\Module\Director\Exception\DuplicateKeyException
+ */
+ public function onSuccess()
+ {
+ $db = $this->getTargetDb();
+ $export = (new Exporter($db))->export($this->source);
+ $newName = $this->getElement('source_name')->getValue();
+ $export->source_name = $newName;
+ unset($export->originalId);
+ if (ImportSource::existsWithName($newName, $db)) {
+ $this->getElement('source_name')->addMessage('Name already exists');
+ }
+ $this->newSource = ImportSource::import($export, $db);
+ $this->newSource->store();
+ }
+
+ public function getSuccessUrl()
+ {
+ if ($this->newSource === null) {
+ return parent::getSuccessUrl();
+ } else {
+ return Url::fromPath('director/importsource', ['id' => $this->newSource->get('id')]);
+ }
+ }
+}
diff --git a/library/Director/Web/Form/CloneSyncRuleForm.php b/library/Director/Web/Form/CloneSyncRuleForm.php
new file mode 100644
index 0000000..f90b593
--- /dev/null
+++ b/library/Director/Web/Form/CloneSyncRuleForm.php
@@ -0,0 +1,76 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form;
+
+use Icinga\Module\Director\Data\Exporter;
+use ipl\Html\Form;
+use ipl\Html\FormDecorator\DdDtDecorator;
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Url;
+use Icinga\Module\Director\Objects\SyncRule;
+
+class CloneSyncRuleForm extends Form
+{
+ use TranslationHelper;
+
+ /** @var SyncRule */
+ protected $rule;
+
+ /** @var SyncRule|null */
+ protected $newRule;
+
+ public function __construct(SyncRule $rule)
+ {
+ $this->setDefaultElementDecorator(new DdDtDecorator());
+ $this->rule = $rule;
+ }
+
+ protected function assemble()
+ {
+ $this->addElement('text', 'rule_name', [
+ 'label' => $this->translate('New name'),
+ 'value' => $this->rule->get('rule_name'),
+ ]);
+ $this->addElement('submit', 'submit', [
+ 'label' => $this->translate('Clone')
+ ]);
+ }
+
+ /**
+ * @return \Icinga\Module\Director\Db
+ */
+ protected function getTargetDb()
+ {
+ return $this->rule->getConnection();
+ }
+
+ /**
+ * @throws \Icinga\Exception\NotFoundError
+ * @throws \Icinga\Module\Director\Exception\DuplicateKeyException
+ */
+ public function onSuccess()
+ {
+ $db = $this->getTargetDb();
+ $exporter = new Exporter($db);
+
+ $export = $exporter->export($this->rule);
+ $newName = $this->getValue('rule_name');
+ $export->rule_name = $newName;
+ unset($export->originalId);
+
+ if (SyncRule::existsWithName($newName, $db)) {
+ $this->getElement('rule_name')->addMessage('Name already exists');
+ }
+ $this->newRule = SyncRule::import($export, $db);
+ $this->newRule->store();
+ }
+
+ public function getSuccessUrl()
+ {
+ if ($this->newRule === null) {
+ return parent::getSuccessUrl();
+ } else {
+ return Url::fromPath('director/syncrule', ['id' => $this->newRule->get('id')]);
+ }
+ }
+}
diff --git a/library/Director/Web/Form/CsrfToken.php b/library/Director/Web/Form/CsrfToken.php
new file mode 100644
index 0000000..24edf88
--- /dev/null
+++ b/library/Director/Web/Form/CsrfToken.php
@@ -0,0 +1,53 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form;
+
+class CsrfToken
+{
+ /**
+ * Check whether the given token is valid
+ *
+ * @param string $token Token
+ *
+ * @return bool
+ */
+ public static function isValid($token)
+ {
+ if (strpos($token, '|') === false) {
+ return false;
+ }
+
+ list($seed, $token) = explode('|', $elementValue);
+
+ if (!is_numeric($seed)) {
+ return false;
+ }
+
+ return $token === hash('sha256', self::getSessionId() . $seed);
+ }
+
+ /**
+ * Create a new token
+ *
+ * @return string
+ */
+ public static function generate()
+ {
+ $seed = mt_rand();
+ $token = hash('sha256', self::getSessionId() . $seed);
+
+ return sprintf('%s|%s', $seed, $token);
+ }
+
+ /**
+ * Get current session id
+ *
+ * TODO: we should do this through our App or Session object
+ *
+ * @return string
+ */
+ protected static function getSessionId()
+ {
+ return session_id();
+ }
+}
diff --git a/library/Director/Web/Form/DbSelectorForm.php b/library/Director/Web/Form/DbSelectorForm.php
new file mode 100644
index 0000000..52fe5ea
--- /dev/null
+++ b/library/Director/Web/Form/DbSelectorForm.php
@@ -0,0 +1,85 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form;
+
+use gipfl\IcingaWeb2\Url;
+use Icinga\Web\Response;
+use ipl\Html\Form;
+use Icinga\Web\Window;
+
+class DbSelectorForm extends Form
+{
+ protected $defaultAttributes = [
+ 'class' => 'db-selector'
+ ];
+
+ protected $allowedNames;
+
+ /** @var Window */
+ protected $window;
+
+ protected $response;
+
+ public function __construct(Response $response, Window $window, $allowedNames)
+ {
+ $this->response = $response;
+ $this->window = $window;
+ $this->allowedNames = $allowedNames;
+ }
+
+ protected function assemble()
+ {
+ $this->addElement('hidden', 'DbSelector', [
+ 'value' => 'sent'
+ ]);
+ $this->addElement('select', 'db_resource', [
+ 'options' => $this->allowedNames,
+ 'class' => 'autosubmit',
+ 'value' => $this->getSession()->get('db_resource')
+ ]);
+ }
+
+ /**
+ * A base class should handle this, based on hidden fields
+ *
+ * @return bool
+ */
+ public function hasBeenSubmitted()
+ {
+ return $this->hasBeenSent() && $this->getRequestParam('DbSelector') === 'sent';
+ }
+
+ public function onSuccess()
+ {
+ $this->getSession()->set('db_resource', $this->getElement('db_resource')->getValue());
+ $this->response->redirectAndExit(Url::fromRequest($this->getRequest()));
+ }
+
+ protected function getRequestParam($name, $default = null)
+ {
+ $request = $this->getRequest();
+ if ($request === null) {
+ return $default;
+ }
+ if ($request->getMethod() === 'POST') {
+ $params = $request->getParsedBody();
+ } elseif ($this->getMethod() === 'GET') {
+ parse_str($request->getUri()->getQuery(), $params);
+ } else {
+ $params = [];
+ }
+
+ if (array_key_exists($name, $params)) {
+ return $params[$name];
+ }
+
+ return $default;
+ }
+ /**
+ * @return \Icinga\Web\Session\SessionNamespace
+ */
+ protected function getSession()
+ {
+ return $this->window->getSessionNamespace('director');
+ }
+}
diff --git a/library/Director/Web/Form/Decorator/ViewHelperRaw.php b/library/Director/Web/Form/Decorator/ViewHelperRaw.php
new file mode 100644
index 0000000..a3aefbf
--- /dev/null
+++ b/library/Director/Web/Form/Decorator/ViewHelperRaw.php
@@ -0,0 +1,14 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form\Decorator;
+
+use Zend_Form_Decorator_ViewHelper as ViewHelper;
+use Zend_Form_Element as Element;
+
+class ViewHelperRaw extends ViewHelper
+{
+ public function getValue($element)
+ {
+ return $element->getUnfilteredValue();
+ }
+}
diff --git a/library/Director/Web/Form/DirectorForm.php b/library/Director/Web/Form/DirectorForm.php
new file mode 100644
index 0000000..145be5b
--- /dev/null
+++ b/library/Director/Web/Form/DirectorForm.php
@@ -0,0 +1,58 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form;
+
+use Icinga\Application\Icinga;
+use Icinga\Module\Director\Db;
+
+abstract class DirectorForm extends QuickForm
+{
+ /** @var Db */
+ protected $db;
+
+ /**
+ * @param Db $db
+ * @return $this
+ */
+ public function setDb(Db $db)
+ {
+ $this->db = $db;
+ return $this;
+ }
+
+ /**
+ * @return Db
+ */
+ public function getDb()
+ {
+ return $this->db;
+ }
+
+ /**
+ * @return static
+ */
+ public static function load()
+ {
+ return new static([
+ 'icingaModule' => Icinga::App()->getModuleManager()->getModule('director')
+ ]);
+ }
+
+ public function addBoolean($key, $options, $default = null)
+ {
+ if ($default === null) {
+ return $this->addElement('OptionalYesNo', $key, $options);
+ } else {
+ $this->addElement('YesNo', $key, $options);
+ return $this->getElement($key)->setValue($default);
+ }
+ }
+
+ protected function optionalBoolean($key, $label, $description)
+ {
+ return $this->addBoolean($key, array(
+ 'label' => $label,
+ 'description' => $description
+ ));
+ }
+}
diff --git a/library/Director/Web/Form/DirectorObjectForm.php b/library/Director/Web/Form/DirectorObjectForm.php
new file mode 100644
index 0000000..b70bd7b
--- /dev/null
+++ b/library/Director/Web/Form/DirectorObjectForm.php
@@ -0,0 +1,1734 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form;
+
+use Exception;
+use gipfl\IcingaWeb2\Url;
+use Icinga\Authentication\Auth;
+use Icinga\Module\Director\Data\Db\DbObjectStore;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Data\Db\DbObject;
+use Icinga\Module\Director\Data\Db\DbObjectWithSettings;
+use Icinga\Module\Director\Db\Branch\Branch;
+use Icinga\Module\Director\Exception\NestingError;
+use Icinga\Module\Director\Hook\IcingaObjectFormHook;
+use Icinga\Module\Director\IcingaConfig\StateFilterSet;
+use Icinga\Module\Director\IcingaConfig\TypeFilterSet;
+use Icinga\Module\Director\Objects\IcingaTemplateChoice;
+use Icinga\Module\Director\Objects\IcingaCommand;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Icinga\Module\Director\Util;
+use Icinga\Module\Director\Web\Form\Element\ExtensibleSet;
+use Icinga\Module\Director\Web\Form\Validate\NamePattern;
+use Zend_Form_Element as ZfElement;
+use Zend_Form_Element_Select as ZfSelect;
+
+abstract class DirectorObjectForm extends DirectorForm
+{
+ const GROUP_ORDER_OBJECT_DEFINITION = 20;
+ const GROUP_ORDER_RELATED_OBJECTS = 25;
+ const GROUP_ORDER_ASSIGN = 30;
+ const GROUP_ORDER_CHECK_EXECUTION = 40;
+ const GROUP_ORDER_CUSTOM_FIELDS = 50;
+ const GROUP_ORDER_CUSTOM_FIELD_CATEGORIES = 60;
+ const GROUP_ORDER_EVENT_FILTERS = 700;
+ const GROUP_ORDER_EXTRA_INFO = 750;
+ const GROUP_ORDER_CLUSTERING = 800;
+ const GROUP_ORDER_BUTTONS = 1000;
+
+ /** @var IcingaObject */
+ protected $object;
+
+ /** @var Branch */
+ protected $branch;
+
+ protected $objectName;
+
+ protected $className;
+
+ protected $deleteButtonName;
+
+ protected $displayGroups = [];
+
+ protected $resolvedImports;
+
+ protected $listUrl;
+
+ /** @var Auth */
+ private $auth;
+
+ private $choiceElements = [];
+
+ protected $preferredObjectType;
+
+ /** @var IcingaObjectFieldLoader */
+ protected $fieldLoader;
+
+ private $allowsExperimental;
+
+ private $presetImports;
+
+ private $earlyProperties = array(
+ // 'imports',
+ 'check_command',
+ 'check_command_id',
+ 'has_agent',
+ 'command',
+ 'command_id',
+ 'event_command',
+ 'event_command_id',
+ );
+
+ public function setPreferredObjectType($type)
+ {
+ $this->preferredObjectType = $type;
+ return $this;
+ }
+
+ public function setAuth(Auth $auth)
+ {
+ $this->auth = $auth;
+ return $this;
+ }
+
+ public function getAuth()
+ {
+ if ($this->auth === null) {
+ $this->auth = Auth::getInstance();
+ }
+ return $this->auth;
+ }
+
+ protected function eventuallyAddNameRestriction($restrictionName)
+ {
+ $restrictions = $this->getAuth()->getRestrictions($restrictionName);
+ if (! empty($restrictions)) {
+ $this->getElement('object_name')->addValidator(
+ new NamePattern($restrictions)
+ );
+ }
+
+ return $this;
+ }
+
+ public function presetImports($imports)
+ {
+ if (! empty($imports)) {
+ if (is_array($imports)) {
+ $this->presetImports = $imports;
+ } else {
+ $this->presetImports = array($imports);
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * @return DbObject|DbObjectWithSettings|IcingaObject
+ */
+ protected function object()
+ {
+ if ($this->object === null) {
+ $values = $this->getValues();
+ /** @var DbObject|IcingaObject $class */
+ $class = $this->getObjectClassname();
+ if ($this->preferredObjectType) {
+ $values['object_type'] = $this->preferredObjectType;
+ }
+ if ($this->presetImports) {
+ $values['imports'] = $this->presetImports;
+ }
+
+ $this->object = $class::create($values, $this->db);
+ } else {
+ if (! $this->object->hasConnection()) {
+ $this->object->setConnection($this->db);
+ }
+ }
+
+ return $this->object;
+ }
+
+ protected function extractChoicesFromPost($post)
+ {
+ $imports = [];
+
+ foreach ($this->choiceElements as $other) {
+ $name = $other->getName();
+ if (array_key_exists($name, $post)) {
+ $value = $post[$name];
+ if (is_string($value)) {
+ $imports[] = $value;
+ } elseif (is_array($value)) {
+ foreach ($value as $chosen) {
+ $imports[] = $chosen;
+ }
+ }
+ }
+ }
+
+ return $imports;
+ }
+
+ protected function assertResolvedImports()
+ {
+ if ($this->resolvedImports !== null) {
+ return $this->resolvedImports;
+ }
+
+ $object = $this->object;
+
+ if (! $object instanceof IcingaObject) {
+ return $this->setResolvedImports(false);
+ }
+ if (! $object->supportsImports()) {
+ return $this->setResolvedImports(false);
+ }
+
+ if ($this->hasBeenSent()) {
+ // prefill special properties, required to resolve fields and similar
+ $post = $this->getRequest()->getPost();
+
+ $key = 'imports';
+ if ($el = $this->getElement($key)) {
+ if (array_key_exists($key, $post)) {
+ $imports = $post[$key];
+ if (! is_array($imports)) {
+ $imports = array($imports);
+ }
+ $imports = array_filter(array_values(array_merge(
+ $imports,
+ $this->extractChoicesFromPost($post)
+ )), 'strlen');
+
+ /** @var ZfElement $el */
+ $this->populate([$key => $imports]);
+ $el->setValue($imports);
+ if (! $this->tryToSetObjectPropertyFromElement($object, $el, $key)) {
+ return $this->resolvedImports = false;
+ }
+ }
+ } elseif ($this->presetImports) {
+ $imports = array_values(array_merge(
+ $this->presetImports,
+ $this->extractChoicesFromPost($post)
+ ));
+ if (! $this->eventuallySetImports($imports)) {
+ return $this->resolvedImports = false;
+ }
+ } else {
+ if (! empty($this->choiceElements)) {
+ if (! $this->eventuallySetImports($this->extractChoicesFromPost($post))) {
+ return $this->resolvedImports = false;
+ }
+ }
+ }
+
+ foreach ($this->earlyProperties as $key) {
+ if ($el = $this->getElement($key)) {
+ if (array_key_exists($key, $post)) {
+ $this->populate([$key => $post[$key]]);
+ $this->tryToSetObjectPropertyFromElement($object, $el, $key);
+ }
+ }
+ }
+ }
+
+ try {
+ $object->listAncestorIds();
+ } catch (NestingError $e) {
+ $this->addUniqueErrorMessage($e->getMessage());
+ return $this->resolvedImports = false;
+ } catch (Exception $e) {
+ $this->addException($e, 'imports');
+ return $this->resolvedImports = false;
+ }
+
+ return $this->setResolvedImports();
+ }
+
+ protected function eventuallySetImports($imports)
+ {
+ try {
+ $this->object()->set('imports', $imports);
+ return true;
+ } catch (Exception $e) {
+ $this->addException($e, 'imports');
+ return false;
+ }
+ }
+
+ protected function tryToSetObjectPropertyFromElement(
+ IcingaObject $object,
+ ZfElement $element,
+ $key
+ ) {
+ $old = null;
+ try {
+ $old = $object->get($key);
+ $object->set($key, $element->getValue());
+ $object->resolveUnresolvedRelatedProperties();
+
+ if ($key === 'imports') {
+ $object->imports()->getObjects();
+ }
+ return true;
+ } catch (Exception $e) {
+ if ($old !== null) {
+ $object->set($key, $old);
+ }
+ $this->addException($e, $key);
+ return false;
+ }
+ }
+
+ public function setResolvedImports($resolved = true)
+ {
+ return $this->resolvedImports = $resolved;
+ }
+
+ public function isObject()
+ {
+ return $this->getSentOrObjectValue('object_type') === 'object';
+ }
+
+ public function isTemplate()
+ {
+ return $this->getSentOrObjectValue('object_type') === 'template';
+ }
+
+ // TODO: move to a subform
+ protected function handleRanges(IcingaObject $object, &$values)
+ {
+ if (! $object->supportsRanges()) {
+ return;
+ }
+
+ $key = 'ranges';
+ $object = $this->object();
+
+ /* Sample:
+
+ array(
+ 'monday' => 'eins',
+ 'tuesday' => '00:00-24:00',
+ 'sunday' => 'zwei',
+ );
+
+ */
+ if (array_key_exists($key, $values)) {
+ $object->ranges()->set($values[$key]);
+ unset($values[$key]);
+ }
+
+ foreach ($object->ranges()->getRanges() as $key => $value) {
+ $this->addRange($key, $value);
+ }
+ }
+
+ protected function addToCheckExecutionDisplayGroup($elements)
+ {
+ return $this->addElementsToGroup(
+ $elements,
+ 'check_execution',
+ self::GROUP_ORDER_CHECK_EXECUTION,
+ $this->translate('Check execution')
+ );
+ }
+
+ public function addElementsToGroup($elements, $group, $order, $legend = null)
+ {
+ if (! is_array($elements)) {
+ $elements = array($elements);
+ }
+
+ // These are optional elements, they might exist or not. We still want
+ // to see exception for other ones
+ $skipLegally = array('check_period_id');
+
+ $skip = array();
+ foreach ($elements as $k => $v) {
+ if (is_string($v)) {
+ $el = $this->getElement($v);
+ if (!$el && in_array($v, $skipLegally)) {
+ $skip[] = $k;
+ continue;
+ }
+
+ $elements[$k] = $el;
+ }
+ }
+
+ foreach ($skip as $k) {
+ unset($elements[$k]);
+ }
+
+ if (! array_key_exists($group, $this->displayGroups)) {
+ $this->addDisplayGroup($elements, $group, array(
+ 'decorators' => array(
+ 'FormElements',
+ array('HtmlTag', array('tag' => 'dl')),
+ 'Fieldset',
+ ),
+ 'order' => $order,
+ 'legend' => $legend ?: $group,
+ ));
+ $this->displayGroups[$group] = $this->getDisplayGroup($group);
+ } else {
+ $this->displayGroups[$group]->addElements($elements);
+ }
+
+ return $this->displayGroups[$group];
+ }
+
+ protected function handleProperties(DbObject $object, &$values)
+ {
+ if ($this->hasBeenSent()) {
+ foreach ($values as $key => $value) {
+ try {
+ if ($key === 'imports' && ! empty($this->choiceElements)) {
+ if (! is_array($value)) {
+ $value = [$value];
+ }
+ foreach ($this->choiceElements as $element) {
+ $chosen = $element->getValue();
+ if (is_string($chosen)) {
+ $value[] = $chosen;
+ } elseif (is_array($chosen)) {
+ foreach ($chosen as $choice) {
+ $value[] = $choice;
+ }
+ }
+ }
+ }
+ $object->set($key, $value);
+ if ($object instanceof IcingaObject) {
+ if ($this->resolvedImports !== false) {
+ $object->imports()->getObjects();
+ }
+ }
+ } catch (Exception $e) {
+ $this->addException($e, $key);
+ }
+ }
+ }
+ }
+
+ protected function loadInheritedProperties()
+ {
+ if ($this->assertResolvedImports()) {
+ try {
+ $this->showInheritedProperties($this->object());
+ } catch (Exception $e) {
+ $this->addException($e);
+ }
+ }
+ }
+
+ protected function showInheritedProperties(IcingaObject $object)
+ {
+ $inherited = $object->getInheritedProperties();
+ $origins = $object->getOriginsProperties();
+
+ foreach ($inherited as $k => $v) {
+ if ($v !== null && $k !== 'object_name') {
+ $el = $this->getElement($k);
+ if ($el) {
+ $this->setInheritedValue($el, $inherited->$k, $origins->$k);
+ } elseif (substr($k, -3) === '_id') {
+ $k = substr($k, 0, -3);
+ $el = $this->getElement($k);
+ if ($el) {
+ $this->setInheritedValue(
+ $el,
+ $object->getRelatedObjectName($k, $v),
+ $origins->{"${k}_id"}
+ );
+ }
+ }
+ }
+ }
+ }
+
+ protected function prepareFields($object)
+ {
+ if ($this->assertResolvedImports()) {
+ $this->fieldLoader = new IcingaObjectFieldLoader($object);
+ $this->fieldLoader->prepareElements($this);
+ }
+
+ return $this;
+ }
+
+ protected function setCustomVarValues($values)
+ {
+ if ($this->fieldLoader) {
+ $this->fieldLoader->setValues($values, 'var_');
+ }
+
+ return $this;
+ }
+
+ protected function addFields()
+ {
+ if ($this->fieldLoader) {
+ $this->fieldLoader->addFieldsToForm($this);
+ $this->onAddedFields();
+ }
+ }
+
+ protected function onAddedFields()
+ {
+ }
+
+ // TODO: remove, used in sets I guess
+ protected function fieldLoader($object)
+ {
+ if ($this->fieldLoader === null) {
+ $this->fieldLoader = new IcingaObjectFieldLoader($object);
+ }
+
+ return $this->fieldLoader;
+ }
+
+ protected function isNew()
+ {
+ return $this->object === null || ! $this->object->hasBeenLoadedFromDb();
+ }
+
+ protected function setButtons()
+ {
+ if ($this->isNew()) {
+ $this->setSubmitLabel(
+ $this->translate('Add')
+ );
+ } else {
+ $this->setSubmitLabel(
+ $this->translate('Store')
+ );
+ $this->addDeleteButton();
+ }
+ }
+
+ /**
+ * @param bool $importsFirst
+ * @return $this
+ */
+ protected function groupMainProperties($importsFirst = false)
+ {
+ if ($importsFirst) {
+ $elements = [
+ 'imports',
+ 'object_type',
+ 'object_name',
+ ];
+ } else {
+ $elements = [
+ 'object_type',
+ 'object_name',
+ 'imports',
+ ];
+ }
+ $elements = array_merge($elements, [
+ 'display_name',
+ 'host',
+ 'host_id',
+ 'address',
+ 'address6',
+ 'groups',
+ 'inherited_groups',
+ 'applied_groups',
+ 'users',
+ 'user_groups',
+ 'apply_to',
+ 'command_id', // Notification
+ 'notification_interval',
+ 'period_id',
+ 'times_begin',
+ 'times_end',
+ 'email',
+ 'pager',
+ 'enable_notifications',
+ 'disable_checks', //Dependencies
+ 'disable_notifications',
+ 'ignore_soft_states',
+ 'apply_for',
+ 'create_live',
+ 'disabled',
+ ]);
+
+ // Add template choices to the main section
+ /** @var \Zend_Form_Element $el */
+ foreach ($this->getElements() as $key => $el) {
+ if (substr($el->getName(), 0, 6) === 'choice') {
+ $elements[] = $key;
+ }
+ }
+
+ $this->addDisplayGroup($elements, 'object_definition', array(
+ 'decorators' => array(
+ 'FormElements',
+ array('HtmlTag', array('tag' => 'dl')),
+ 'Fieldset',
+ ),
+ 'order' => self::GROUP_ORDER_OBJECT_DEFINITION,
+ 'legend' => $this->translate('Main properties')
+ ));
+
+ return $this;
+ }
+
+ protected function setSentValue($name, $value)
+ {
+ if ($this->hasBeenSent()) {
+ $request = $this->getRequest();
+ if ($value !== null && $request->isPost() && $request->getPost($name) !== null) {
+ $request->setPost($name, $value);
+ }
+ }
+
+ $this->setElementValue($name, $value);
+ }
+
+ public function setElementValue($name, $value = null)
+ {
+ $el = $this->getElement($name);
+ if (! $el) {
+ // Not showing an error, as most object properties do not exist. Not
+ // yet, because IMO this should be checked.
+ // $this->addError(sprintf($this->translate('Form element "%s" does not exist'), $name));
+ return;
+ }
+
+ if ($value !== null) {
+ $el->setValue($value);
+ }
+ }
+
+ public function setInheritedValue(ZfElement $el, $inherited, $inheritedFrom)
+ {
+ if ($inherited === null) {
+ return;
+ }
+
+ $txtInherited = sprintf($this->translate(' (inherited from "%s")'), $inheritedFrom);
+ if ($el instanceof ZfSelect) {
+ $multi = $el->getMultiOptions();
+ if (is_bool($inherited)) {
+ $inherited = $inherited ? 'y' : 'n';
+ }
+ if (is_scalar($inherited) && array_key_exists($inherited, $multi)) {
+ $multi[null] = $multi[$inherited] . $txtInherited;
+ } else {
+ $multi[null] = $this->stringifyInheritedValue($inherited) . $txtInherited;
+ }
+ $el->setMultiOptions($multi);
+ } elseif ($el instanceof ExtensibleSet) {
+ $el->setAttrib('inherited', $inherited);
+ $el->setAttrib('inheritedFrom', $inheritedFrom);
+ } else {
+ if (is_string($inherited) || is_int($inherited)) {
+ $el->setAttrib('placeholder', $inherited . $txtInherited);
+ }
+ }
+
+ // We inherited a value, so no need to require the field
+ $el->setRequired(false);
+ }
+
+ protected function stringifyInheritedValue($value)
+ {
+ return is_scalar($value) ? $value : substr(json_encode($value), 0, 40);
+ }
+
+ public function setListUrl($url)
+ {
+ $this->listUrl = $url;
+ return $this;
+ }
+
+ public function onSuccess()
+ {
+ $object = $this->object();
+ if ($object->hasBeenModified()) {
+ if (! $object->hasBeenLoadedFromDb()) {
+ $this->setHttpResponseCode(201);
+ }
+
+ $msg = sprintf(
+ $object->hasBeenLoadedFromDb()
+ ? $this->translate('The %s has successfully been stored')
+ : $this->translate('A new %s has successfully been created'),
+ $this->translate($this->getObjectShortClassName())
+ );
+ $this->getDbObjectStore()->store($object);
+ } else {
+ if ($this->isApiRequest()) {
+ $this->setHttpResponseCode(304);
+ }
+ $msg = $this->translate('No action taken, object has not been modified');
+ }
+
+ $this->setObjectSuccessUrl();
+ $this->beforeSuccessfulRedirect();
+ $this->redirectOnSuccess($msg);
+ }
+
+ protected function setObjectSuccessUrl()
+ {
+ $object = $this->object();
+
+ if ($object instanceof IcingaObject) {
+ $params = $object->getUrlParams();
+ $url = Url::fromPath($this->getAction());
+ if ($url->hasParam('dbResourceName')) {
+ $params['dbResourceName'] = $url->getParam('dbResourceName');
+ }
+ $this->setSuccessUrl(
+ 'director/' . strtolower($this->getObjectShortClassName()),
+ $params
+ );
+ } elseif ($object->hasProperty('id')) {
+ $this->setSuccessUrl($this->getSuccessUrl()->with('id', $object->getProperty('id')));
+ }
+ }
+
+ protected function beforeSuccessfulRedirect()
+ {
+ }
+
+ public function hasElement($name)
+ {
+ return $this->getElement($name) !== null;
+ }
+
+ public function getObject()
+ {
+ return $this->object;
+ }
+
+ public function hasObject()
+ {
+ return $this->object !== null;
+ }
+
+ public function isIcingaObject()
+ {
+ if ($this->object !== null) {
+ return $this->object instanceof IcingaObject;
+ }
+
+ /** @var DbObject $class */
+ $class = $this->getObjectClassname();
+ $instance = $class::create();
+
+ return $instance instanceof IcingaObject;
+ }
+
+ public function isMultiObjectForm()
+ {
+ return false;
+ }
+
+ public function setObject(DbObject $object)
+ {
+ $this->object = $object;
+ if ($this->db === null) {
+ /** @var Db $connection */
+ $connection = $object->getConnection();
+ $this->setDb($connection);
+ }
+
+ return $this;
+ }
+
+ protected function getObjectClassname()
+ {
+ if ($this->className === null) {
+ return 'Icinga\\Module\\Director\\Objects\\'
+ . substr(join('', array_slice(explode('\\', get_class($this)), -1)), 0, -4);
+ }
+
+ return $this->className;
+ }
+
+ protected function getObjectShortClassName()
+ {
+ if ($this->objectName === null) {
+ $className = substr(strrchr(get_class($this), '\\'), 1);
+ if (substr($className, 0, 6) === 'Icinga') {
+ return substr($className, 6, -4);
+ } else {
+ return substr($className, 0, -4);
+ }
+ }
+
+ return $this->objectName;
+ }
+
+ protected function removeFromSet(&$set, $key)
+ {
+ unset($set[$key]);
+ }
+
+ protected function moveUpInSet(&$set, $key)
+ {
+ list($set[$key - 1], $set[$key]) = array($set[$key], $set[$key - 1]);
+ }
+
+ protected function moveDownInSet(&$set, $key)
+ {
+ list($set[$key + 1], $set[$key]) = array($set[$key], $set[$key + 1]);
+ }
+
+ protected function beforeSetup()
+ {
+ if (!$this->hasBeenSent()) {
+ return;
+ }
+
+ $post = $values = $this->getRequest()->getPost();
+
+ foreach ($post as $key => $value) {
+ if (preg_match('/^(.+?)_(\d+)__(MOVE_DOWN|MOVE_UP|REMOVE)$/', $key, $m)) {
+ $values[$m[1]] = array_filter($values[$m[1]], 'strlen');
+ switch ($m[3]) {
+ case 'MOVE_UP':
+ $this->moveUpInSet($values[$m[1]], $m[2]);
+ break;
+ case 'MOVE_DOWN':
+ $this->moveDownInSet($values[$m[1]], $m[2]);
+ break;
+ case 'REMOVE':
+ $this->removeFromSet($values[$m[1]], $m[2]);
+ break;
+ }
+
+ $this->getRequest()->setPost($m[1], $values[$m[1]]);
+ }
+ }
+ }
+
+ protected function onRequest()
+ {
+ if ($this->object !== null) {
+ $this->setDefaultsFromObject($this->object);
+ }
+ $this->prepareFields($this->object());
+ IcingaObjectFormHook::callOnSetup($this);
+ if ($this->hasBeenSent()) {
+ $this->handlePost();
+ }
+ try {
+ $this->loadInheritedProperties();
+ $this->addFields();
+ $this->callOnRequestCallables();
+ } catch (Exception $e) {
+ $this->addUniqueException($e);
+
+ return;
+ }
+
+ if ($this->shouldBeDeleted()) {
+ $this->deleteObject($this->object());
+ }
+ }
+
+ protected function handlePost()
+ {
+ $object = $this->object();
+
+ $post = $this->getRequest()->getPost();
+ $this->populate($post);
+ $values = $this->getValues();
+
+ if ($object instanceof IcingaObject) {
+ $this->setCustomVarValues($post);
+ }
+
+ $this->handleProperties($object, $values);
+
+ // TODO: get rid of this
+ if ($object instanceof IcingaObject) {
+ $this->handleRanges($object, $values);
+ }
+ }
+
+ protected function setDefaultsFromObject(DbObject $object)
+ {
+ /** @var ZfElement $element */
+ foreach ($this->getElements() as $element) {
+ $key = $element->getName();
+ if ($object->hasProperty($key)) {
+ $value = $object->get($key);
+ if ($object instanceof IcingaObject) {
+ if ($object->propertyIsRelatedSet($key)) {
+ if (! count((array) $value)) {
+ continue;
+ }
+ }
+ }
+
+ if ($value !== null && $value !== []) {
+ $element->setValue($value);
+ }
+ }
+ }
+ }
+
+ protected function deleteObject($object)
+ {
+ if ($object instanceof IcingaObject && $object->hasProperty('object_name')) {
+ $msg = sprintf(
+ '%s "%s" has been removed',
+ $this->translate($this->getObjectShortClassName()),
+ $object->getObjectName()
+ );
+ } else {
+ $msg = sprintf(
+ '%s has been removed',
+ $this->translate($this->getObjectShortClassName())
+ );
+ }
+
+ if ($this->listUrl) {
+ $url = $this->listUrl;
+ } elseif ($object instanceof IcingaObject && $object->hasProperty('object_name')) {
+ $url = $object->getOnDeleteUrl();
+ } else {
+ $url = $this->getSuccessUrl()->without(
+ array('field_id', 'argument_id', 'range', 'range_type')
+ );
+ }
+
+ if ($this->getDbObjectStore()->delete($object)) {
+ $this->setSuccessUrl($url);
+ }
+ $this->redirectOnSuccess($msg);
+ }
+
+ /**
+ * @return DbObjectStore
+ */
+ protected function getDbObjectStore()
+ {
+ $store = new DbObjectStore($this->getDb(), $this->branch);
+ return $store;
+ }
+
+ protected function addDeleteButton($label = null)
+ {
+ $object = $this->object;
+
+ if ($label === null) {
+ $label = $this->translate('Delete');
+ }
+
+ $el = $this->createElement('submit', $label)
+ ->setLabel($label)
+ ->setDecorators(array('ViewHelper'));
+ //->removeDecorator('Label');
+
+ $this->deleteButtonName = $el->getName();
+
+ if ($object instanceof IcingaObject && $object->isTemplate()) {
+ if ($cnt = $object->countDirectDescendants()) {
+ $el->setAttrib('disabled', 'disabled');
+ $el->setAttrib(
+ 'title',
+ sprintf(
+ $this->translate('This template is still in use by %d other objects'),
+ $cnt
+ )
+ );
+ }
+ } elseif ($object instanceof IcingaCommand && $object->isInUse()) {
+ $el->setAttrib('disabled', 'disabled');
+ $el->setAttrib(
+ 'title',
+ sprintf(
+ $this->translate('This Command is still in use by %d other objects'),
+ $object->countDirectUses()
+ )
+ );
+ }
+
+ $this->addElement($el);
+
+ return $this;
+ }
+
+ public function hasDeleteButton()
+ {
+ return $this->deleteButtonName !== null;
+ }
+
+ public function shouldBeDeleted()
+ {
+ if (! $this->hasDeleteButton()) {
+ return false;
+ }
+
+ $name = $this->deleteButtonName;
+ return $this->getSentValue($name) === $this->getElement($name)->getLabel();
+ }
+
+ public function abortDeletion()
+ {
+ if ($this->hasDeleteButton()) {
+ $this->setSentValue($this->deleteButtonName, 'ABORTED');
+ }
+ }
+
+ public function getSentOrResolvedObjectValue($name, $default = null)
+ {
+ return $this->getSentOrObjectValue($name, $default, true);
+ }
+
+ public function getSentOrObjectValue($name, $default = null, $resolved = false)
+ {
+ // TODO: check whether getSentValue is still needed since element->getValue
+ // is in place (currently for form element default values only)
+
+ if (!$this->hasObject()) {
+ if ($this->hasBeenSent()) {
+ return $this->getSentValue($name, $default);
+ } else {
+ if ($name === 'object_type' && $this->preferredObjectType) {
+ return $this->preferredObjectType;
+ }
+ if ($name === 'imports' && $this->presetImports) {
+ return $this->presetImports;
+ }
+ if ($this->valueIsEmpty($val = $this->getValue($name))) {
+ return $default;
+ } else {
+ return $val;
+ }
+ }
+ }
+
+ if ($this->hasBeenSent()) {
+ if (!$this->valueIsEmpty($value = $this->getSentValue($name))) {
+ return $value;
+ }
+ }
+
+ $object = $this->getObject();
+
+ if ($object->hasProperty($name)) {
+ if ($resolved && $object->supportsImports()) {
+ if ($this->assertResolvedImports()) {
+ $objectProperty = $object->getResolvedProperty($name);
+ } else {
+ $objectProperty = $object->$name;
+ }
+ } else {
+ $objectProperty = $object->$name;
+ }
+ } else {
+ $objectProperty = null;
+ }
+
+ if ($objectProperty !== null) {
+ return $objectProperty;
+ }
+
+ if (($el = $this->getElement($name)) && !$this->valueIsEmpty($val = $el->getValue())) {
+ return $val;
+ }
+
+ return $default;
+ }
+
+ public function loadObject($id)
+ {
+ if ($this->branch && $this->branch->isBranch()) {
+ throw new \RuntimeException('Calling loadObject from form in a branch');
+ }
+ /** @var DbObject $class */
+ $class = $this->getObjectClassname();
+ if (is_int($id)) {
+ $this->object = $class::loadWithAutoIncId($id, $this->db);
+ if ($this->object->getKeyName() === 'id') {
+ $this->addHidden('id', $id);
+ }
+ } else {
+ $this->object = $class::load($id, $this->db);
+ }
+
+
+ return $this;
+ }
+
+ protected function addRange($key, $range)
+ {
+ $this->addElement('text', 'range_' . $key, array(
+ 'label' => 'ranges.' . $key,
+ 'value' => $range->range_value
+ ));
+ }
+
+ /**
+ * @param Db $db
+ * @return $this
+ */
+ public function setDb(Db $db)
+ {
+ if ($this->object !== null) {
+ $this->object->setConnection($db);
+ }
+
+ parent::setDb($db);
+ return $this;
+ }
+
+ public function optionallyAddFromEnum($enum)
+ {
+ return array(
+ null => $this->translate('- click to add more -')
+ ) + $enum;
+ }
+
+ protected function addObjectTypeElement()
+ {
+ if (!$this->isNew()) {
+ return $this;
+ }
+
+ if ($this->preferredObjectType) {
+ $this->addHidden('object_type', $this->preferredObjectType);
+ return $this;
+ }
+
+ $object = $this->object();
+
+ if ($object->supportsImports()) {
+ $templates = $this->enumAllowedTemplates();
+
+ if (empty($templates) && $this->getObjectShortClassName() !== 'Command') {
+ $types = array('template' => $this->translate('Template'));
+ } else {
+ $types = array(
+ 'object' => $this->translate('Object'),
+ 'template' => $this->translate('Template'),
+ );
+ }
+ } else {
+ $types = array('object' => $this->translate('Object'));
+ }
+
+ if ($this->object()->supportsApplyRules()) {
+ $types['apply'] = $this->translate('Apply rule');
+ }
+
+ $this->addElement('select', 'object_type', array(
+ 'label' => $this->translate('Object type'),
+ 'description' => $this->translate(
+ 'What kind of object this should be. Templates allow full access'
+ . ' to any property, they are your building blocks for "real" objects.'
+ . ' External objects should usually not be manually created or modified.'
+ . ' They allow you to work with objects locally defined on your Icinga nodes,'
+ . ' while not rendering and deploying them with the Director. Apply rules allow'
+ . ' to assign services, notifications and groups to other objects.'
+ ),
+ 'required' => true,
+ 'multiOptions' => $this->optionalEnum($types),
+ 'class' => 'autosubmit'
+ ));
+
+ return $this;
+ }
+
+ protected function hasObjectType()
+ {
+ if (!$this->object()->hasProperty('object_type')) {
+ return false;
+ }
+
+ return ! $this->valueIsEmpty($this->getSentOrObjectValue('object_type'));
+ }
+
+ protected function addZoneElement($all = false)
+ {
+ if ($all || $this->isTemplate()) {
+ $zones = $this->db->enumZones();
+ } else {
+ $zones = $this->db->enumNonglobalZones();
+ }
+
+ $this->addElement('select', 'zone_id', array(
+ 'label' => $this->translate('Cluster Zone'),
+ 'description' => $this->translate(
+ 'Icinga cluster zone. Allows to manually override Directors decisions'
+ . ' of where to deploy your config to. You should consider not doing so'
+ . ' unless you gained deep understanding of how an Icinga Cluster stack'
+ . ' works'
+ ),
+ 'multiOptions' => $this->optionalEnum($zones)
+ ));
+
+ return $this;
+ }
+
+ /**
+ * @param $type
+ * @return $this
+ */
+ protected function addChoices($type)
+ {
+ if ($this->isTemplate()) {
+ return $this;
+ }
+
+ $connection = $this->getDb();
+ $choiceType = 'TemplateChoice' . ucfirst($type);
+ $table = "icinga_$type";
+ $choices = IcingaObject::loadAllByType($choiceType, $connection);
+ $chosenTemplates = $this->getSentOrObjectValue('imports');
+ $db = $connection->getDbAdapter();
+ if (empty($chosenTemplates)) {
+ $importedIds = [];
+ } else {
+ $importedIds = $db->fetchCol(
+ $db->select()->from($table, 'id')
+ ->where('object_name in (?)', (array)$chosenTemplates)
+ ->where('object_type = ?', 'template')
+ );
+ }
+
+ foreach ($choices as $choice) {
+ $required = $choice->get('required_template_id');
+ if ($required === null || in_array($required, $importedIds, false)) {
+ $this->addChoiceElement($choice);
+ }
+ }
+
+ return $this;
+ }
+
+ protected function addChoiceElement(IcingaTemplateChoice $choice)
+ {
+ $imports = $this->object()->listImportNames();
+ $element = $choice->createFormElement($this, $imports);
+ $this->addElement($element);
+ $this->choiceElements[$element->getName()] = $element;
+ return $this;
+ }
+
+ /**
+ * @param bool $required
+ * @return $this
+ * @throws \Zend_Form_Exception
+ */
+ protected function addImportsElement($required = null)
+ {
+ if ($this->presetImports) {
+ return $this;
+ }
+
+ if (in_array($this->getObjectShortClassName(), ['TimePeriod', 'ScheduledDowntime'])) {
+ $required = false;
+ } else {
+ $required = $required !== null ? $required : !$this->isTemplate();
+ }
+ $enum = $this->enumAllowedTemplates();
+ if (empty($enum)) {
+ if ($required) {
+ if ($this->hasBeenSent()) {
+ $this->addError($this->translate('No template has been chosen'));
+ } else {
+ if ($this->hasPermission('director/admin')) {
+ $html = $this->translate('Please define a related template first');
+ } else {
+ $html = $this->translate('No related template has been provided yet');
+ }
+ $this->addHtml('<p class="warning">' . $html . '</p>');
+ }
+ }
+ return $this;
+ }
+
+ $db = $this->getDb()->getDbAdapter();
+ $object = $this->object;
+ if ($object->supportsChoices()) {
+ $choiceNames = $db->fetchCol(
+ $db->select()->from(
+ $this->object()->getTableName(),
+ 'object_name'
+ )->where('template_choice_id IS NOT NULL')
+ );
+ } else {
+ $choiceNames = [];
+ }
+
+ $type = $object->getShortTableName();
+ $this->addElement('extensibleSet', 'imports', array(
+ 'label' => $this->translate('Imports'),
+ 'description' => $this->translate(
+ 'Importable templates, add as many as you want. Please note that order'
+ . ' matters when importing properties from multiple templates: last one'
+ . ' wins'
+ ),
+ 'required' => $required,
+ 'spellcheck' => 'false',
+ 'hideOptions' => $choiceNames,
+ 'suggest' => "${type}templates",
+ // 'multiOptions' => $this->optionallyAddFromEnum($enum),
+ 'sorted' => true,
+ 'value' => $this->presetImports,
+ 'class' => 'autosubmit'
+ ));
+
+ return $this;
+ }
+
+ protected function addDisabledElement()
+ {
+ if ($this->isTemplate()) {
+ return $this;
+ }
+
+ $this->addBoolean(
+ 'disabled',
+ array(
+ 'label' => $this->translate('Disabled'),
+ 'description' => $this->translate('Disabled objects will not be deployed')
+ ),
+ 'n'
+ );
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ * @throws \Zend_Form_Exception
+ */
+ protected function addGroupDisplayNameElement()
+ {
+ $this->addElement('text', 'display_name', array(
+ 'label' => $this->translate('Display Name'),
+ 'description' => $this->translate(
+ 'An alternative display name for this group. If you wonder how this'
+ . ' could be helpful just leave it blank'
+ )
+ ));
+
+ return $this;
+ }
+
+ /**
+ * @param bool $force
+ *
+ * @return $this
+ * @throws \Zend_Form_Exception
+ */
+ protected function addCheckCommandElements($force = false)
+ {
+ if (! $force && ! $this->isTemplate()) {
+ return $this;
+ }
+
+ $this->addElement('text', 'check_command', array(
+ 'label' => $this->translate('Check command'),
+ 'description' => $this->translate('Check command definition'),
+ // 'multiOptions' => $this->optionalEnum($this->db->enumCheckcommands()),
+ 'class' => 'autosubmit director-suggest', // This influences fields
+ 'data-suggestion-context' => 'checkcommandnames',
+ 'value' => $this->getObject()->get('check_command')
+ ));
+ $this->getDisplayGroup('object_definition')
+ // ->addElement($this->getElement('check_command_id'))
+ ->addElement($this->getElement('check_command'));
+
+ $eventCommands = $this->db->enumEventcommands();
+
+ if (! empty($eventCommands)) {
+ $this->addElement('select', 'event_command_id', array(
+ 'label' => $this->translate('Event command'),
+ 'description' => $this->translate('Event command definition'),
+ 'multiOptions' => $this->optionalEnum($eventCommands),
+ 'class' => 'autosubmit',
+ ));
+ $this->addToCheckExecutionDisplayGroup('event_command_id');
+ }
+
+ return $this;
+ }
+
+ protected function addCheckExecutionElements($force = false)
+ {
+ if (! $force && ! $this->isTemplate()) {
+ return $this;
+ }
+
+ $this->addElement(
+ 'text',
+ 'check_interval',
+ array(
+ 'label' => $this->translate('Check interval'),
+ 'description' => $this->translate('Your regular check interval')
+ )
+ );
+
+ $this->addElement(
+ 'text',
+ 'retry_interval',
+ array(
+ 'label' => $this->translate('Retry interval'),
+ 'description' => $this->translate(
+ 'Retry interval, will be applied after a state change unless the next hard state is reached'
+ )
+ )
+ );
+
+ $this->addElement(
+ 'text',
+ 'max_check_attempts',
+ array(
+ 'label' => $this->translate('Max check attempts'),
+ 'description' => $this->translate(
+ 'Defines after how many check attempts a new hard state is reached'
+ )
+ )
+ );
+
+ $this->addElement(
+ 'text',
+ 'check_timeout',
+ array(
+ 'label' => $this->translate('Check timeout'),
+ 'description' => $this->translate(
+ "Check command timeout in seconds. Overrides the CheckCommand's timeout attribute"
+ )
+ )
+ );
+
+ $periods = $this->db->enumTimeperiods();
+
+ if (!empty($periods)) {
+ $this->addElement(
+ 'select',
+ 'check_period_id',
+ array(
+ 'label' => $this->translate('Check period'),
+ 'description' => $this->translate(
+ 'The name of a time period which determines when this'
+ . ' object should be monitored. Not limited by default.'
+ ),
+ 'multiOptions' => $this->optionalEnum($periods),
+ )
+ );
+ }
+
+ $this->optionalBoolean(
+ 'enable_active_checks',
+ $this->translate('Execute active checks'),
+ $this->translate('Whether to actively check this object')
+ );
+
+ $this->optionalBoolean(
+ 'enable_passive_checks',
+ $this->translate('Accept passive checks'),
+ $this->translate('Whether to accept passive check results for this object')
+ );
+
+ $this->optionalBoolean(
+ 'enable_notifications',
+ $this->translate('Send notifications'),
+ $this->translate('Whether to send notifications for this object')
+ );
+
+ $this->optionalBoolean(
+ 'enable_event_handler',
+ $this->translate('Enable event handler'),
+ $this->translate('Whether to enable event handlers this object')
+ );
+
+ $this->optionalBoolean(
+ 'enable_perfdata',
+ $this->translate('Process performance data'),
+ $this->translate('Whether to process performance data provided by this object')
+ );
+
+ $this->optionalBoolean(
+ 'enable_flapping',
+ $this->translate('Enable flap detection'),
+ $this->translate('Whether flap detection is enabled on this object')
+ );
+
+ $this->addElement(
+ 'text',
+ 'flapping_threshold_high',
+ array(
+ 'label' => $this->translate('Flapping threshold (high)'),
+ 'description' => $this->translate(
+ 'Flapping upper bound in percent for a service to be considered flapping'
+ )
+ )
+ );
+
+ $this->addElement(
+ 'text',
+ 'flapping_threshold_low',
+ array(
+ 'label' => $this->translate('Flapping threshold (low)'),
+ 'description' => $this->translate(
+ 'Flapping lower bound in percent for a service to be considered not flapping'
+ )
+ )
+ );
+
+ $this->optionalBoolean(
+ 'volatile',
+ $this->translate('Volatile'),
+ $this->translate('Whether this check is volatile.')
+ );
+
+ $elements = array(
+ 'check_interval',
+ 'retry_interval',
+ 'max_check_attempts',
+ 'check_timeout',
+ 'check_period_id',
+ 'enable_active_checks',
+ 'enable_passive_checks',
+ 'enable_notifications',
+ 'enable_event_handler',
+ 'enable_perfdata',
+ 'enable_flapping',
+ 'flapping_threshold_high',
+ 'flapping_threshold_low',
+ 'volatile'
+ );
+ $this->addToCheckExecutionDisplayGroup($elements);
+
+ return $this;
+ }
+
+ protected function enumAllowedTemplates()
+ {
+ $object = $this->object();
+ $tpl = $this->db->enumIcingaTemplates($object->getShortTableName());
+ if (empty($tpl)) {
+ return [];
+ }
+
+ $id = $object->get('id');
+
+ if (array_key_exists($id, $tpl)) {
+ unset($tpl[$id]);
+ }
+
+ return array_combine($tpl, $tpl);
+ }
+
+ protected function addExtraInfoElements()
+ {
+ $this->addElement('textarea', 'notes', array(
+ 'label' => $this->translate('Notes'),
+ 'description' => $this->translate(
+ 'Additional notes for this object'
+ ),
+ 'rows' => 2,
+ 'columns' => 60,
+ ));
+
+ $this->addElement('text', 'notes_url', array(
+ 'label' => $this->translate('Notes URL'),
+ 'description' => $this->translate(
+ 'An URL pointing to additional notes for this object'
+ ),
+ ));
+
+ $this->addElement('text', 'action_url', array(
+ 'label' => $this->translate('Action URL'),
+ 'description' => $this->translate(
+ 'An URL leading to additional actions for this object. Often used'
+ . ' with Icinga Classic, rarely with Icinga Web 2 as it provides'
+ . ' far better possibilities to integrate addons'
+ ),
+ ));
+
+ $this->addElement('text', 'icon_image', array(
+ 'label' => $this->translate('Icon image'),
+ 'description' => $this->translate(
+ 'An URL pointing to an icon for this object. Try "tux.png" for icons'
+ . ' relative to public/img/icons or "cloud" (no extension) for items'
+ . ' from the Icinga icon font'
+ ),
+ ));
+
+ $this->addElement('text', 'icon_image_alt', array(
+ 'label' => $this->translate('Icon image alt'),
+ 'description' => $this->translate(
+ 'Alternative text to be shown in case above icon is missing'
+ ),
+ ));
+
+ $elements = array(
+ 'notes',
+ 'notes_url',
+ 'action_url',
+ 'icon_image',
+ 'icon_image_alt',
+ );
+
+ $this->addDisplayGroup($elements, 'extrainfo', array(
+ 'decorators' => array(
+ 'FormElements',
+ array('HtmlTag', array('tag' => 'dl')),
+ 'Fieldset',
+ ),
+ 'order' => self::GROUP_ORDER_EXTRA_INFO,
+ 'legend' => $this->translate('Additional properties')
+ ));
+
+ return $this;
+ }
+
+ /**
+ * Add an assign_filter form element
+ *
+ * Forms should use this helper method for objects using the typical
+ * assign_filter column
+ *
+ * @param array $properties Form element properties
+ *
+ * @return $this
+ * @throws \Zend_Form_Exception
+ */
+ protected function addAssignFilter($properties)
+ {
+ if (!$this->object || !$this->object->supportsAssignments()) {
+ return $this;
+ }
+
+ $this->addFilterElement('assign_filter', $properties);
+ $el = $this->getElement('assign_filter');
+
+ $this->addDisplayGroup(array($el), 'assign', array(
+ 'decorators' => array(
+ 'FormElements',
+ array('HtmlTag', array('tag' => 'dl')),
+ 'Fieldset',
+ ),
+ 'order' => self::GROUP_ORDER_ASSIGN,
+ 'legend' => $this->translate('Assign where')
+ ));
+
+ return $this;
+ }
+
+ /**
+ * Add a dataFilter element with fitting decorators
+ *
+ * TODO: Evaluate whether parts or all of this could be moved to the element
+ * class.
+ *
+ * @param string $name Element name
+ * @param array $properties Form element properties
+ *
+ * @return $this
+ * @throws \Zend_Form_Exception
+ */
+ protected function addFilterElement($name, $properties)
+ {
+ $this->addElement('dataFilter', $name, $properties);
+ $el = $this->getElement($name);
+
+ $ddClass = 'full-width';
+ if (array_key_exists('required', $properties) && $properties['required']) {
+ $ddClass .= ' required';
+ }
+
+ $el->clearDecorators()
+ ->addDecorator('ViewHelper')
+ ->addDecorator('Errors')
+ ->addDecorator('Description', array('tag' => 'p', 'class' => 'description'))
+ ->addDecorator('HtmlTag', array(
+ 'tag' => 'dd',
+ 'class' => $ddClass,
+ ));
+
+ return $this;
+ }
+
+ protected function addEventFilterElements($elements = array('states','types'))
+ {
+ if (in_array('states', $elements)) {
+ $this->addElement('extensibleSet', 'states', array(
+ 'label' => $this->translate('States'),
+ 'multiOptions' => $this->optionallyAddFromEnum($this->enumStates()),
+ 'description' => $this->translate(
+ 'The host/service states you want to get notifications for'
+ ),
+ ));
+ }
+
+ if (in_array('types', $elements)) {
+ $this->addElement('extensibleSet', 'types', array(
+ 'label' => $this->translate('Transition types'),
+ 'multiOptions' => $this->optionallyAddFromEnum($this->enumTypes()),
+ 'description' => $this->translate(
+ 'The state transition types you want to get notifications for'
+ ),
+ ));
+ }
+
+ $this->addDisplayGroup($elements, 'event_filters', array(
+ 'decorators' => array(
+ 'FormElements',
+ array('HtmlTag', array('tag' => 'dl')),
+ 'Fieldset',
+ ),
+ 'order' => self::GROUP_ORDER_EVENT_FILTERS,
+ 'legend' => $this->translate('State and transition type filters')
+ ));
+
+ return $this;
+ }
+
+ /**
+ * @param string $permission
+ * @return bool
+ */
+ public function hasPermission($permission)
+ {
+ return Util::hasPermission($permission);
+ }
+
+ public function setBranch(Branch $branch)
+ {
+ $this->branch = $branch;
+
+ return $this;
+ }
+
+ protected function allowsExperimental()
+ {
+ // NO, it is NOT a good idea to use this. You'll break your monitoring
+ // and nobody will help you.
+ if ($this->allowsExperimental === null) {
+ $this->allowsExperimental = $this->db->settings()->get(
+ 'experimental_features'
+ ) === 'allow';
+ }
+
+ return $this->allowsExperimental;
+ }
+
+ protected function enumStates()
+ {
+ $set = new StateFilterSet();
+ return $set->enumAllowedValues();
+ }
+
+ protected function enumTypes()
+ {
+ $set = new TypeFilterSet();
+ return $set->enumAllowedValues();
+ }
+}
diff --git a/library/Director/Web/Form/Element/Boolean.php b/library/Director/Web/Form/Element/Boolean.php
new file mode 100644
index 0000000..b2402c7
--- /dev/null
+++ b/library/Director/Web/Form/Element/Boolean.php
@@ -0,0 +1,90 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form\Element;
+
+use Zend_Form_Element_Select as ZfSelect;
+
+/**
+ * Input control for booleans
+ */
+class Boolean extends ZfSelect
+{
+ public $options = array(
+ null => '- please choose -',
+ 'y' => 'Yes',
+ 'n' => 'No',
+ );
+
+ public function getValue()
+ {
+ $value = $this->getUnfilteredValue();
+
+ if ($value === 'y' || $value === true) {
+ return true;
+ } elseif ($value === 'n' || $value === false) {
+ return false;
+ }
+
+ return null;
+ }
+
+ public function isValid($value, $context = null)
+ {
+ if ($value === 'y' || $value === 'n') {
+ $this->setValue($value);
+ return true;
+ }
+
+ return parent::isValid($value, $context);
+ }
+
+ /**
+ * @param string $value
+ * @param string $key
+ * @codingStandardsIgnoreStart
+ */
+ protected function _filterValue(&$value, &$key)
+ {
+ // @codingStandardsIgnoreEnd
+ if ($value === true) {
+ $value = 'y';
+ } elseif ($value === false) {
+ $value = 'n';
+ } elseif ($value === '') {
+ $value = null;
+ }
+
+ parent::_filterValue($value, $key);
+ }
+
+ public function setValue($value)
+ {
+ if ($value === true) {
+ $value = 'y';
+ } elseif ($value === false) {
+ $value = 'n';
+ } elseif ($value === '') {
+ $value = null;
+ }
+
+ return parent::setValue($value);
+ }
+
+ /**
+ * @codingStandardsIgnoreStart
+ */
+ protected function _translateOption($option, $value)
+ {
+ // @codingStandardsIgnoreEnd
+ if (!isset($this->_translated[$option]) && !empty($value)) {
+ $this->options[$option] = mt('director', $value);
+ if ($this->options[$option] === $value) {
+ return false;
+ }
+ $this->_translated[$option] = true;
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/library/Director/Web/Form/Element/DataFilter.php b/library/Director/Web/Form/Element/DataFilter.php
new file mode 100644
index 0000000..adae07d
--- /dev/null
+++ b/library/Director/Web/Form/Element/DataFilter.php
@@ -0,0 +1,361 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form\Element;
+
+use Icinga\Data\Filter\Filter;
+use Icinga\Data\Filter\FilterChain;
+use Icinga\Data\Filter\FilterExpression;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Module\Director\Web\Form\IconHelper;
+use Exception;
+
+/**
+ * Input control for extensible sets
+ */
+class DataFilter extends FormElement
+{
+ /**
+ * Default form view helper to use for rendering
+ * @var string
+ */
+ public $helper = 'formDataFilter';
+
+ private $addTo;
+
+ private $removeFilter;
+
+ private $stripFilter;
+
+ /** @var FilterChain */
+ private $filter;
+
+ public function getValue()
+ {
+ $value = parent::getValue();
+ if ($value !== null && $this->isEmpty($value)) {
+ $value = null;
+ }
+
+ return $value;
+ }
+
+ protected function isEmpty(Filter $filter)
+ {
+ return $filter->isEmpty() || $this->isEmptyExpression($filter);
+ }
+
+ protected function isEmptyExpression(Filter $filter)
+ {
+ return $filter instanceof FilterExpression &&
+ $filter->getColumn() === '' &&
+ $filter->getExpression() === '""'; // -> json_encode('')
+ }
+
+ /**
+ * @inheritdoc
+ * @codingStandardsIgnoreStart
+ */
+ protected function _filterValue(&$value, &$key)
+ {
+ // @codingStandardsIgnoreEnd
+ try {
+ if ($value instanceof Filter) {
+ // OK
+ } elseif (is_string($value)) {
+ $value = Filter::fromQueryString($value);
+ } elseif (is_array($value) || is_null($value)) {
+ $value = $this->arrayToFilter($value);
+ } else {
+ throw new ProgrammingError(
+ 'Value to be filtered has to be Filter, string, array or null'
+ );
+ }
+ } catch (Exception $e) {
+ $value = null;
+ // TODO: getFile, getLine
+ // Hint: cannot addMessage at it would loop through getValue
+ $this->addErrorMessage($e->getMessage());
+ $this->_isErrorForced = true;
+ }
+ }
+
+ /**
+ * This method transforms filter form data into a filter
+ * and reacts on pressed buttons
+ *
+ * @param array|null $array
+ *
+ * @return FilterChain|null
+ */
+ protected function arrayToFilter($array)
+ {
+ if ($array === null) {
+ return null;
+ }
+
+ $this->filter = null;
+ foreach ($array as $id => $entry) {
+ $filterId = $this->idToFilterId($id);
+ $sub = $this->entryToFilter($entry);
+ $this->checkEntryForActions($filterId, $entry);
+ $parentId = $this->parentIdFor($filterId);
+
+ if ($this->filter === null) {
+ $this->filter = $sub;
+ } else {
+ $this->filter->getById($parentId)->addFilter($sub);
+ }
+ }
+
+ $this->removeFilterIfRequested()
+ ->stripFilterIfRequested()
+ ->addNewFilterIfRequested()
+ ->fixNotsWithMultipleChildren();
+
+ return $this->filter;
+ }
+
+ protected function removeFilterIfRequested()
+ {
+ if ($this->removeFilter !== null) {
+ if ($this->filter->getById($this->removeFilter)->isRootNode()) {
+ $this->filter = $this->emptyExpression();
+ } else {
+ $this->filter->removeId($this->removeFilter);
+ }
+ }
+
+ return $this;
+ }
+
+
+ protected function stripFilterIfRequested()
+ {
+ if ($this->stripFilter !== null) {
+ $strip = $this->stripFilter;
+ $subId = $strip . '-1';
+ if ($this->filter->getId() === $strip) {
+ $this->filter = $this->filter->getById($subId);
+ } else {
+ $this->filter->replaceById($strip, $this->filter->getById($subId));
+ }
+ }
+
+ return $this;
+ }
+
+ protected function addNewFilterIfRequested()
+ {
+ if ($this->addTo !== null) {
+ $parent = $this->filter->getById($this->addTo);
+
+ if ($parent instanceof FilterChain) {
+ if ($parent->isEmpty()) {
+ $parent->addFilter($this->emptyExpression());
+ } else {
+ $parent->addFilter($this->emptyExpression());
+ }
+ } elseif ($parent instanceof FilterExpression) {
+ $replacement = Filter::matchAll(clone($parent));
+ if ($parent->isRootNode()) {
+ $this->filter = $replacement;
+ } else {
+ $this->filter->replaceById($parent->getId(), $replacement);
+ }
+ }
+ }
+
+ return $this;
+ }
+
+ protected function fixNotsWithMultipleChildren()
+ {
+ $this->filter = $this->fixNotsWithMultipleChildrenForFilter($this->filter);
+ return $this;
+ }
+
+ protected function fixNotsWithMultipleChildrenForFilter(Filter $filter)
+ {
+ if ($filter instanceof FilterChain) {
+ if ($filter->getOperatorName() === 'NOT') {
+ if ($filter->count() > 1) {
+ $filter = $this->notToNotAnd($filter);
+ }
+ }
+ /** @var Filter $sub */
+ foreach ($filter->filters() as $sub) {
+ $filter->replaceById(
+ $sub->getId(),
+ $this->fixNotsWithMultipleChildrenForFilter($sub)
+ );
+ }
+ }
+
+ return $filter;
+ }
+
+ protected function notToNotAnd(FilterChain $not)
+ {
+ $and = Filter::matchAll();
+ foreach ($not->filters() as $sub) {
+ $and->addFilter(clone($sub));
+ }
+
+ return Filter::not($and);
+ }
+
+ protected function emptyExpression()
+ {
+ return Filter::expression('', '=', '');
+ }
+
+ protected function parentIdFor($id)
+ {
+ if (false === ($pos = strrpos($id, '-'))) {
+ return '0';
+ } else {
+ return substr($id, 0, $pos);
+ }
+ }
+
+ protected function idToFilterId($id)
+ {
+ if (! preg_match('/^id_(new_)?(\d+(?:-\d+)*)$/', $id, $m)) {
+ die('nono' . $id);
+ }
+
+ return $m[2];
+ }
+
+ protected function checkEntryForActions($filterId, $entry)
+ {
+ switch ($this->entryAction($entry)) {
+ case 'cancel':
+ $this->removeFilter = $filterId;
+ break;
+
+ case 'minus':
+ $this->stripFilter = $filterId;
+ break;
+
+ case 'plus':
+ case 'angle-double-right':
+ $this->addTo = $filterId;
+ break;
+ }
+ }
+
+ /**
+ * Transforms a single submitted form component from an array
+ * into a Filter object
+ *
+ * @param array $entry The array as submitted through the form
+ *
+ * @return Filter
+ */
+ protected function entryToFilter($entry)
+ {
+ if (array_key_exists('operator', $entry)) {
+ return Filter::chain($entry['operator']);
+ } else {
+ return $this->entryToFilterExpression($entry);
+ }
+ }
+
+ protected function entryToFilterExpression($entry)
+ {
+ if ($entry['sign'] === 'true') {
+ return Filter::expression(
+ $entry['column'],
+ '=',
+ json_encode(true)
+ );
+ } elseif ($entry['sign'] === 'false') {
+ return Filter::expression(
+ $entry['column'],
+ '=',
+ json_encode(false)
+ );
+ } elseif ($entry['sign'] === 'in') {
+ if (array_key_exists('value', $entry)) {
+ if (is_array($entry['value'])) {
+ $value = array_filter($entry['value'], 'strlen');
+ } elseif (empty($entry['value'])) {
+ $value = array();
+ } else {
+ $value = array($entry['value']);
+ }
+ } else {
+ $value = array();
+ }
+ return Filter::expression(
+ $entry['column'],
+ '=',
+ json_encode($value)
+ );
+ } elseif ($entry['sign'] === 'contains') {
+ $value = array_key_exists('value', $entry) ? $entry['value'] : null;
+
+ return Filter::expression(
+ json_encode($value),
+ '=',
+ $entry['column']
+ );
+ } else {
+ $value = array_key_exists('value', $entry) ? $entry['value'] : null;
+
+ return Filter::expression(
+ $entry['column'],
+ $entry['sign'],
+ json_encode($value)
+ );
+ }
+ }
+
+ protected function entryAction($entry)
+ {
+ if (array_key_exists('action', $entry)) {
+ return IconHelper::instance()->characterIconName($entry['action']);
+ }
+
+ return null;
+ }
+
+ protected function hasIncompleteExpressions(Filter $filter)
+ {
+ if ($filter instanceof FilterChain) {
+ foreach ($filter->filters() as $sub) {
+ if ($this->hasIncompleteExpressions($sub)) {
+ return true;
+ }
+ }
+
+ return false;
+ } else {
+ /** @var FilterExpression $filter */
+ if ($filter->isRootNode() && $this->isEmptyExpression($filter)) {
+ return false;
+ }
+
+ return $filter->getColumn() === '';
+ }
+ }
+
+ public function isValid($value, $context = null)
+ {
+ if (! $value instanceof Filter) {
+ // TODO: try, return false on E
+ $filter = $this->arrayToFilter($value);
+ $this->setValue($filter);
+ } else {
+ $filter = $value;
+ }
+
+ if ($this->hasIncompleteExpressions($filter)) {
+ $this->addError('The configured filter is incomplete');
+ return false;
+ }
+
+ return parent::isValid($value);
+ }
+}
diff --git a/library/Director/Web/Form/Element/ExtensibleSet.php b/library/Director/Web/Form/Element/ExtensibleSet.php
new file mode 100644
index 0000000..f3c968f
--- /dev/null
+++ b/library/Director/Web/Form/Element/ExtensibleSet.php
@@ -0,0 +1,89 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form\Element;
+
+use InvalidArgumentException;
+
+/**
+ * Input control for extensible sets
+ */
+class ExtensibleSet extends FormElement
+{
+ /**
+ * Default form view helper to use for rendering
+ * @var string
+ */
+ public $helper = 'formIplExtensibleSet';
+
+ // private $multiOptions;
+
+ public function getValue()
+ {
+ $value = parent::getValue();
+ if (is_string($value) || is_numeric($value)) {
+ $value = [$value];
+ } elseif ($value === null) {
+ return $value;
+ }
+ if (! is_array($value)) {
+ throw new InvalidArgumentException(sprintf(
+ 'ExtensibleSet expects to work with Arrays, got %s',
+ var_export($value, 1)
+ ));
+ }
+ $value = array_filter($value, 'strlen');
+
+ if (empty($value)) {
+ return null;
+ }
+
+ return $value;
+ }
+
+ /**
+ * We do not want one message per entry
+ *
+ * @codingStandardsIgnoreStart
+ */
+ protected function _getErrorMessages()
+ {
+ return $this->_errorMessages;
+ // @codingStandardsIgnoreEnd
+ }
+
+ /**
+ * @codingStandardsIgnoreStart
+ */
+ protected function _filterValue(&$value, &$key)
+ {
+ // @codingStandardsIgnoreEnd
+ if (is_array($value)) {
+ $value = array_filter($value, 'strlen');
+ } elseif (is_string($value) && !strlen($value)) {
+ $value = null;
+ }
+
+ parent::_filterValue($value, $key);
+ }
+
+ public function isValid($value, $context = null)
+ {
+ if ($value === null) {
+ $value = [];
+ }
+
+ $value = array_filter($value, 'strlen');
+ $this->setValue($value);
+ if ($this->isRequired() && empty($value)) {
+ // TODO: translate
+ $this->addError('You are required to choose at least one element');
+ return false;
+ }
+
+ if ($this->hasErrors()) {
+ return false;
+ }
+
+ return parent::isValid($value, $context);
+ }
+}
diff --git a/library/Director/Web/Form/Element/FormElement.php b/library/Director/Web/Form/Element/FormElement.php
new file mode 100644
index 0000000..c327859
--- /dev/null
+++ b/library/Director/Web/Form/Element/FormElement.php
@@ -0,0 +1,9 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form\Element;
+
+use Zend_Form_Element_Xhtml;
+
+class FormElement extends Zend_Form_Element_Xhtml
+{
+}
diff --git a/library/Director/Web/Form/Element/InstanceSummary.php b/library/Director/Web/Form/Element/InstanceSummary.php
new file mode 100644
index 0000000..722ad26
--- /dev/null
+++ b/library/Director/Web/Form/Element/InstanceSummary.php
@@ -0,0 +1,51 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form\Element;
+
+use gipfl\IcingaWeb2\Link;
+use ipl\Html\Html;
+
+/**
+ * Used by the
+ */
+class InstanceSummary extends FormElement
+{
+ public $helper = 'formSimpleNote';
+
+ /**
+ * Always ignore this element
+ * @codingStandardsIgnoreStart
+ *
+ * @var boolean
+ */
+ protected $_ignore = true;
+ // @codingStandardsIgnoreEnd
+
+ private $instances;
+
+ /** @var array will be set via options */
+ protected $linkParams;
+
+ public function setValue($value)
+ {
+ $this->instances = $value;
+ return $this;
+ }
+
+ public function getValue()
+ {
+ return Html::tag('span', [
+ Html::tag('italic', 'empty'),
+ ' ',
+ Link::create('Manage Instances', 'director/data/dictionary', $this->linkParams, [
+ 'data-base-target' => '_next',
+ 'class' => 'icon-forward'
+ ])
+ ]);
+ }
+
+ public function isValid($value, $context = null)
+ {
+ return true;
+ }
+}
diff --git a/library/Director/Web/Form/Element/OptionalYesNo.php b/library/Director/Web/Form/Element/OptionalYesNo.php
new file mode 100644
index 0000000..7ef6d7f
--- /dev/null
+++ b/library/Director/Web/Form/Element/OptionalYesNo.php
@@ -0,0 +1,22 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form\Element;
+
+/**
+ * Input control for booleans, gives y/n
+ */
+class OptionalYesNo extends Boolean
+{
+ public function getValue()
+ {
+ $value = $this->getUnfilteredValue();
+
+ if ($value === 'y' || $value === true) {
+ return 'y';
+ } elseif ($value === 'n' || $value === false) {
+ return 'n';
+ }
+
+ return null;
+ }
+}
diff --git a/library/Director/Web/Form/Element/SimpleNote.php b/library/Director/Web/Form/Element/SimpleNote.php
new file mode 100644
index 0000000..3097e11
--- /dev/null
+++ b/library/Director/Web/Form/Element/SimpleNote.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form\Element;
+
+use Icinga\Module\Director\PlainObjectRenderer;
+use ipl\Html\ValidHtml;
+
+class SimpleNote extends FormElement
+{
+ public $helper = 'formSimpleNote';
+
+ /**
+ * Always ignore this element
+ * @codingStandardsIgnoreStart
+ *
+ * @var boolean
+ */
+ protected $_ignore = true;
+ // @codingStandardsIgnoreEnd
+
+ public function isValid($value, $context = null)
+ {
+ return true;
+ }
+
+ public function setValue($value)
+ {
+ if (is_object($value) && ! $value instanceof ValidHtml) {
+ $value = 'Unexpected object: ' . PlainObjectRenderer::render($value);
+ }
+
+ return parent::setValue($value);
+ }
+}
diff --git a/library/Director/Web/Form/Element/StoredPassword.php b/library/Director/Web/Form/Element/StoredPassword.php
new file mode 100644
index 0000000..fa0545b
--- /dev/null
+++ b/library/Director/Web/Form/Element/StoredPassword.php
@@ -0,0 +1,62 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form\Element;
+
+use Zend_Form_Element_Text as ZfText;
+
+/**
+ * StoredPassword
+ *
+ * This is a special form field and it might look a little bit weird at first
+ * sight. It's main use-case are stored cleartext passwords a user should be
+ * allowed to change.
+ *
+ * While this might sound simple, it's quite tricky if you try to fulfill the
+ * following requirements:
+ *
+ * - the current password should not be rendered to the HTML page (unless the
+ * user decides to change it)
+ * - it must be possible to visually distinct whether a password has been set
+ * - it should be impossible to "see" the length of the stored password
+ * - a changed password must be persisted
+ * - forms might be subject to multiple submissions in case other fields fail.
+ * If the user changed the password during the first submission attempt, the
+ * new string should not be lost.
+ * - all this must happen within the bounds of ZF1 form elements and related
+ * view helpers. This means that there is no related context available - and
+ * we do not know whether the form has been submitted and whether the current
+ * values have been populated from DB
+ *
+ * @package Icinga\Module\Director\Web\Form\Element
+ */
+class StoredPassword extends ZfText
+{
+ const UNCHANGED = '__UNCHANGED_VALUE__';
+
+ public $helper = 'formStoredPassword';
+
+ public function setValue($value)
+ {
+ if (\is_array($value) && isset($value['_value'], $value['_sent'])
+ && $value['_sent'] === 'y'
+ ) {
+ $value = $sentValue = $value['_value'];
+ if ($sentValue !== self::UNCHANGED) {
+ $this->setAttrib('sentValue', $sentValue);
+ }
+ } else {
+ $sentValue = null;
+ }
+
+ if ($value === self::UNCHANGED) {
+ return $this;
+ } else {
+ // Workaround for issue with modified DataTypes. This is Director-specific
+ if (\is_array($value)) {
+ $value = \json_encode($value);
+ }
+
+ return parent::setValue((string) $value);
+ }
+ }
+}
diff --git a/library/Director/Web/Form/Element/Text.php b/library/Director/Web/Form/Element/Text.php
new file mode 100644
index 0000000..eeb36f1
--- /dev/null
+++ b/library/Director/Web/Form/Element/Text.php
@@ -0,0 +1,16 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form\Element;
+
+use Zend_Form_Element_Text as ZfText;
+
+class Text extends ZfText
+{
+ public function setValue($value)
+ {
+ if (\is_array($value)) {
+ $value = \json_encode($value);
+ }
+ return parent::setValue((string) $value);
+ }
+}
diff --git a/library/Director/Web/Form/Element/YesNo.php b/library/Director/Web/Form/Element/YesNo.php
new file mode 100644
index 0000000..3e8aaa7
--- /dev/null
+++ b/library/Director/Web/Form/Element/YesNo.php
@@ -0,0 +1,14 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form\Element;
+
+/**
+ * Input control for booleans, gives y/n
+ */
+class YesNo extends OptionalYesNo
+{
+ public $options = array(
+ 'y' => 'Yes',
+ 'n' => 'No',
+ );
+}
diff --git a/library/Director/Web/Form/Filter/QueryColumnsFromSql.php b/library/Director/Web/Form/Filter/QueryColumnsFromSql.php
new file mode 100644
index 0000000..6f6d475
--- /dev/null
+++ b/library/Director/Web/Form/Filter/QueryColumnsFromSql.php
@@ -0,0 +1,48 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form\Filter;
+
+use Exception;
+use Icinga\Data\Db\DbConnection;
+use Icinga\Module\Director\Forms\ImportSourceForm;
+use Zend_Filter_Interface;
+
+class QueryColumnsFromSql implements Zend_Filter_Interface
+{
+ /** @var ImportSourceForm */
+ private $form;
+
+ public function __construct(ImportSourceForm $form)
+ {
+ $this->form = $form;
+ }
+
+ public function filter($value)
+ {
+ $form = $this->form;
+ if (empty($value) || $form->hasChangedSetting('query')) {
+ try {
+ return implode(
+ ', ',
+ $this->getQueryColumns($form->getSentOrObjectSetting('query'))
+ );
+ } catch (Exception $e) {
+ $this->form->addUniqueException($e);
+ return '';
+ }
+ } else {
+ return $value;
+ }
+ }
+
+ protected function getQueryColumns($query)
+ {
+ $resourceName = $this->form->getSentOrObjectSetting('resource');
+ if (! $resourceName) {
+ return [];
+ }
+ $db = DbConnection::fromResourceName($resourceName)->getDbAdapter();
+
+ return array_keys((array) current($db->fetchAll($query)));
+ }
+}
diff --git a/library/Director/Web/Form/FormLoader.php b/library/Director/Web/Form/FormLoader.php
new file mode 100644
index 0000000..ea82857
--- /dev/null
+++ b/library/Director/Web/Form/FormLoader.php
@@ -0,0 +1,43 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form;
+
+use Icinga\Application\Icinga;
+use Icinga\Application\Modules\Module;
+use Icinga\Exception\ProgrammingError;
+use RuntimeException;
+
+class FormLoader
+{
+ public static function load($name, Module $module = null)
+ {
+ if ($module === null) {
+ try {
+ $basedir = Icinga::app()->getApplicationDir('forms');
+ } catch (ProgrammingError $e) {
+ throw new RuntimeException($e->getMessage(), 0, $e);
+ }
+ $ns = '\\Icinga\\Web\\Forms\\';
+ } else {
+ $basedir = $module->getFormDir();
+ $ns = '\\Icinga\\Module\\' . ucfirst($module->getName()) . '\\Forms\\';
+ }
+ if (preg_match('~^[a-z0-9/]+$~i', $name)) {
+ $parts = preg_split('~/~', $name);
+ $class = ucfirst(array_pop($parts)) . 'Form';
+ $file = sprintf('%s/%s/%s.php', rtrim($basedir, '/'), implode('/', $parts), $class);
+ if (file_exists($file)) {
+ require_once($file);
+ $class = $ns . $class;
+ $options = array();
+ if ($module !== null) {
+ $options['icingaModule'] = $module;
+ }
+
+ return new $class($options);
+ }
+ }
+
+ throw new RuntimeException(sprintf('Cannot load %s (%s), no such form', $name, $file));
+ }
+}
diff --git a/library/Director/Web/Form/IcingaObjectFieldLoader.php b/library/Director/Web/Form/IcingaObjectFieldLoader.php
new file mode 100644
index 0000000..c900edf
--- /dev/null
+++ b/library/Director/Web/Form/IcingaObjectFieldLoader.php
@@ -0,0 +1,628 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form;
+
+use Exception;
+use Icinga\Data\Filter\Filter;
+use Icinga\Data\Filter\FilterChain;
+use Icinga\Data\Filter\FilterExpression;
+use Icinga\Exception\IcingaException;
+use Icinga\Module\Director\Hook\HostFieldHook;
+use Icinga\Module\Director\Hook\ServiceFieldHook;
+use Icinga\Module\Director\Objects\DirectorDatafieldCategory;
+use Icinga\Module\Director\Objects\IcingaCommand;
+use Icinga\Module\Director\Objects\IcingaHost;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Icinga\Module\Director\Objects\DirectorDatafield;
+use Icinga\Module\Director\Objects\IcingaService;
+use Icinga\Module\Director\Objects\ObjectApplyMatches;
+use Icinga\Web\Hook;
+use stdClass;
+use Zend_Db_Select as ZfSelect;
+use Zend_Form_Element as ZfElement;
+
+class IcingaObjectFieldLoader
+{
+ protected $form;
+
+ /** @var IcingaObject */
+ protected $object;
+
+ /** @var \Icinga\Module\Director\Db */
+ protected $connection;
+
+ /** @var \Zend_Db_Adapter_Abstract */
+ protected $db;
+
+ /** @var DirectorDatafield[] */
+ protected $fields;
+
+ protected $elements;
+
+ protected $forceNull = array();
+
+ /** @var array Map element names to variable names 'elName' => 'varName' */
+ protected $nameMap = array();
+
+ public function __construct(IcingaObject $object)
+ {
+ $this->object = $object;
+ $this->connection = $object->getConnection();
+ $this->db = $this->connection->getDbAdapter();
+ }
+
+ public function addFieldsToForm(DirectorObjectForm $form)
+ {
+ if ($this->fields || $this->object->supportsFields()) {
+ $this->attachFieldsToForm($form);
+ }
+
+ return $this;
+ }
+
+ public function loadFieldsForMultipleObjects($objects)
+ {
+ $fields = array();
+ foreach ($objects as $object) {
+ foreach ($this->prepareObjectFields($object) as $varname => $field) {
+ $varname = $field->get('varname');
+ if (array_key_exists($varname, $fields)) {
+ if ($field->get('datatype') !== $fields[$varname]->datatype) {
+ unset($fields[$varname]);
+ }
+
+ continue;
+ }
+
+ $fields[$varname] = $field;
+ }
+ }
+
+ $this->fields = $fields;
+
+ return $this;
+ }
+
+ /**
+ * Set a list of values
+ *
+ * Works in a fail-safe way, when a field does not exist the value will be
+ * silently ignored
+ *
+ * @param array $values key/value pairs with variable names and their value
+ * @param string $prefix An optional prefix that would be stripped from keys
+ *
+ * @return IcingaObjectFieldLoader
+ *
+ * @throws IcingaException
+ */
+ public function setValues($values, $prefix = null)
+ {
+ if (! $this->object->supportsCustomVars()) {
+ return $this;
+ }
+
+ if ($prefix === null) {
+ $len = null;
+ } else {
+ $len = strlen($prefix);
+ }
+ $vars = $this->object->vars();
+
+ foreach ($values as $key => $value) {
+ if ($len !== null) {
+ if (substr($key, 0, $len) === $prefix) {
+ $key = substr($key, $len);
+ } else {
+ continue;
+ }
+ }
+
+ $varName = $this->getElementVarName($prefix . $key);
+ if ($varName === null) {
+ // throw new IcingaException(
+ // 'Cannot set variable value for "%s", got no such element',
+ // $key
+ // );
+
+ // Silently ignore additional fields. One might have switched
+ // template or command
+ continue;
+ }
+
+ $el = $this->getElement($varName);
+ if ($el === null) {
+ // throw new IcingaException('No such element %s', $key);
+ // Same here.
+ continue;
+ }
+
+ $el->setValue($value);
+ $value = $el->getValue();
+ if ($value === '' || $value === array()) {
+ $value = null;
+ }
+
+ $vars->set($varName, $value);
+ }
+
+ // Hint: this does currently not happen, as removeFilteredFields did not
+ // take place yet. This has been added to be on the safe side when
+ // cleaning things up one future day
+ foreach ($this->forceNull as $key) {
+ $vars->set($key, null);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Get the fields for our object
+ *
+ * @return DirectorDatafield[]
+ */
+ public function getFields()
+ {
+ if ($this->fields === null) {
+ $this->fields = $this->prepareObjectFields($this->object);
+ }
+
+ return $this->fields;
+ }
+
+ /**
+ * Get the form elements for our fields
+ *
+ * @param DirectorObjectForm $form Optional
+ *
+ * @return ZfElement[]
+ */
+ public function getElements(DirectorObjectForm $form = null)
+ {
+ if ($this->elements === null) {
+ $this->elements = $this->createElements($form);
+ $this->setValuesFromObject($this->object);
+ }
+
+ return $this->elements;
+ }
+
+ /**
+ * Prepare the form elements for our fields
+ *
+ * @param DirectorObjectForm $form Optional
+ *
+ * @return self
+ */
+ public function prepareElements(DirectorObjectForm $form = null)
+ {
+ if ($this->object->supportsFields()) {
+ $this->getElements($form);
+ }
+
+ return $this;
+ }
+
+ /**
+ * Attach our form fields to the given form
+ *
+ * This will also create a 'Custom properties' display group
+ *
+ * @param DirectorObjectForm $form
+ */
+ protected function attachFieldsToForm(DirectorObjectForm $form)
+ {
+ if ($this->fields === null) {
+ return;
+ }
+ $elements = $this->removeFilteredFields($this->getElements($form));
+
+ foreach ($elements as $element) {
+ $form->addElement($element);
+ }
+
+ $this->attachGroupElements($elements, $form);
+ }
+
+ /**
+ * @param ZfElement[] $elements
+ * @param DirectorObjectForm $form
+ */
+ protected function attachGroupElements(array $elements, DirectorObjectForm $form)
+ {
+ $categories = [];
+ $categoriesFetchedById = [];
+ foreach ($this->fields as $key => $field) {
+ if ($id = $field->get('category_id')) {
+ if (isset($categoriesFetchedById[$id])) {
+ $category = $categoriesFetchedById[$id];
+ } else {
+ $category = DirectorDatafieldCategory::loadWithAutoIncId($id, $form->getDb());
+ $categoriesFetchedById[$id] = $category;
+ }
+ } elseif ($field->hasCategory()) {
+ $category = $field->getCategory();
+ } else {
+ continue;
+ }
+ $categories[$key] = $category;
+ }
+ $prioIdx = \array_flip(\array_keys($categories));
+
+ foreach ($elements as $key => $element) {
+ if (isset($categories[$key])) {
+ $category = $categories[$key];
+ $form->addElementsToGroup(
+ [$element],
+ 'custom_fields:' . $category->get('category_name'),
+ DirectorObjectForm::GROUP_ORDER_CUSTOM_FIELD_CATEGORIES + $prioIdx[$key],
+ $category->get('category_name')
+ );
+ } else {
+ $form->addElementsToGroup(
+ [$element],
+ 'custom_fields',
+ DirectorObjectForm::GROUP_ORDER_CUSTOM_FIELDS,
+ $form->translate('Custom properties')
+ );
+ }
+ }
+ }
+
+ /**
+ * @param ZfElement[] $elements
+ * @return ZfElement[]
+ */
+ protected function removeFilteredFields(array $elements)
+ {
+ $filters = array();
+ foreach ($this->fields as $key => $field) {
+ if ($filter = $field->var_filter) {
+ $filters[$key] = Filter::fromQueryString($filter);
+ }
+ }
+
+ $kill = array();
+ $columns = array();
+ $object = $this->object;
+ if ($object instanceof IcingaHost) {
+ $prefix = 'host.vars.';
+ } elseif ($object instanceof IcingaService) {
+ $prefix = 'service.vars.';
+ } else {
+ return $elements;
+ }
+
+ $object->invalidateResolveCache();
+ $vars = $object::fromPlainObject(
+ $object->toPlainObject(true),
+ $object->getConnection()
+ )->getVars();
+
+ $prefixedVars = (object) array();
+ foreach ($vars as $k => $v) {
+ $prefixedVars->{$prefix . $k} = $v;
+ }
+
+ foreach ($filters as $key => $filter) {
+ ObjectApplyMatches::fixFilterColumns($filter);
+ /** @var $filter FilterChain|FilterExpression */
+ foreach ($filter->listFilteredColumns() as $column) {
+ $column = substr($column, strlen($prefix));
+ $columns[$column] = $column;
+ }
+ if (! $filter->matches($prefixedVars)) {
+ $kill[] = $key;
+ }
+ }
+
+ $vars = $object->vars();
+ foreach ($kill as $key) {
+ unset($elements[$key]);
+ $this->forceNull[$key] = $key;
+ // Hint: this should happen later on, currently execution order is
+ // a little bit weird
+ $vars->set($key, null);
+ }
+
+ foreach ($columns as $col) {
+ if (array_key_exists($col, $elements)) {
+ $el = $elements[$col];
+ $existingClass = $el->getAttrib('class');
+ if ($existingClass !== null && strlen($existingClass)) {
+ $el->setAttrib('class', $existingClass . ' autosubmit');
+ } else {
+ $el->setAttrib('class', 'autosubmit');
+ }
+ }
+ }
+
+ return $elements;
+ }
+
+ protected function getElementVarName($name)
+ {
+ if (array_key_exists($name, $this->nameMap)) {
+ return $this->nameMap[$name];
+ }
+
+ return null;
+ }
+
+ /**
+ * Get the form element for a specific field by it's variable name
+ *
+ * @param string $name
+ * @return null|ZfElement
+ */
+ protected function getElement($name)
+ {
+ $elements = $this->getElements();
+ if (array_key_exists($name, $elements)) {
+ return $this->elements[$name];
+ }
+
+ return null;
+ }
+
+ /**
+ * Get the form elements based on the given form
+ *
+ * @param DirectorObjectForm $form
+ *
+ * @return ZfElement[]
+ */
+ protected function createElements(DirectorObjectForm $form)
+ {
+ $elements = array();
+
+ foreach ($this->getFields() as $name => $field) {
+ $el = $field->getFormElement($form);
+ $elName = $el->getName();
+ if (array_key_exists($elName, $this->nameMap)) {
+ $form->addErrorMessage(sprintf(
+ 'Form element name collision, "%s" resolves to "%s", but this is also used for "%s"',
+ $name,
+ $elName,
+ $this->nameMap[$elName]
+ ));
+ }
+ $this->nameMap[$elName] = $name;
+ $elements[$name] = $el;
+ }
+
+ return $elements;
+ }
+
+ /**
+ * @param IcingaObject $object
+ */
+ protected function setValuesFromObject(IcingaObject $object)
+ {
+ foreach ($object->getVars() as $k => $v) {
+ if ($v !== null && $el = $this->getElement($k)) {
+ $el->setValue($v);
+ }
+ }
+ }
+
+ protected function mergeFields($listOfFields)
+ {
+ // TODO: Merge field for different object, mostly sets
+ }
+
+ /**
+ * Create the fields for our object
+ *
+ * @param IcingaObject $object
+ * @return DirectorDatafield[]
+ */
+ protected function prepareObjectFields($object)
+ {
+ $fields = $this->loadResolvedFieldsForObject($object);
+ if ($object->hasRelation('check_command')) {
+ try {
+ /** @var IcingaCommand $command */
+ $command = $object->getResolvedRelated('check_command');
+ } catch (Exception $e) {
+ // Ignore failures
+ $command = null;
+ }
+
+ if ($command) {
+ $cmdLoader = new static($command);
+ $cmdFields = $cmdLoader->getFields();
+ foreach ($cmdFields as $varname => $field) {
+ if (! array_key_exists($varname, $fields)) {
+ $fields[$varname] = $field;
+ }
+ }
+ }
+
+ // TODO -> filters!
+ }
+
+ return $fields;
+ }
+
+ /**
+ * Create the fields for our object
+ *
+ * Follows the inheritance logic, resolves all fields and keeps the most
+ * specific ones. Returns a list of fields indexed by variable name
+ *
+ * @param IcingaObject $object
+ *
+ * @return DirectorDatafield[]
+ */
+ protected function loadResolvedFieldsForObject(IcingaObject $object)
+ {
+ $result = $this->loadDataFieldsForObject(
+ $object
+ );
+
+ $fields = array();
+ foreach ($result as $objectId => $varFields) {
+ foreach ($varFields as $var => $field) {
+ $fields[$var] = $field;
+ }
+ }
+
+ return $fields;
+ }
+
+ /**
+ * @param IcingaObject[] $objectList List of objects
+ *
+ * @return array
+ */
+ protected function getIdsForObjectList($objectList)
+ {
+ $ids = [];
+ foreach ($objectList as $object) {
+ if ($object->hasBeenLoadedFromDb()) {
+ $ids[] = $object->get('id');
+ }
+ }
+
+ return $ids;
+ }
+
+ public function fetchFieldDetailsForObject(IcingaObject $object)
+ {
+ $ids = $object->listAncestorIds();
+ if ($id = $object->getProperty('id')) {
+ $ids[] = $id;
+ }
+ return $this->fetchFieldDetailsForIds($ids);
+ }
+
+ /***
+ * @param $objectIds
+ *
+ * @return \stdClass[]
+ */
+ protected function fetchFieldDetailsForIds($objectIds)
+ {
+ if (empty($objectIds)) {
+ return [];
+ }
+
+ $query = $this->prepareSelectForIds($objectIds);
+ return $this->db->fetchAll($query);
+ }
+
+ /**
+ * @param array $ids
+ *
+ * @return ZfSelect
+ */
+ protected function prepareSelectForIds(array $ids)
+ {
+ $object = $this->object;
+
+ $idColumn = 'f.' . $object->getShortTableName() . '_id';
+
+ $query = $this->db->select()->from(
+ array('df' => 'director_datafield'),
+ array(
+ 'object_id' => $idColumn,
+ 'icinga_type' => "('" . $object->getShortTableName() . "')",
+ 'var_filter' => 'f.var_filter',
+ 'is_required' => 'f.is_required',
+ 'id' => 'df.id',
+ 'category_id' => 'df.category_id',
+ 'varname' => 'df.varname',
+ 'caption' => 'df.caption',
+ 'description' => 'df.description',
+ 'datatype' => 'df.datatype',
+ 'format' => 'df.format',
+ )
+ )->join(
+ array('f' => $object->getTableName() . '_field'),
+ 'df.id = f.datafield_id',
+ array()
+ )->where($idColumn . ' IN (?)', $ids)
+ ->order('CASE WHEN var_filter IS NULL THEN 0 ELSE 1 END ASC')
+ ->order('df.caption ASC');
+
+ return $query;
+ }
+
+ /**
+ * Fetches fields for a given object
+ *
+ * Gives a list indexed by object id, with each entry being a list of that
+ * objects DirectorDatafield instances indexed by variable name
+ *
+ * @param IcingaObject $object
+ *
+ * @return array
+ */
+ public function loadDataFieldsForObject(IcingaObject $object)
+ {
+ $res = $this->fetchFieldDetailsForObject($object);
+
+ $result = [];
+ foreach ($res as $r) {
+ $id = $r->object_id;
+ unset($r->object_id);
+ if (! array_key_exists($id, $result)) {
+ $result[$id] = new stdClass;
+ }
+
+ $result[$id]->{$r->varname} = DirectorDatafield::fromDbRow(
+ $r,
+ $this->connection
+ );
+ }
+
+ foreach ($this->loadHookedDataFieldForObject($object) as $id => $fields) {
+ if (array_key_exists($id, $result)) {
+ foreach ($fields as $varName => $field) {
+ $result[$id]->$varName = $field;
+ }
+ } else {
+ $result[$id] = $fields;
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * @param IcingaObject $object
+ * @return array
+ */
+ protected function loadHookedDataFieldForObject(IcingaObject $object)
+ {
+ $fields = [];
+ if ($object instanceof IcingaHost || $object instanceof IcingaService) {
+ $fields = $this->addHookedFields($object);
+ }
+
+ return $fields;
+ }
+
+ /**
+ * @param IcingaObject $object
+ * @return mixed
+ */
+ protected function addHookedFields(IcingaObject $object)
+ {
+ $fields = [];
+ /** @var HostFieldHook|ServiceFieldHook $hook */
+ $type = ucfirst($object->getShortTableName());
+ foreach (Hook::all("Director\\${type}Field") as $hook) {
+ if ($hook->wants($object)) {
+ $id = $object->get('id');
+ $spec = $hook->getFieldSpec($object);
+ if (!array_key_exists($id, $fields)) {
+ $fields[$id] = new stdClass();
+ }
+ $fields[$id]->{$spec->getVarName()} = $spec->toDataField($object);
+ }
+ }
+ return $fields;
+ }
+}
diff --git a/library/Director/Web/Form/IconHelper.php b/library/Director/Web/Form/IconHelper.php
new file mode 100644
index 0000000..3add09b
--- /dev/null
+++ b/library/Director/Web/Form/IconHelper.php
@@ -0,0 +1,89 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form;
+
+use Icinga\Exception\ProgrammingError;
+
+/**
+ * Icon helper class
+ *
+ * Should help to reduce redundant icon-lookup code. Currently with hardcoded
+ * icons only, could easily provide support for all of them as follows:
+ *
+ * $confFile = Icinga::app()
+ * ->getApplicationDir('fonts/fontello-ifont/config.json');
+ *
+ * $font = json_decode(file_get_contents($confFile));
+ * // 'icon-' is to be found in $font->css_prefix_text
+ * foreach ($font->glyphs as $icon) {
+ * // $icon->css (= 'help') -> 0x . dechex($icon->code)
+ * }
+ */
+class IconHelper
+{
+ private $icons = array(
+ 'minus' => 'e806',
+ 'trash' => 'e846',
+ 'plus' => 'e805',
+ 'cancel' => 'e804',
+ 'help' => 'e85b',
+ 'angle-double-right' => 'e87b',
+ 'up-big' => 'e825',
+ 'down-big' => 'e828',
+ 'down-open' => 'e821',
+ );
+
+ private $mappedUtf8Icons;
+
+ private $reversedUtf8Icons;
+
+ private static $instance;
+
+ public function __construct()
+ {
+ $this->prepareIconMappings();
+ }
+
+ public static function instance()
+ {
+ if (self::$instance === null) {
+ self::$instance = new static;
+ }
+
+ return self::$instance;
+ }
+
+ public function characterIconName($character)
+ {
+ if (array_key_exists($character, $this->reversedUtf8Icons)) {
+ return $this->reversedUtf8Icons[$character];
+ } else {
+ throw new ProgrammingError('There is no mapping for the given character');
+ }
+ }
+
+ protected function hexToCharacter($hex)
+ {
+ return json_decode('"\u' . $hex . '"');
+ }
+
+ public function iconCharacter($name)
+ {
+ if (array_key_exists($name, $this->mappedUtf8Icons)) {
+ return $this->mappedUtf8Icons[$name];
+ } else {
+ return $this->mappedUtf8Icons['help'];
+ }
+ }
+
+ protected function prepareIconMappings()
+ {
+ $this->mappedUtf8Icons = array();
+ $this->reversedUtf8Icons = array();
+ foreach ($this->icons as $name => $hex) {
+ $character = $this->hexToCharacter($hex);
+ $this->mappedUtf8Icons[$name] = $character;
+ $this->reversedUtf8Icons[$character] = $name;
+ }
+ }
+}
diff --git a/library/Director/Web/Form/IplElement/ExtensibleSetElement.php b/library/Director/Web/Form/IplElement/ExtensibleSetElement.php
new file mode 100644
index 0000000..a4dbb20
--- /dev/null
+++ b/library/Director/Web/Form/IplElement/ExtensibleSetElement.php
@@ -0,0 +1,570 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form\IplElement;
+
+use Icinga\Exception\ProgrammingError;
+use Icinga\Module\Director\IcingaConfig\ExtensibleSet as Set;
+use Icinga\Module\Director\Web\Form\IconHelper;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use gipfl\Translation\TranslationHelper;
+
+class ExtensibleSetElement extends BaseHtmlElement
+{
+ use TranslationHelper;
+
+ protected $tag = 'ul';
+
+ /** @var Set */
+ protected $set;
+
+ private $id;
+
+ private $name;
+
+ private $value;
+
+ private $description;
+
+ private $multiOptions;
+
+ private $validOptions;
+
+ private $chosenOptionCount = 0;
+
+ private $suggestionContext;
+
+ private $sorted = false;
+
+ private $disabled = false;
+
+ private $remainingAttribs;
+
+ private $hideOptions = [];
+
+ private $inherited;
+
+ private $inheritedFrom;
+
+ protected $defaultAttributes = [
+ 'class' => 'extensible-set'
+ ];
+
+ protected function __construct($name)
+ {
+ $this->name = $this->id = $name;
+ }
+
+ public function hideOptions($options)
+ {
+ $this->hideOptions = array_merge($this->hideOptions, $options);
+ return $this;
+ }
+
+ private function setMultiOptions($options)
+ {
+ $this->multiOptions = $options;
+ $this->validOptions = $this->flattenOptions($options);
+ }
+
+ protected function isValidOption($option)
+ {
+ if ($this->validOptions === null) {
+ if ($this->suggestionContext === null) {
+ return true;
+ } else {
+ // TODO: ask suggestionContext, if any
+ return true;
+ }
+ } else {
+ return in_array($option, $this->validOptions);
+ }
+ }
+
+ private function disable($disable = true)
+ {
+ $this->disabled = (bool) $disable;
+ }
+
+ private function isDisabled()
+ {
+ return $this->disabled;
+ }
+
+ private function isSorted()
+ {
+ return $this->sorted;
+ }
+
+ public function setValue($value)
+ {
+ if ($value instanceof Set) {
+ $value = $value->toPlainObject();
+ }
+
+ if (is_array($value)) {
+ $value = array_filter($value, 'strlen');
+ }
+
+ if (null !== $value && ! is_array($value)) {
+ throw new ProgrammingError(
+ 'Got unexpected value, no array: %s',
+ var_export($value, 1)
+ );
+ }
+
+ $this->value = $value;
+ return $this;
+ }
+
+ protected function extractZfInfo(&$attribs = null)
+ {
+ if ($attribs === null) {
+ return;
+ }
+
+ foreach (['id', 'name', 'descriptions'] as $key) {
+ if (array_key_exists($key, $attribs)) {
+ $this->$key = $attribs[$key];
+ unset($attribs[$key]);
+ }
+ }
+ if (array_key_exists('disable', $attribs)) {
+ $this->disable($attribs['disable']);
+ unset($attribs['disable']);
+ }
+ if (array_key_exists('value', $attribs)) {
+ $this->setValue($attribs['value']);
+ unset($attribs['value']);
+ }
+ if (array_key_exists('inherited', $attribs)) {
+ $this->inherited = $attribs['inherited'];
+ unset($attribs['inherited']);
+ }
+ if (array_key_exists('inheritedFrom', $attribs)) {
+ $this->inheritedFrom = $attribs['inheritedFrom'];
+ unset($attribs['inheritedFrom']);
+ }
+
+ if (array_key_exists('multiOptions', $attribs)) {
+ $this->setMultiOptions($attribs['multiOptions']);
+ unset($attribs['multiOptions']);
+ }
+
+ if (array_key_exists('hideOptions', $attribs)) {
+ $this->hideOptions($attribs['hideOptions']);
+ unset($attribs['hideOptions']);
+ }
+
+ if (array_key_exists('sorted', $attribs)) {
+ $this->sorted = (bool) $attribs['sorted'];
+ unset($attribs['sorted']);
+ }
+
+ if (array_key_exists('description', $attribs)) {
+ $this->description = $attribs['description'];
+ unset($attribs['description']);
+ }
+
+ if (array_key_exists('suggest', $attribs)) {
+ $this->suggestionContext = $attribs['suggest'];
+ unset($attribs['suggest']);
+ }
+
+ if (! empty($attribs)) {
+ $this->remainingAttribs = $attribs;
+ }
+ }
+
+ /**
+ * Generates an 'extensible set' element.
+ *
+ * @codingStandardsIgnoreEnd
+ *
+ * @param string|array $name If a string, the element name. If an
+ * array, all other parameters are ignored, and the array elements
+ * are used in place of added parameters.
+ *
+ * @param mixed $value The element value.
+ *
+ * @param array $attribs Attributes for the element tag.
+ *
+ * @return string The element XHTML.
+ */
+ public static function fromZfDingens($name, $value = null, $attribs = null)
+ {
+ $el = new static($name);
+ $el->extractZfInfo($attribs);
+ $el->setValue($value);
+ return $el->render();
+ }
+
+ protected function assemble()
+ {
+ $this->addChosenOptions();
+ $this->addAddMore();
+
+ if ($this->isSorted()) {
+ $this->getAttributes()->add('class', 'sortable');
+ }
+ if (null !== $this->description) {
+ $this->addDescription($this->description);
+ }
+ }
+
+ private function eventuallyAddAutosuggestion(BaseHtmlElement $element)
+ {
+ if ($this->suggestionContext !== null) {
+ $attrs = $element->getAttributes();
+ $attrs->add('class', 'director-suggest');
+ $attrs->set([
+ 'data-suggestion-context' => $this->suggestionContext,
+ ]);
+ }
+
+ return $element;
+ }
+
+ private function hasAvailableMultiOptions()
+ {
+ return count($this->multiOptions) > 1 || strlen(key($this->multiOptions));
+ }
+
+ private function addAddMore()
+ {
+ $cnt = $this->chosenOptionCount;
+
+ if ($this->multiOptions) {
+ if (! $this->hasAvailableMultiOptions()) {
+ return;
+ }
+ $field = Html::tag('select', ['class' => 'autosubmit']);
+ $more = $this->inherited === null
+ ? $this->translate('- add more -')
+ : $this->getInheritedInfo();
+ $field->add(Html::tag('option', [
+ 'value' => '',
+ 'tabindex' => '-1'
+ ], $more));
+
+ foreach ($this->multiOptions as $key => $label) {
+ if ($key === null) {
+ $key = '';
+ }
+ if (is_array($label)) {
+ $optGroup = Html::tag('optgroup', ['label' => $key]);
+ foreach ($label as $grpKey => $grpLabel) {
+ $optGroup->add(
+ Html::tag('option', ['value' => $grpKey], $grpLabel)
+ );
+ }
+ $field->add($optGroup);
+ } else {
+ $option = Html::tag('option', ['value' => $key], $label);
+ $field->add($option);
+ }
+ }
+ } else {
+ $field = Html::tag('input', [
+ 'type' => 'text',
+ 'placeholder' => $this->inherited === null
+ ? $this->translate('Add a new one...')
+ : $this->getInheritedInfo(),
+ ]);
+ }
+ $field->addAttributes([
+ 'id' => $this->id . $this->suffix($cnt),
+ 'name' => $this->name . '[]',
+ ]);
+ $this->eventuallyAddAutosuggestion(
+ $this->addRemainingAttributes(
+ $this->eventuallyDisable($field)
+ )
+ );
+ if ($cnt !== 0) { // TODO: was === 0?!
+ $field->getAttributes()->add('class', 'extend-set');
+ }
+
+ if ($this->suggestionContext === null) {
+ $this->add(Html::tag('li', null, [
+ $this->createAddNewButton(),
+ $field
+ ]));
+ } else {
+ $this->add(Html::tag('li', null, [
+ $this->newInlineButtons(
+ $this->renderDropDownButton()
+ ),
+ $field
+ ]));
+ }
+ }
+
+ private function getInheritedInfo()
+ {
+ if ($this->inheritedFrom === null) {
+ return \sprintf(
+ $this->translate('%s (inherited)'),
+ $this->stringifyInheritedValue()
+ );
+ } else {
+ return \sprintf(
+ $this->translate('%s (inherited from %s)'),
+ $this->stringifyInheritedValue(),
+ $this->inheritedFrom
+ );
+ }
+ }
+
+ private function stringifyInheritedValue()
+ {
+ if (\is_array($this->inherited)) {
+ return \implode(', ', $this->inherited);
+ } else {
+ return \sprintf(
+ $this->translate('%s (not an Array!)'),
+ \var_export($this->inherited, 1)
+ );
+ }
+ }
+
+ private function createAddNewButton()
+ {
+ return $this->newInlineButtons(
+ $this->eventuallyDisable($this->renderAddButton())
+ );
+ }
+
+ private function addChosenOptions()
+ {
+ if (null === $this->value) {
+ return;
+ }
+ $total = count($this->value);
+
+ foreach ($this->value as $val) {
+ if (in_array($val, $this->hideOptions)) {
+ continue;
+ }
+
+ if ($this->multiOptions !== null) {
+ if ($this->isValidOption($val)) {
+ $this->multiOptions = $this->removeOption(
+ $this->multiOptions,
+ $val
+ );
+ // TODO:
+ // $this->removeOption($val);
+ }
+ }
+
+ $text = Html::tag('input', [
+ 'type' => 'text',
+ 'name' => $this->name . '[]',
+ 'id' => $this->id . $this->suffix($this->chosenOptionCount),
+ 'value' => $val
+ ]);
+ $text->getAttributes()->set([
+ 'autocomplete' => 'off',
+ 'autocorrect' => 'off',
+ 'autocapitalize' => 'off',
+ 'spellcheck' => 'false',
+ ]);
+
+ $this->addRemainingAttributes($this->eventuallyDisable($text));
+ $this->add(Html::tag('li', null, [
+ $this->getOptionButtons($this->chosenOptionCount, $total),
+ $text
+ ]));
+ $this->chosenOptionCount++;
+ }
+ }
+
+ private function addRemainingAttributes(BaseHtmlElement $element)
+ {
+ if ($this->remainingAttribs !== null) {
+ $element->getAttributes()->add($this->remainingAttribs);
+ }
+
+ return $element;
+ }
+
+ private function eventuallyDisable(BaseHtmlElement $element)
+ {
+ if ($this->isDisabled()) {
+ $this->disableElement($element);
+ }
+
+ return $element;
+ }
+
+ private function disableElement(BaseHtmlElement $element)
+ {
+ $element->getAttributes()->set('disabled', 'disabled');
+ return $element;
+ }
+
+ private function disableIf(BaseHtmlElement $element, $condition)
+ {
+ if ($condition) {
+ $this->disableElement($element);
+ }
+
+ return $element;
+ }
+
+ private function getOptionButtons($cnt, $total)
+ {
+ if ($this->isDisabled()) {
+ return [];
+ }
+ $first = $cnt === 0;
+ $last = $cnt === $total - 1;
+ $name = $this->name;
+ $buttons = $this->newInlineButtons();
+ if ($this->isSorted()) {
+ $buttons->add([
+ $this->disableIf($this->renderDownButton($name, $cnt), $last),
+ $this->disableIf($this->renderUpButton($name, $cnt), $first)
+ ]);
+ }
+
+ $buttons->add($this->renderDeleteButton($name, $cnt));
+
+ return $buttons;
+ }
+
+ protected function newInlineButtons($content = null)
+ {
+ return Html::tag('span', ['class' => 'inline-buttons'], $content);
+ }
+
+ protected function addDescription($description)
+ {
+ $this->add(
+ Html::tag('p', ['class' => 'description'], $description)
+ );
+ }
+
+ private function flattenOptions($options)
+ {
+ $flat = array();
+
+ foreach ($options as $key => $option) {
+ if (is_array($option)) {
+ foreach ($option as $k => $o) {
+ $flat[] = $k;
+ }
+ } else {
+ $flat[] = $key;
+ }
+ }
+
+ return $flat;
+ }
+
+ private function removeOption($options, $option)
+ {
+ $unset = array();
+ foreach ($options as $key => & $value) {
+ if (is_array($value)) {
+ $value = $this->removeOption($value, $option);
+ if (empty($value)) {
+ $unset[] = $key;
+ }
+ } elseif ($key === $option) {
+ $unset[] = $key;
+ }
+ }
+
+ foreach ($unset as $key) {
+ unset($options[$key]);
+ }
+
+ return $options;
+ }
+
+ private function suffix($cnt)
+ {
+ if ($cnt === 0) {
+ return '';
+ } else {
+ return '_' . $cnt;
+ }
+ }
+
+ private function renderDropDownButton()
+ {
+ return $this->createRelatedAction(
+ 'drop-down',
+ $this->name,
+ $this->translate('Show available options'),
+ 'down-open'
+ );
+ }
+
+ private function renderAddButton()
+ {
+ return $this->createRelatedAction(
+ 'add',
+ // This would interfere with how PHP resolves _POST arrays. So we
+ // use a fake name for now, that way the button will be ignored and
+ // behave similar to an auto-submission
+ 'X_' . $this->name,
+ $this->translate('Add a new entry'),
+ 'plus'
+ );
+ }
+
+ private function renderDeleteButton($name, $cnt)
+ {
+ return $this->createRelatedAction(
+ 'remove',
+ $name . '_' . $cnt,
+ $this->translate('Remove this entry'),
+ 'cancel'
+ );
+ }
+
+ private function renderUpButton($name, $cnt)
+ {
+ return $this->createRelatedAction(
+ 'move-up',
+ $name . '_' . $cnt,
+ $this->translate('Move up'),
+ 'up-big'
+ );
+ }
+
+ private function renderDownButton($name, $cnt)
+ {
+ return $this->createRelatedAction(
+ 'move-down',
+ $name . '_' . $cnt,
+ $this->translate('Move down'),
+ 'down-big'
+ );
+ }
+
+ protected function makeActionName($name, $action)
+ {
+ return $name . '__' . str_replace('-', '_', strtoupper($action));
+ }
+
+ protected function createRelatedAction(
+ $action,
+ $name,
+ $title,
+ $icon
+ ) {
+ $input = Html::tag('input', [
+ 'type' => 'submit',
+ 'class' => ['related-action', 'action-' . $action],
+ 'name' => $this->makeActionName($name, $action),
+ 'value' => IconHelper::instance()->iconCharacter($icon),
+ 'title' => $title
+ ]);
+
+ return $input;
+ }
+}
diff --git a/library/Director/Web/Form/QuickBaseForm.php b/library/Director/Web/Form/QuickBaseForm.php
new file mode 100644
index 0000000..8d25ffb
--- /dev/null
+++ b/library/Director/Web/Form/QuickBaseForm.php
@@ -0,0 +1,177 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form;
+
+use Icinga\Application\Icinga;
+use Icinga\Application\Modules\Module;
+use ipl\Html\Html;
+use ipl\Html\ValidHtml;
+use Zend_Form;
+
+abstract class QuickBaseForm extends Zend_Form implements ValidHtml
+{
+ /**
+ * The Icinga module this form belongs to. Usually only set if the
+ * form is initialized through the FormLoader
+ *
+ * @var Module
+ */
+ protected $icingaModule;
+
+ protected $icingaModuleName;
+
+ private $hintCount = 0;
+
+ public function __construct($options = null)
+ {
+ $this->callZfConstructor($this->handleOptions($options))
+ ->initializePrefixPaths();
+ }
+
+ protected function callZfConstructor($options = null)
+ {
+ parent::__construct($options);
+ return $this;
+ }
+
+ protected function initializePrefixPaths()
+ {
+ $this->addPrefixPathsForDirector();
+ if ($this->icingaModule && $this->icingaModuleName !== 'director') {
+ $this->addPrefixPathsForModule($this->icingaModule);
+ }
+ }
+
+ protected function addPrefixPathsForDirector()
+ {
+ $module = Icinga::app()
+ ->getModuleManager()
+ ->loadModule('director')
+ ->getModule('director');
+
+ $this->addPrefixPathsForModule($module);
+ }
+
+ public function addPrefixPathsForModule(Module $module)
+ {
+ $basedir = sprintf(
+ '%s/%s/Web/Form',
+ $module->getLibDir(),
+ ucfirst($module->getName())
+ );
+
+ $this->addPrefixPath(
+ __NAMESPACE__ . '\\Element\\',
+ $basedir . '/Element',
+ static::ELEMENT
+ );
+
+ return $this;
+ }
+
+ public function addHidden($name, $value = null)
+ {
+ $this->addElement('hidden', $name);
+ $el = $this->getElement($name);
+ $el->setDecorators(array('ViewHelper'));
+ if ($value !== null) {
+ $this->setDefault($name, $value);
+ $el->setValue($value);
+ }
+
+ return $this;
+ }
+
+ // TODO: Should be an element
+ public function addHtmlHint($html, $options = [])
+ {
+ return $this->addHtml(
+ Html::tag('div', ['class' => 'hint'], $html),
+ $options
+ );
+ }
+
+ public function addHtml($html, $options = [])
+ {
+ if ($html instanceof ValidHtml) {
+ $html = $html->render();
+ }
+
+ if (array_key_exists('name', $options)) {
+ $name = $options['name'];
+ unset($options['name']);
+ } else {
+ $name = '_HINT' . ++$this->hintCount;
+ }
+
+ $this->addElement('simpleNote', $name, $options);
+ $this->getElement($name)
+ ->setValue($html)
+ ->setIgnore(true)
+ ->setDecorators(array('ViewHelper'));
+
+ return $this;
+ }
+
+ public function optionalEnum($enum, $nullLabel = null)
+ {
+ if ($nullLabel === null) {
+ $nullLabel = $this->translate('- please choose -');
+ }
+
+ return array(null => $nullLabel) + $enum;
+ }
+
+ protected function handleOptions($options = null)
+ {
+ if ($options === null) {
+ return $options;
+ }
+
+ if (array_key_exists('icingaModule', $options)) {
+ /** @var Module icingaModule */
+ $this->icingaModule = $options['icingaModule'];
+ $this->icingaModuleName = $this->icingaModule->getName();
+ unset($options['icingaModule']);
+ }
+
+ return $options;
+ }
+
+ public function setIcingaModule(Module $module)
+ {
+ $this->icingaModule = $module;
+ return $this;
+ }
+
+ protected function loadForm($name, Module $module = null)
+ {
+ if ($module === null) {
+ $module = $this->icingaModule;
+ }
+
+ return FormLoader::load($name, $module);
+ }
+
+ protected function valueIsEmpty($value)
+ {
+ if ($value === null) {
+ return true;
+ }
+
+ if (is_array($value)) {
+ return empty($value);
+ }
+
+ return strlen($value) === 0;
+ }
+
+ public function translate($string)
+ {
+ if ($this->icingaModuleName === null) {
+ return t($string);
+ } else {
+ return mt($this->icingaModuleName, $string);
+ }
+ }
+}
diff --git a/library/Director/Web/Form/QuickForm.php b/library/Director/Web/Form/QuickForm.php
new file mode 100644
index 0000000..91c8f00
--- /dev/null
+++ b/library/Director/Web/Form/QuickForm.php
@@ -0,0 +1,641 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form;
+
+use Icinga\Application\Icinga;
+use Icinga\Web\Notification;
+use Icinga\Web\Request;
+use Icinga\Web\Response;
+use Icinga\Web\Url;
+use InvalidArgumentException;
+use Exception;
+use RuntimeException;
+
+/**
+ * QuickForm wants to be a base class for simple forms
+ */
+abstract class QuickForm extends QuickBaseForm
+{
+ const ID = '__FORM_NAME';
+
+ const CSRF = '__FORM_CSRF';
+
+ /**
+ * The name of this form
+ */
+ protected $formName;
+
+ /**
+ * Whether the form has been sent
+ */
+ protected $hasBeenSent;
+
+ /**
+ * Whether the form has been sent
+ */
+ protected $hasBeenSubmitted;
+
+ /**
+ * The submit caption, element - still tbd
+ */
+ // protected $submit;
+
+ /**
+ * Our request
+ */
+ protected $request;
+
+ protected $successUrl;
+
+ protected $successMessage;
+
+ protected $submitLabel;
+
+ protected $submitButtonName;
+
+ protected $deleteButtonName;
+
+ protected $fakeSubmitButtonName;
+
+ /**
+ * Whether form elements have already been created
+ */
+ protected $didSetup = false;
+
+ protected $isApiRequest = false;
+
+ protected $successCallbacks = [];
+
+ protected $calledSuccessCallbacks = false;
+
+ protected $onRequestCallbacks = [];
+
+ protected $calledOnRequestCallbacks = false;
+
+ public function __construct($options = null)
+ {
+ parent::__construct($options);
+
+ $this->setMethod('post');
+ $this->getActionFromRequest()
+ ->createIdElement()
+ ->regenerateCsrfToken()
+ ->setPreferredDecorators();
+ }
+
+ protected function getActionFromRequest()
+ {
+ $this->setAction(Url::fromRequest());
+ return $this;
+ }
+
+ protected function setPreferredDecorators()
+ {
+ $current = $this->getAttrib('class');
+ $current .= ' director-form';
+ if ($current) {
+ $this->setAttrib('class', "$current autofocus");
+ } else {
+ $this->setAttrib('class', 'autofocus');
+ }
+ $this->setDecorators(
+ array(
+ 'Description',
+ array('FormErrors', array('onlyCustomFormErrors' => true)),
+ 'FormElements',
+ 'Form'
+ )
+ );
+
+ return $this;
+ }
+
+ protected function addSubmitButton($label, $options = [])
+ {
+ $el = $this->createElement('submit', $label, $options)
+ ->setLabel($label)
+ ->setDecorators(array('ViewHelper'));
+ $this->submitButtonName = $el->getName();
+ $this->setSubmitLabel($label);
+ $this->addElement($el);
+ }
+
+ protected function addStandaloneSubmitButton($label, $options = [])
+ {
+ $this->addSubmitButton($label, $options);
+ $this->addDisplayGroup([$this->submitButtonName], 'buttons', array(
+ 'decorators' => array(
+ 'FormElements',
+ array('HtmlTag', array('tag' => 'p')),
+ ),
+ 'order' => 1000,
+ ));
+ }
+
+ protected function addSubmitButtonIfSet()
+ {
+ if (false === ($label = $this->getSubmitLabel())) {
+ return;
+ }
+
+ if ($this->submitButtonName && $el = $this->getElement($this->submitButtonName)) {
+ return;
+ }
+
+ $this->addSubmitButton($label);
+
+ $fakeEl = $this->createElement('submit', '_FAKE_SUBMIT', array(
+ 'role' => 'none',
+ 'tabindex' => '-1',
+ ))
+ ->setLabel($label)
+ ->setDecorators(array('ViewHelper'));
+ $this->fakeSubmitButtonName = $fakeEl->getName();
+ $this->addElement($fakeEl);
+
+ $this->addDisplayGroup(
+ array($this->fakeSubmitButtonName),
+ 'fake_button',
+ array(
+ 'decorators' => array('FormElements'),
+ 'order' => 1,
+ )
+ );
+
+ $this->addButtonDisplayGroup();
+ }
+
+ protected function addButtonDisplayGroup()
+ {
+ $grp = array(
+ $this->submitButtonName,
+ $this->deleteButtonName
+ );
+ $this->addDisplayGroup($grp, 'buttons', array(
+ 'decorators' => array(
+ 'FormElements',
+ array('HtmlTag', array('tag' => 'dl')),
+ 'DtDdWrapper',
+ ),
+ 'order' => 1000,
+ ));
+ }
+
+ protected function addSimpleDisplayGroup($elements, $name, $options)
+ {
+ if (! array_key_exists('decorators', $options)) {
+ $options['decorators'] = array(
+ 'FormElements',
+ array('HtmlTag', array('tag' => 'dl')),
+ 'Fieldset',
+ );
+ }
+
+ return $this->addDisplayGroup($elements, $name, $options);
+ }
+
+ protected function createIdElement()
+ {
+ if ($this->isApiRequest()) {
+ return $this;
+ }
+ $this->detectName();
+ $this->addHidden(self::ID, $this->getName());
+ $this->getElement(self::ID)->setIgnore(true);
+ return $this;
+ }
+
+ public function getSentValue($name, $default = null)
+ {
+ $request = $this->getRequest();
+ if ($request->isPost() && $this->hasBeenSent()) {
+ return $request->getPost($name);
+ } else {
+ return $default;
+ }
+ }
+
+ public function getSubmitLabel()
+ {
+ if ($this->submitLabel === null) {
+ return $this->translate('Submit');
+ }
+
+ return $this->submitLabel;
+ }
+
+ public function setSubmitLabel($label)
+ {
+ $this->submitLabel = $label;
+ return $this;
+ }
+
+ public function setApiRequest($isApiRequest = true)
+ {
+ $this->isApiRequest = $isApiRequest;
+ return $this;
+ }
+
+ public function isApiRequest()
+ {
+ if ($this->isApiRequest === null) {
+ if ($this->request === null) {
+ throw new RuntimeException(
+ 'Early access to isApiRequest(). This is not possible, sorry'
+ );
+ }
+
+ return $this->getRequest()->isApiRequest();
+ } else {
+ return $this->isApiRequest;
+ }
+ }
+
+ public function regenerateCsrfToken()
+ {
+ if ($this->isApiRequest()) {
+ return $this;
+ }
+ if (! $element = $this->getElement(self::CSRF)) {
+ $this->addHidden(self::CSRF, CsrfToken::generate());
+ $element = $this->getElement(self::CSRF);
+ }
+ $element->setIgnore(true);
+
+ return $this;
+ }
+
+ public function removeCsrfToken()
+ {
+ $this->removeElement(self::CSRF);
+ return $this;
+ }
+
+ public function setSuccessUrl($url, $params = null)
+ {
+ if (! $url instanceof Url) {
+ $url = Url::fromPath($url);
+ }
+ if ($params !== null) {
+ $url->setParams($params);
+ }
+ $this->successUrl = $url;
+ return $this;
+ }
+
+ public function getSuccessUrl()
+ {
+ $url = $this->successUrl ?: $this->getAction();
+ if (! $url instanceof Url) {
+ $url = Url::fromPath($url);
+ }
+
+ return $url;
+ }
+
+ protected function beforeSetup()
+ {
+ }
+
+ public function setup()
+ {
+ }
+
+ protected function onSetup()
+ {
+ }
+
+ public function setAction($action)
+ {
+ if ($action instanceof Url) {
+ $action = $action->getAbsoluteUrl('&');
+ }
+
+ return parent::setAction($action);
+ }
+
+ public function hasBeenSubmitted()
+ {
+ if ($this->hasBeenSubmitted === null) {
+ $req = $this->getRequest();
+ if ($req->isApiRequest()) {
+ return $this->hasBeenSubmitted = true;
+ }
+ if ($req->isPost()) {
+ if (! $this->hasSubmitButton()) {
+ return $this->hasBeenSubmitted = $this->hasBeenSent();
+ }
+
+ $this->hasBeenSubmitted = $this->pressedButton(
+ $this->fakeSubmitButtonName,
+ $this->getSubmitLabel()
+ ) || $this->pressedButton(
+ $this->submitButtonName,
+ $this->getSubmitLabel()
+ );
+ } else {
+ $this->hasBeenSubmitted = false;
+ }
+ }
+
+ return $this->hasBeenSubmitted;
+ }
+
+ protected function hasSubmitButton()
+ {
+ return $this->submitButtonName !== null;
+ }
+
+ protected function pressedButton($name, $label)
+ {
+ $req = $this->getRequest();
+ if (! $req->isPost()) {
+ return false;
+ }
+
+ $req = $this->getRequest();
+ $post = $req->getPost();
+
+ return array_key_exists($name, $post)
+ && $post[$name] === $label;
+ }
+
+ protected function beforeValidation($data = array())
+ {
+ }
+
+ public function prepareElements()
+ {
+ if (! $this->didSetup) {
+ $this->beforeSetup();
+ $this->setup();
+ $this->onSetup();
+ $this->didSetup = true;
+ }
+
+ return $this;
+ }
+
+ public function handleRequest(Request $request = null)
+ {
+ if ($request === null) {
+ $request = $this->getRequest();
+ } else {
+ $this->setRequest($request);
+ }
+
+ $this->prepareElements();
+ $this->addSubmitButtonIfSet();
+
+ if ($this->hasBeenSent()) {
+ $post = $request->getPost();
+ if ($this->hasBeenSubmitted()) {
+ $this->beforeValidation($post);
+ if ($this->isValid($post)) {
+ try {
+ $this->onSuccess();
+ $this->callOnSuccessCallables();
+ } catch (Exception $e) {
+ $this->addException($e);
+ $this->onFailure();
+ }
+ } else {
+ $this->onFailure();
+ }
+ } else {
+ $this->setDefaults($post);
+ }
+ }
+
+ return $this;
+ }
+
+ public function addException(Exception $e, $elementName = null)
+ {
+ $msg = $this->getErrorMessageForException($e);
+ if ($el = $this->getElement($elementName)) {
+ $el->addError($msg);
+ } else {
+ $this->addError($msg);
+ }
+ }
+
+ public function addUniqueErrorMessage($msg)
+ {
+ if (! in_array($msg, $this->getErrorMessages())) {
+ $this->addErrorMessage($msg);
+ }
+
+ return $this;
+ }
+
+ public function addUniqueException(Exception $e)
+ {
+ $msg = $this->getErrorMessageForException($e);
+
+ if (! in_array($msg, $this->getErrorMessages())) {
+ $this->addErrorMessage($msg);
+ }
+
+ return $this;
+ }
+
+ protected function getErrorMessageForException(Exception $e)
+ {
+ $file = preg_split('/[\/\\\]/', $e->getFile(), -1, PREG_SPLIT_NO_EMPTY);
+ $file = array_pop($file);
+ return sprintf(
+ '%s (%s:%d)',
+ $e->getMessage(),
+ $file,
+ $e->getLine()
+ );
+ }
+
+ public function onSuccess()
+ {
+ $this->redirectOnSuccess();
+ }
+
+ /**
+ * @param callable $callable
+ * @return $this
+ */
+ public function callOnRequest($callable)
+ {
+ if (! is_callable($callable)) {
+ throw new InvalidArgumentException(
+ 'callOnRequest() expects a callable'
+ );
+ }
+ $this->onRequestCallbacks[] = $callable;
+
+ return $this;
+ }
+
+ protected function callOnRequestCallables()
+ {
+ if (! $this->calledOnRequestCallbacks) {
+ $this->calledOnRequestCallbacks = true;
+ foreach ($this->onRequestCallbacks as $callable) {
+ $callable($this);
+ }
+ }
+ }
+
+ /**
+ * @param callable $callable
+ * @return $this
+ */
+ public function callOnSuccess($callable)
+ {
+ if (! is_callable($callable)) {
+ throw new InvalidArgumentException(
+ 'callOnSuccess() expects a callable'
+ );
+ }
+ $this->successCallbacks[] = $callable;
+
+ return $this;
+ }
+
+ protected function callOnSuccessCallables()
+ {
+ if (! $this->calledSuccessCallbacks) {
+ $this->calledSuccessCallbacks = true;
+ foreach ($this->successCallbacks as $callable) {
+ $callable($this);
+ }
+ }
+ }
+
+ public function setSuccessMessage($message)
+ {
+ $this->successMessage = $message;
+ return $this;
+ }
+
+ public function getSuccessMessage($message = null)
+ {
+ if ($message !== null) {
+ return $message;
+ }
+ if ($this->successMessage === null) {
+ return t('Form has successfully been sent');
+ }
+ return $this->successMessage;
+ }
+
+ public function redirectOnSuccess($message = null)
+ {
+ if ($this->isApiRequest()) {
+ // TODO: Set the status line message?
+ $this->successMessage = $this->getSuccessMessage($message);
+ $this->callOnSuccessCallables();
+ return;
+ }
+
+ $url = $this->getSuccessUrl();
+ $this->callOnSuccessCallables();
+ $this->notifySuccess($this->getSuccessMessage($message));
+ $this->redirectAndExit($url);
+ }
+
+ public function onFailure()
+ {
+ }
+
+ public function notifySuccess($message = null)
+ {
+ if ($message === null) {
+ $message = t('Form has successfully been sent');
+ }
+ Notification::success($message);
+ return $this;
+ }
+
+ public function notifyError($message)
+ {
+ Notification::error($message);
+ return $this;
+ }
+
+ protected function redirectAndExit($url)
+ {
+ /** @var Response $response */
+ $response = Icinga::app()->getFrontController()->getResponse();
+ $response->redirectAndExit($url);
+ }
+
+ protected function setHttpResponseCode($code)
+ {
+ Icinga::app()->getFrontController()->getResponse()->setHttpResponseCode($code);
+ return $this;
+ }
+
+ protected function onRequest()
+ {
+ $this->callOnRequestCallables();
+ }
+
+ public function setRequest(Request $request)
+ {
+ if ($this->request !== null) {
+ throw new RuntimeException('Unable to set request twice');
+ }
+
+ $this->request = $request;
+ $this->prepareElements();
+ $this->onRequest();
+ $this->callOnRequestCallables();
+
+ return $this;
+ }
+
+ /**
+ * @return Request
+ */
+ public function getRequest()
+ {
+ if ($this->request === null) {
+ /** @var Request $request */
+ $request = Icinga::app()->getFrontController()->getRequest();
+ $this->setRequest($request);
+ }
+ return $this->request;
+ }
+
+ public function hasBeenSent()
+ {
+ if ($this->hasBeenSent === null) {
+
+ /** @var Request $req */
+ if ($this->request === null) {
+ $req = Icinga::app()->getFrontController()->getRequest();
+ } else {
+ $req = $this->request;
+ }
+
+ if ($req->isApiRequest()) {
+ $this->hasBeenSent = true;
+ } elseif ($req->isPost()) {
+ $post = $req->getPost();
+ $this->hasBeenSent = array_key_exists(self::ID, $post) &&
+ $post[self::ID] === $this->getName();
+ } else {
+ $this->hasBeenSent = false;
+ }
+ }
+
+ return $this->hasBeenSent;
+ }
+
+ protected function detectName()
+ {
+ if ($this->formName !== null) {
+ $this->setName($this->formName);
+ } else {
+ $this->setName(get_class($this));
+ }
+ }
+}
diff --git a/library/Director/Web/Form/QuickSubForm.php b/library/Director/Web/Form/QuickSubForm.php
new file mode 100644
index 0000000..2487d35
--- /dev/null
+++ b/library/Director/Web/Form/QuickSubForm.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form;
+
+abstract class QuickSubForm extends QuickBaseForm
+{
+ /**
+ * Whether or not form elements are members of an array
+ * @codingStandardsIgnoreStart
+ * @var bool
+ */
+ protected $_isArray = true;
+ // @codingStandardsIgnoreEnd
+
+ /**
+ * Load the default decorators
+ *
+ * @return $this
+ */
+ public function loadDefaultDecorators()
+ {
+ if ($this->loadDefaultDecoratorsIsDisabled()) {
+ return $this;
+ }
+
+ $decorators = $this->getDecorators();
+ if (empty($decorators)) {
+ $this->addDecorator('FormElements')
+ ->addDecorator('HtmlTag', array('tag' => 'dl'))
+ ->addDecorator('Fieldset')
+ ->addDecorator('DtDdWrapper');
+ }
+
+ return $this;
+ }
+}
diff --git a/library/Director/Web/Form/Validate/IsDataListEntry.php b/library/Director/Web/Form/Validate/IsDataListEntry.php
new file mode 100644
index 0000000..5762d2e
--- /dev/null
+++ b/library/Director/Web/Form/Validate/IsDataListEntry.php
@@ -0,0 +1,55 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form\Validate;
+
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Objects\DirectorDatalistEntry;
+use Zend_Validate_Abstract;
+
+class IsDataListEntry extends Zend_Validate_Abstract
+{
+ const INVALID = 'intInvalid';
+
+ /** @var Db */
+ private $db;
+
+ /** @var int */
+ private $dataListId;
+
+ public function __construct($dataListId, Db $db)
+ {
+ $this->db = $db;
+ $this->dataListId = (int) $dataListId;
+ }
+
+ public function isValid($value)
+ {
+ if (is_array($value)) {
+ foreach ($value as $name) {
+ if (! $this->isListEntry($name)) {
+ $this->_error(self::INVALID, $value);
+
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ if ($this->isListEntry($value)) {
+ return true;
+ } else {
+ $this->_error(self::INVALID, $value);
+
+ return false;
+ }
+ }
+
+ protected function isListEntry($name)
+ {
+ return DirectorDatalistEntry::exists([
+ 'list_id' => $this->dataListId,
+ 'entry_name' => $name,
+ ], $this->db);
+ }
+}
diff --git a/library/Director/Web/Form/Validate/NamePattern.php b/library/Director/Web/Form/Validate/NamePattern.php
new file mode 100644
index 0000000..fac44d9
--- /dev/null
+++ b/library/Director/Web/Form/Validate/NamePattern.php
@@ -0,0 +1,38 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Form\Validate;
+
+use Icinga\Module\Director\Restriction\MatchingFilter;
+use Zend_Validate_Abstract;
+
+class NamePattern extends Zend_Validate_Abstract
+{
+ const INVALID = 'intInvalid';
+
+ private $filter;
+
+ public function __construct($pattern)
+ {
+ if (! is_array($pattern)) {
+ $pattern = [$pattern];
+ }
+
+ $this->filter = MatchingFilter::forPatterns($pattern, 'value');
+
+ $this->_messageTemplates[self::INVALID] = sprintf(
+ 'Does not match %s',
+ (string) $this->filter
+ );
+ }
+
+ public function isValid($value)
+ {
+ if ($this->filter->matches((object) ['value' => $value])) {
+ return true;
+ } else {
+ $this->_error(self::INVALID, $value);
+
+ return false;
+ }
+ }
+}
diff --git a/library/Director/Web/Navigation/Renderer/ConfigHealthItemRenderer.php b/library/Director/Web/Navigation/Renderer/ConfigHealthItemRenderer.php
new file mode 100644
index 0000000..1aabada
--- /dev/null
+++ b/library/Director/Web/Navigation/Renderer/ConfigHealthItemRenderer.php
@@ -0,0 +1,196 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Navigation\Renderer;
+
+use Exception;
+use Icinga\Application\Config;
+use Icinga\Application\Icinga;
+use Icinga\Application\Web;
+use Icinga\Authentication\Auth;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Db\Branch\Branch;
+use Icinga\Module\Director\Db\Branch\BranchStore;
+use Icinga\Module\Director\Db\Migrations;
+use Icinga\Module\Director\KickstartHelper;
+use Icinga\Module\Director\Web\Controller\Extension\DirectorDb;
+use Icinga\Web\Navigation\Renderer\BadgeNavigationItemRenderer;
+use Icinga\Module\Director\Web\Window;
+
+class ConfigHealthItemRenderer extends BadgeNavigationItemRenderer
+{
+ use DirectorDb;
+
+ private $directorState = self::STATE_OK;
+
+ private $message;
+
+ private $count = 0;
+
+ private $window;
+
+ protected function hasProblems()
+ {
+ try {
+ $this->checkHealth();
+ } catch (Exception $e) {
+ $this->directorState = self::STATE_UNKNOWN;
+ $this->count = 1;
+ $this->message = $e->getMessage();
+ }
+
+ return $this->count > 0;
+ }
+
+ public function getState()
+ {
+ return $this->directorState;
+ }
+
+ public function getCount()
+ {
+ if ($this->hasProblems()) {
+ return $this->count;
+ } else {
+ return 0;
+ }
+ }
+
+ public function getTitle()
+ {
+ return $this->message;
+ }
+
+ protected function checkHealth()
+ {
+ $db = $this->db();
+ if (! $db) {
+ $this->directorState = self::STATE_PENDING;
+ $this->count = 1;
+ $this->message = $this->translate(
+ 'No database has been configured for Icinga Director'
+ );
+
+ return;
+ }
+
+ $migrations = new Migrations($db);
+ if (!$migrations->hasSchema()) {
+ $this->count = 1;
+ $this->directorState = self::STATE_CRITICAL;
+ $this->message = $this->translate(
+ 'Director database schema has not been created yet'
+ );
+ return;
+ }
+
+ if ($migrations->hasPendingMigrations()) {
+ $this->count = $migrations->countPendingMigrations();
+ $this->directorState = self::STATE_PENDING;
+ $this->message = sprintf(
+ $this->translate('There are %d pending database migrations'),
+ $this->count
+ );
+ return;
+ }
+
+ $kickstart = new KickstartHelper($db);
+ if ($kickstart->isRequired()) {
+ $this->directorState = self::STATE_PENDING;
+ $this->count = 1;
+ $this->message = $this->translate(
+ 'No API user configured, you might run the kickstart helper'
+ );
+
+ return;
+ }
+
+ $branch = Branch::detect(new BranchStore($this->db()));
+ if ($branch->isBranch()) {
+ $count = $branch->getActivityCount();
+ if ($count > 0) {
+ $this->directorState = self::STATE_PENDING;
+ $this->count = $count;
+ $this->message = sprintf(
+ $this->translate('%s config changes are available in your configuration branch'),
+ $count
+ );
+ }
+
+ return;
+ }
+
+ $pendingChanges = $db->countActivitiesSinceLastDeployedConfig();
+
+ if ($pendingChanges > 0) {
+ $this->directorState = self::STATE_WARNING;
+ $this->count = $pendingChanges;
+ $this->message = sprintf(
+ $this->translate(
+ '%s config changes happend since the last deployed configuration'
+ ),
+ $pendingChanges
+ );
+ }
+ }
+
+ protected function translate($message)
+ {
+ return mt('director', $message);
+ }
+
+ protected function db()
+ {
+ try {
+ $resourceName = Config::module('director')->get('db', 'resource');
+ if ($resourceName) {
+ // Window might have switched to another DB:
+ return Db::fromResourceName($this->getDbResourceName());
+ } else {
+ return false;
+ }
+ } catch (Exception $e) {
+ return false;
+ }
+ }
+
+ /**
+ * TODO: the following methods are for the DirectorDb trait, we need
+ * something better in future. It is required to show Health
+ * related to the DB chosen in the current Window
+ *
+ * @codingStandardsIgnoreStart
+ * @return Auth
+ */
+ protected function Auth()
+ {
+ return Auth::getInstance();
+ }
+
+ /**
+ * @return Window
+ */
+ public function Window()
+ {
+ if ($this->window === null) {
+ try {
+ /** @var $app Web */
+ $app = Icinga::app();
+ $this->window = new Window(
+ $app->getRequest()->getHeader('X-Icinga-WindowId')
+ );
+ } catch (Exception $e) {
+ $this->window = new Window(Window::UNDEFINED);
+ }
+ }
+ return $this->window;
+ }
+
+ /**
+ * @return Config
+ */
+ protected function Config()
+ {
+ // @codingStandardsIgnoreEnd
+ return Config::module('director');
+ }
+}
diff --git a/library/Director/Web/ObjectPreview.php b/library/Director/Web/ObjectPreview.php
new file mode 100644
index 0000000..e7648e1
--- /dev/null
+++ b/library/Director/Web/ObjectPreview.php
@@ -0,0 +1,182 @@
+<?php
+
+namespace Icinga\Module\Director\Web;
+
+use gipfl\Web\Widget\Hint;
+use ipl\Html\Text;
+use Icinga\Module\Director\Exception\NestingError;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Icinga\Web\Request;
+use ipl\Html\Html;
+use gipfl\IcingaWeb2\Link;
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Widget\ControlsAndContent;
+
+class ObjectPreview
+{
+ use TranslationHelper;
+
+ /** @var IcingaObject */
+ protected $object;
+
+ /** @var Request */
+ protected $request;
+
+ public function __construct(IcingaObject $object, Request $request)
+ {
+ $this->object = $object;
+ $this->request = $request;
+ }
+
+ /**
+ * @param ControlsAndContent $cc
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function renderTo(ControlsAndContent $cc)
+ {
+ $object = $this->object;
+ $url = $this->request->getUrl();
+ $params = $url->getParams();
+ $cc->addTitle(
+ $this->translate('Config preview: %s'),
+ $object->getObjectName()
+ );
+
+ if ($params->shift('resolved')) {
+ $object = $object::fromPlainObject(
+ $object->toPlainObject(true),
+ $object->getConnection()
+ );
+
+ $cc->actions()->add(Link::create(
+ $this->translate('Show normal'),
+ $url->without('resolved'),
+ null,
+ ['class' => 'icon-resize-small state-warning']
+ ));
+ } else {
+ try {
+ if ($object->supportsImports() && $object->imports()->count() > 0) {
+ $cc->actions()->add(Link::create(
+ $this->translate('Show resolved'),
+ $url->with('resolved', true),
+ null,
+ ['class' => 'icon-resize-full']
+ ));
+ }
+ } catch (NestingError $e) {
+ // No resolve link with nesting errors
+ }
+ }
+
+ $content = $cc->content();
+ if ($object->isDisabled()) {
+ $content->add(Hint::error(
+ $this->translate('This object will not be deployed as it has been disabled')
+ ));
+ }
+ if ($object->isExternal()) {
+ $content->add(Html::tag('p', null, $this->translate((
+ 'This is an external object. It has been imported from Icinga 2 through the'
+ . ' Core API and cannot be managed with the Icinga Director. It is however'
+ . ' perfectly valid to create objects using this or referring to this object.'
+ . ' You might also want to define related Fields to make work based on this'
+ . ' object more enjoyable.'
+ ))));
+ }
+ $config = $object->toSingleIcingaConfig();
+
+ foreach ($config->getFiles() as $filename => $file) {
+ if (! $object->isExternal()) {
+ $content->add(Html::tag('h2', null, $filename));
+ }
+
+ $classes = array();
+ if ($object->isDisabled()) {
+ $classes[] = 'disabled';
+ } elseif ($object->isExternal()) {
+ $classes[] = 'logfile';
+ }
+
+ $type = $object->getShortTableName();
+
+ $plain = Html::wantHtml($file->getContent())->render();
+ $plain = preg_replace_callback(
+ '/^(\s+import\s+\&quot\;)(.+)(\&quot\;)/m',
+ [$this, 'linkImport'],
+ $plain
+ );
+
+ if ($type !== 'command') {
+ $plain = preg_replace_callback(
+ '/^(\s+(?:check_|event_)?command\s+=\s+\&quot\;)(.+)(\&quot\;)/m',
+ [$this, 'linkCommand'],
+ $plain
+ );
+ }
+
+ $plain = preg_replace_callback(
+ '/^(\s+host_name\s+=\s+\&quot\;)(.+)(\&quot\;)/m',
+ [$this, 'linkHost'],
+ $plain
+ );
+ $text = Text::create($plain)->setEscaped();
+
+ $content->add(Html::tag('pre', ['class' => $classes], $text));
+ }
+ }
+
+ /**
+ * @api internal
+ * @param $match
+ * @return string
+ */
+ public function linkImport($match)
+ {
+ $blacklist = [
+ 'plugin-notification-command',
+ 'plugin-check-command',
+ ];
+ if (in_array($match[2], $blacklist)) {
+ return $match[1] . $match[2] . $match[3];
+ }
+
+ $urlObjectType = $this->object->getShortTableName();
+ if ($urlObjectType === 'service_set') {
+ $urlObjectType = 'service';
+ }
+ return $match[1] . Link::create(
+ $match[2],
+ sprintf("director/$urlObjectType"),
+ ['name' => $match[2]]
+ )->render() . $match[3];
+ }
+
+ /**
+ * @api internal
+ * @param $match
+ * @return string
+ */
+ public function linkCommand($match)
+ {
+ return $match[1] . Link::create(
+ $match[2],
+ sprintf('director/command'),
+ ['name' => $match[2]]
+ )->render() . $match[3];
+ }
+
+ /**
+ * @api internal
+ * @param $match
+ * @return string
+ */
+ public function linkHost($match)
+ {
+ return $match[1] . Link::create(
+ $match[2],
+ sprintf('director/host'),
+ ['name' => $match[2]]
+ )->render() . $match[3];
+ }
+}
diff --git a/library/Director/Web/SelfService.php b/library/Director/Web/SelfService.php
new file mode 100644
index 0000000..33756b7
--- /dev/null
+++ b/library/Director/Web/SelfService.php
@@ -0,0 +1,311 @@
+<?php
+
+namespace Icinga\Module\Director\Web;
+
+use Exception;
+use gipfl\Web\Widget\Hint;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Module\Director\Core\CoreApi;
+use Icinga\Module\Director\Forms\IcingaForgetApiKeyForm;
+use Icinga\Module\Director\Forms\IcingaGenerateApiKeyForm;
+use Icinga\Application\Icinga;
+use Icinga\Exception\NotFoundError;
+use Icinga\Module\Director\IcingaConfig\AgentWizard;
+use Icinga\Module\Director\Objects\IcingaHost;
+use Icinga\Module\Director\Util;
+use Icinga\Module\Director\Web\Widget\Documentation;
+use ipl\Html\Html;
+use gipfl\IcingaWeb2\Link;
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Widget\ActionBar;
+use gipfl\IcingaWeb2\Widget\ControlsAndContent;
+
+class SelfService
+{
+ use TranslationHelper;
+
+ /** @var IcingaHost */
+ protected $host;
+
+ /** @var CoreApi */
+ protected $api;
+
+ public function __construct(IcingaHost $host, CoreApi $api)
+ {
+ $this->host = $host;
+ $this->api = $api;
+ }
+
+ /**
+ * @param ControlsAndContent $controller
+ */
+ public function renderTo(ControlsAndContent $controller)
+ {
+ $host = $this->host;
+ if ($host->isTemplate()) {
+ $this->showSelfServiceTemplateInstructions($controller);
+ } elseif ($key = $host->getProperty('api_key')) {
+ $this->showRegisteredAgentInstructions($key, $controller);
+ } elseif ($key = $host->getSingleResolvedProperty('api_key')) {
+ $this->showNewAgentInstructions($controller);
+ } else {
+ $this->showLegacyAgentInstructions($controller);
+ }
+ }
+
+ /**
+ * @param string $key
+ * @param ControlsAndContent $c
+ */
+ protected function showRegisteredAgentInstructions($key, ControlsAndContent $c)
+ {
+ $c->addTitle($this->translate('Registered Agent'));
+ $c->content()->add([
+ Html::tag('p', null, $this->translate(
+ 'This host has been registered via the Icinga Director Self Service'
+ . " API. In case you re-installed the host or somehow lost it's"
+ . ' secret key, you might want to dismiss the current key. This'
+ . ' would allow you to register the same host again.'
+ )),
+ Html::tag('p', null, [$this->translate('Api Key:'), ' ', Html::tag('strong', null, $key)]),
+ Hint::warning($this->translate(
+ 'It is not a good idea to do so as long as your Agent still has'
+ . ' a valid Self Service API key!'
+ )),
+ IcingaForgetApiKeyForm::load()->setHost($this->host)->handleRequest()
+ ]);
+ }
+
+ /**
+ * @param ControlsAndContent $cc
+ */
+ protected function showSelfServiceTemplateInstructions(ControlsAndContent $cc)
+ {
+ $host = $this->host;
+ $key = $host->getProperty('api_key');
+ $hasKey = $key !== null;
+ if ($hasKey) {
+ $cc->addTitle($this->translate('Shared for Self Service API'));
+ } else {
+ $cc->addTitle($this->translate('Share this Template for Self Service API'));
+ }
+
+ $c = $cc->content();
+ /** @var ActionBar $actions */
+ $actions = $cc->actions();
+ $actions->setBaseTarget('_next')->add(Link::create(
+ $this->translate('Settings'),
+ 'director/settings/self-service',
+ null,
+ [
+ 'title' => $this->translate('Global Self Service Setting'),
+ 'class' => 'icon-services',
+ ]
+ ));
+
+ $actions->add($this->getDocumentationLink());
+
+ if ($hasKey) {
+ $c->add([
+ Html::tag('p', [
+ $this->translate('Api Key:'), ' ', Html::tag('strong', null, $key)
+ ]),
+ $this->getWindowsInstructions($host, $key),
+ Html::tag('h2', null, $this->translate('Generate a new key')),
+ Hint::warning($this->translate(
+ 'This will invalidate the former key'
+ )),
+ ]);
+ }
+
+ $c->add([
+ // Html::tag('p', null, $this->translate('..')),
+ IcingaGenerateApiKeyForm::load()->setHost($host)->handleRequest()
+ ]);
+ if ($hasKey) {
+ $c->add([
+ Html::tag('h2', null, $this->translate('Stop sharing this Template')),
+ Html::tag('p', null, $this->translate(
+ 'You can stop sharing a Template at any time. This will'
+ . ' immediately invalidate the former key.'
+ ) . ' ' . $this->translate(
+ 'Generated Host keys will continue to work, but you\'ll no'
+ . ' longer be able to register new Hosts with this key'
+ )),
+ IcingaForgetApiKeyForm::load()->setHost($host)->handleRequest()
+ ]);
+ }
+ }
+
+ protected function getWindowsInstructions($host, $key)
+ {
+ $wizard = new AgentWizard($host);
+
+ return [
+ Html::tag('h2', $this->translate('Icinga for Windows')),
+ Html::tag('p', Html::sprintf(
+ $this->translate('In case you\'re using %s, please run this Script:'),
+ Html::tag('a', [
+ 'href' => 'https://icinga.com/docs/windows/latest/',
+ 'target' => '_blank',
+ ], $this->translate('Icinga for Windows'))
+ )),
+ Html::tag(
+ 'pre',
+ ['class' => 'logfile'],
+ $wizard->renderIcinga4WindowsWizardCommand($key)
+ ),
+ Html::tag('h3', $this->translate('Icinga 2 Powershell Module')),
+ Html::tag('p', Html::sprintf(
+ $this->translate('In case you\'re using the legacy %s, please run:'),
+ Html::tag('a', [
+ 'href' => 'https://github.com/Icinga/icinga2-powershell-module',
+ 'target' => '_blank',
+ ], $this->translate('Icinga 2 Powershell Module'))
+ )),
+ Html::tag(
+ 'pre',
+ ['class' => 'logfile'],
+ $wizard->renderPowershellModuleInstaller($key)
+ ),
+ ];
+ }
+
+ protected function getDocumentationLink()
+ {
+ return Documentation::link(
+ $this->translate('Documentation'),
+ 'director',
+ '74-Self-Service-API',
+ $this->translate('Self Service API')
+ );
+ }
+
+ /**
+ * @param ControlsAndContent $cc
+ */
+ protected function showNewAgentInstructions(ControlsAndContent $cc)
+ {
+ $content = $cc->content();
+ $host = $this->host;
+ $key = $host->getSingleResolvedProperty('api_key');
+ $cc->addTitle($this->translate('Configure this Agent via Self Service API'));
+ $cc->actions()->add($this->getDocumentationLink());
+ $content->add(Html::tag('p', [
+ $this->translate('Inherited Template Api Key:'), ' ', Html::tag('strong', null, $key)
+ ]));
+ $content->add($this->getWindowsInstructions($host, $key));
+ }
+
+ /**
+ * @param ControlsAndContent $cc
+ */
+ protected function showLegacyAgentInstructions(ControlsAndContent $cc)
+ {
+ $host = $this->host;
+ $c = $cc->content();
+ $docBaseUrl = 'https://docs.icinga.com/icinga2/latest/doc/module/icinga2/chapter/distributed-monitoring';
+ $sectionSetup = 'distributed-monitoring-setup-satellite-client';
+ $sectionTopDown = 'distributed-monitoring-top-down';
+ $c->add(Html::tag('p')->add(Html::sprintf(
+ 'Please check the %s for more related information.'
+ . ' The Director-assisted setup corresponds to configuring a %s environment.',
+ Html::tag(
+ 'a',
+ ['href' => $docBaseUrl . '#' . $sectionSetup],
+ $this->translate('Icinga 2 Client documentation')
+ ),
+ Html::tag(
+ 'a',
+ ['href' => $docBaseUrl . '#' . $sectionTopDown],
+ $this->translate('Top Down')
+ )
+ )));
+
+ $cc->addTitle('Agent deployment instructions');
+
+ try {
+ $ticket = $this->api->getTicket($host->getEndpointName());
+ $wizard = new AgentWizard($host);
+ $wizard->setTicket($ticket);
+ } catch (Exception $e) {
+ $c->add(Hint::error(sprintf(
+ $this->translate(
+ 'A ticket for this agent could not have been requested from'
+ . ' your deployment endpoint: %s'
+ ),
+ $e->getMessage()
+ )));
+
+ return;
+ }
+
+ $class = ['class' => 'agent-deployment-instructions'];
+ $c->add([
+ Html::tag('h2', null, $this->translate('For manual configuration')),
+ Html::tag('p', null, [$this->translate('Ticket'), ': ', Html::tag('code', null, $ticket)]),
+ Html::tag('h2', null, $this->translate('Windows Kickstart Script')),
+ Link::create(
+ $this->translate('Download'),
+ $cc->url()->with('download', 'windows-kickstart'),
+ null,
+ ['class' => 'icon-download', 'target' => '_blank']
+ ),
+ Html::tag('pre', $class, $wizard->renderWindowsInstaller()),
+ Html::tag('p', null, $this->translate(
+ 'This requires the Icinga Agent to be installed. It generates and signs'
+ . ' it\'s certificate and it also generates a minimal icinga2.conf to get'
+ . ' your agent connected to it\'s parents'
+ )),
+ Html::tag('h2', null, $this->translate('Linux commandline')),
+ Link::create(
+ $this->translate('Download'),
+ $cc->url()->with('download', 'linux'),
+ null,
+ ['class' => 'icon-download', 'target' => '_blank']
+ ),
+ Html::tag('p', null, $this->translate('Just download and run this script on your Linux Client Machine:')),
+ Html::tag('pre', $class, $wizard->renderLinuxInstaller())
+ ]);
+ }
+
+ /**
+ * @param $os
+ * @throws NotFoundError
+ */
+ public function handleLegacyAgentDownloads($os)
+ {
+ $wizard = new AgentWizard($this->host);
+ $wizard->setTicket($this->api->getTicket($this->host->getEndpointName()));
+
+ switch ($os) {
+ case 'windows-kickstart':
+ $ext = 'ps1';
+ $script = preg_replace('/\n/', "\r\n", $wizard->renderWindowsInstaller());
+ break;
+ case 'linux':
+ $ext = 'bash';
+ $script = $wizard->renderLinuxInstaller();
+ break;
+ default:
+ throw new NotFoundError('There is no kickstart helper for %s', $os);
+ }
+
+ header('Content-type: application/octet-stream');
+ header('Content-Disposition: attachment; filename=icinga2-agent-kickstart.' . $ext);
+ echo $script;
+ exit;
+ }
+
+ /**
+ * @return bool
+ */
+ protected function hasDocsModuleLoaded()
+ {
+ try {
+ return Icinga::app()->getModuleManager()->hasLoaded('doc');
+ } catch (ProgrammingError $e) {
+ return false;
+ }
+ }
+}
diff --git a/library/Director/Web/Table/ActivityLogTable.php b/library/Director/Web/Table/ActivityLogTable.php
new file mode 100644
index 0000000..5460bc2
--- /dev/null
+++ b/library/Director/Web/Table/ActivityLogTable.php
@@ -0,0 +1,294 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use gipfl\Format\LocalTimeFormat;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+use Icinga\Module\Director\Util;
+use ipl\Html\Html;
+use ipl\Html\HtmlElement;
+
+class ActivityLogTable extends ZfQueryBasedTable
+{
+ protected $filters = [];
+
+ protected $lastDeployedId;
+
+ protected $extraParams = [];
+
+ protected $columnCount;
+
+ protected $hasObjectFilter = false;
+
+ protected $searchColumns = [
+ 'author',
+ 'object_name',
+ 'object_type',
+ ];
+
+ /** @var LocalTimeFormat */
+ protected $timeFormat;
+
+ protected $ranges = [];
+
+ /** @var ?object */
+ protected $currentRange = null;
+ /** @var ?HtmlElement */
+ protected $currentRangeCell = null;
+ /** @var int */
+ protected $rangeRows = 0;
+ protected $continueRange = false;
+ protected $currentRow;
+
+ public function __construct($db)
+ {
+ parent::__construct($db);
+ $this->timeFormat = new LocalTimeFormat();
+ }
+
+ public function assemble()
+ {
+ $this->getAttributes()->add('class', 'activity-log');
+ }
+
+ public function setLastDeployedId($id)
+ {
+ $this->lastDeployedId = $id;
+ return $this;
+ }
+
+ protected function fetchQueryRows()
+ {
+ $rows = parent::fetchQueryRows();
+ // Hint -> DESC, that's why they are inverted
+ if (empty($rows)) {
+ return $rows;
+ }
+ $last = $rows[0]->id;
+ $first = $rows[count($rows) - 1]->id;
+ $db = $this->db();
+ $this->ranges = $db->fetchAll(
+ $db->select()
+ ->from('director_activity_log_remark')
+ ->where('first_related_activity <= ?', $last)
+ ->where('last_related_activity >= ?', $first)
+ );
+
+ return $rows;
+ }
+
+
+ public function renderRow($row)
+ {
+ $this->currentRow = $row;
+ $this->splitByDay($row->ts_change_time);
+ $action = 'action-' . $row->action. ' ';
+ if ($row->id > $this->lastDeployedId) {
+ $action .= 'undeployed';
+ } else {
+ $action .= 'deployed';
+ }
+
+ $columns = [
+ $this::td($this->makeLink($row))->setSeparator(' '),
+ ];
+ if (! $this->hasObjectFilter) {
+ $columns[] = $this->makeRangeInfo($row->id);
+ }
+ $columns[] = $this::td($this->timeFormat->getTime($row->ts_change_time));
+
+ return $this::tr($columns)->addAttributes(['class' => $action]);
+ }
+
+ /**
+ * Hint: cloned from parent class and modified
+ * @param int $timestamp
+ */
+ protected function renderDayIfNew($timestamp)
+ {
+ $day = $this->getDateFormatter()->getFullDay($timestamp);
+
+ if ($this->lastDay !== $day) {
+ $this->nextHeader()->add(
+ $this::th($day, [
+ 'colspan' => $this->hasObjectFilter ? 2 : 3,
+ 'class' => 'table-header-day'
+ ])
+ );
+
+ $this->lastDay = $day;
+ if ($this->currentRangeCell) {
+ if ($this->currentRange->first_related_activity <= $this->currentRow->id) {
+ $this->currentRangeCell->addAttributes(['class' => 'continuing']);
+ $this->continueRange = true;
+ } else {
+ $this->continueRange = false;
+ }
+ }
+ $this->currentRangeCell = null;
+ $this->currentRange = null;
+ $this->rangeRows = 0;
+ $this->nextBody();
+ }
+ }
+
+ protected function makeRangeInfo($id)
+ {
+ $range = $this->getRangeForId($id);
+ if ($range === null) {
+ if ($this->currentRangeCell) {
+ $this->currentRangeCell->getAttributes()->remove('class', 'continuing');
+ }
+ $this->currentRange = null;
+ $this->currentRangeCell = null;
+ $this->rangeRows = 0;
+ return $this::td();
+ }
+
+ if ($range === $this->currentRange) {
+ $this->growCurrentRange();
+ return null;
+ }
+ $this->startRange($range);
+
+ return $this->currentRangeCell;
+ }
+
+ protected function startRange($range)
+ {
+ $this->currentRangeCell = $this::td($this->renderRangeComment($range), [
+ 'colspan' => $this->rangeRows = 1,
+ 'class' => 'comment-cell'
+ ]);
+ if ($this->continueRange) {
+ $this->currentRangeCell->addAttributes(['class' => 'continued']);
+ $this->continueRange = false;
+ }
+ $this->currentRange = $range;
+ }
+
+ protected function renderRangeComment($range)
+ {
+ // The only purpose of this container is to avoid hovered rows from influencing
+ // the comments background color, as we're using the alpha channel to lighten it
+ // This can be replaced once we get theme-safe colors for such messages
+ return Html::tag('div', [
+ 'class' => 'range-comment-container',
+ ], Link::create($this->continueRange ? '' : $range->remark, '#', null, [
+ 'title' => $range->remark,
+ 'class' => 'range-comment'
+ ]));
+ }
+
+ protected function growCurrentRange()
+ {
+ $this->rangeRows++;
+ $this->currentRangeCell->setAttribute('rowspan', $this->rangeRows);
+ }
+
+ protected function getRangeForId($id)
+ {
+ foreach ($this->ranges as $range) {
+ if ($id >= $range->first_related_activity && $id <= $range->last_related_activity) {
+ return $range;
+ }
+ }
+
+ return null;
+ }
+
+ protected function makeLink($row)
+ {
+ $type = $row->object_type;
+ $name = $row->object_name;
+ if (substr($type, 0, 7) === 'icinga_') {
+ $type = substr($type, 7);
+ }
+
+ if (Util::hasPermission('director/showconfig')) {
+ // Later on replacing, service_set -> serviceset
+
+ // multi column key :(
+ if ($type === 'service' || $this->hasObjectFilter) {
+ $object = "\"$name\"";
+ } else {
+ $object = Link::create(
+ "\"$name\"",
+ 'director/' . str_replace('_', '', $type),
+ ['name' => $name],
+ ['title' => $this->translate('Jump to this object')]
+ );
+ }
+
+ return [
+ '[' . $row->author . ']',
+ Link::create(
+ $row->action,
+ 'director/config/activity',
+ array_merge(['id' => $row->id], $this->extraParams),
+ ['title' => $this->translate('Show details related to this change')]
+ ),
+ str_replace('_', ' ', $type),
+ $object
+ ];
+ } else {
+ return sprintf(
+ '[%s] %s %s "%s"',
+ $row->author,
+ $row->action,
+ $type,
+ $name
+ );
+ }
+ }
+
+ public function filterObject($type, $name)
+ {
+ $this->hasObjectFilter = true;
+ $this->filters[] = ['l.object_type = ?', $type];
+ $this->filters[] = ['l.object_name = ?', $name];
+
+ return $this;
+ }
+
+ public function filterHost($name)
+ {
+ $db = $this->db();
+ $filter = '%"host":' . json_encode($name) . '%';
+ $this->filters[] = ['('
+ . $db->quoteInto('l.old_properties LIKE ?', $filter)
+ . ' OR '
+ . $db->quoteInto('l.new_properties LIKE ?', $filter)
+ . ')', null];
+
+ return $this;
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'author' => 'l.author',
+ 'action' => 'l.action_name',
+ 'object_name' => 'l.object_name',
+ 'object_type' => 'l.object_type',
+ 'id' => 'l.id',
+ 'change_time' => 'l.change_time',
+ 'ts_change_time' => 'UNIX_TIMESTAMP(l.change_time)',
+ ];
+ }
+
+ public function prepareQuery()
+ {
+ $query = $this->db()->select()->from(
+ ['l' => 'director_activity_log'],
+ $this->getColumns()
+ )->order('change_time DESC')->order('id DESC')->limit(100);
+
+ foreach ($this->filters as $filter) {
+ $query->where($filter[0], $filter[1]);
+ }
+
+ return $query;
+ }
+}
diff --git a/library/Director/Web/Table/ApplyRulesTable.php b/library/Director/Web/Table/ApplyRulesTable.php
new file mode 100644
index 0000000..a861bac
--- /dev/null
+++ b/library/Director/Web/Table/ApplyRulesTable.php
@@ -0,0 +1,240 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Authentication\Auth;
+use Icinga\Data\Filter\Filter;
+use Icinga\Exception\IcingaException;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Db\DbUtil;
+use Icinga\Module\Director\Db\IcingaObjectFilterHelper;
+use Icinga\Module\Director\IcingaConfig\AssignRenderer;
+use Icinga\Module\Director\Objects\IcingaObject;
+use gipfl\IcingaWeb2\Icon;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+use gipfl\IcingaWeb2\Url;
+use gipfl\IcingaWeb2\Zf1\Db\FilterRenderer;
+use Ramsey\Uuid\Uuid;
+use Zend_Db_Select as ZfSelect;
+
+class ApplyRulesTable extends ZfQueryBasedTable
+{
+ protected $searchColumns = [
+ 'o.object_name',
+ 'o.assign_filter',
+ ];
+
+ private $type;
+
+ /** @var IcingaObject */
+ protected $dummyObject;
+
+ protected $baseObjectUrl;
+
+ protected $linkWithName = false;
+
+ public static function create($type, Db $db)
+ {
+ $table = new static($db);
+ $table->setType($type);
+ return $table;
+ }
+
+ public function setType($type)
+ {
+ $this->type = $type;
+
+ return $this;
+ }
+
+ public function setBaseObjectUrl($url)
+ {
+ $this->baseObjectUrl = $url;
+
+ return $this;
+ }
+
+ public function createLinksWithNames($linksWithName = true)
+ {
+ $this->linkWithName = (bool) $linksWithName;
+
+ return $this;
+ }
+
+ public function getType()
+ {
+ return $this->type;
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return ['Name', 'assign where'/*, 'Actions'*/];
+ }
+
+ public function renderRow($row)
+ {
+ $row->uuid = DbUtil::binaryResult($row->uuid);
+ if ($this->linkWithName) {
+ $params = ['name' => $row->object_name];
+ } else {
+ $params = ['uuid' => Uuid::fromBytes($row->uuid)->toString()];
+ }
+ $url = Url::fromPath("director/{$this->baseObjectUrl}/edit", $params);
+
+ $assignWhere = $this->renderApplyFilter($row->assign_filter);
+
+ if (! empty($row->apply_for)) {
+ $assignWhere = sprintf('apply for %s / %s', $row->apply_for, $assignWhere);
+ }
+
+ $tr = static::tr([
+ static::td(Link::create($row->object_name, $url)),
+ static::td($assignWhere),
+ // NOT (YET) static::td($this->createActionLinks($row))->setSeparator(' ')
+ ]);
+
+ if ($row->disabled === 'y') {
+ $tr->getAttributes()->add('class', 'disabled');
+ }
+
+ return $tr;
+ }
+
+ /**
+ * Should be triggered from renderRow, still unused.
+ *
+ * @param IcingaObject $template
+ * @param string $inheritance
+ * @return $this
+ * @throws \Icinga\Exception\ProgrammingError
+ */
+ public function filterTemplate(
+ IcingaObject $template,
+ $inheritance = IcingaObjectFilterHelper::INHERIT_DIRECT
+ ) {
+ IcingaObjectFilterHelper::filterByTemplate(
+ $this->getQuery(),
+ $template,
+ 'o',
+ $inheritance
+ );
+
+ return $this;
+ }
+
+ protected function renderApplyFilter($assignFilter)
+ {
+ try {
+ $string = AssignRenderer::forFilter(
+ Filter::fromQueryString($assignFilter)
+ )->renderAssign();
+ // Do not prefix it
+ $string = preg_replace('/^assign where /', '', $string);
+ } catch (IcingaException $e) {
+ // ignore errors in filter rendering
+ $string = 'Error in Filter rendering: ' . $e->getMessage();
+ }
+
+ return $string;
+ }
+
+ public function createActionLinks($row)
+ {
+ $params = ['uuid' => Uuid::fromBytes($row->uuid)->toString()];
+ $baseUrl = 'director/' . $this->baseObjectUrl;
+ $links = [];
+ $links[] = Link::create(
+ Icon::create('sitemap'),
+ "${baseUrl}template/applytargets",
+ ['id' => $row->id],
+ ['title' => $this->translate('Show affected Objects')]
+ );
+
+ $links[] = Link::create(
+ Icon::create('edit'),
+ "$baseUrl/edit",
+ $params,
+ ['title' => $this->translate('Modify this Apply Rule')]
+ );
+
+ $links[] = Link::create(
+ Icon::create('doc-text'),
+ "$baseUrl/render",
+ $params,
+ ['title' => $this->translate('Apply Rule rendering preview')]
+ );
+
+ $links[] = Link::create(
+ Icon::create('history'),
+ "$baseUrl/history",
+ $params,
+ ['title' => $this->translate('Apply rule history')]
+ );
+
+ return $links;
+ }
+
+ protected function applyRestrictions(ZfSelect $query)
+ {
+ $auth = Auth::getInstance();
+ $type = $this->type;
+ // TODO: Centralize this logic
+ if ($type === 'scheduledDowntime') {
+ $type = 'scheduled-downtime';
+ }
+ $restrictions = $auth->getRestrictions("director/$type/apply/filter-by-name");
+ if (empty($restrictions)) {
+ return $query;
+ }
+
+ $filter = Filter::matchAny();
+ foreach ($restrictions as $restriction) {
+ $filter->addFilter(Filter::where('o.object_name', $restriction));
+ }
+
+ return FilterRenderer::applyToQuery($filter, $query);
+ }
+
+
+ /**
+ * @return IcingaObject
+ */
+ protected function getDummyObject()
+ {
+ if ($this->dummyObject === null) {
+ $type = $this->type;
+ $this->dummyObject = IcingaObject::createByType($type);
+ }
+ return $this->dummyObject;
+ }
+
+ public function prepareQuery()
+ {
+ $table = $this->getDummyObject()->getTableName();
+ $columns = [
+ 'id' => 'o.id',
+ 'uuid' => 'o.uuid',
+ 'object_name' => 'o.object_name',
+ 'disabled' => 'o.disabled',
+ 'assign_filter' => 'o.assign_filter',
+ 'apply_for' => '(NULL)',
+ ];
+
+ if ($table === 'icinga_service') {
+ $columns['apply_for'] = 'o.apply_for';
+ }
+ $query = $this->db()->select()->from(
+ ['o' => $table],
+ $columns
+ )->where(
+ "object_type = 'apply'"
+ )->order('o.object_name');
+
+ if ($this->type === 'service') {
+ $query->where('service_set_id IS NULL');
+ }
+
+ return $this->applyRestrictions($query);
+ }
+}
diff --git a/library/Director/Web/Table/BasketSnapshotTable.php b/library/Director/Web/Table/BasketSnapshotTable.php
new file mode 100644
index 0000000..08f808a
--- /dev/null
+++ b/library/Director/Web/Table/BasketSnapshotTable.php
@@ -0,0 +1,125 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use ipl\Html\Html;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+use Icinga\Date\DateFormatter;
+use Icinga\Module\Director\Core\Json;
+use Icinga\Module\Director\DirectorObject\Automation\Basket;
+use RuntimeException;
+
+class BasketSnapshotTable extends ZfQueryBasedTable
+{
+ use DbHelper;
+
+ protected $searchColumns = [
+ 'basket_name',
+ 'summary'
+ ];
+
+ /** @var Basket */
+ protected $basket;
+
+ public function setBasket(Basket $basket)
+ {
+ $this->basket = $basket;
+ $this->searchColumns = [];
+
+ return $this;
+ }
+
+ public function renderRow($row)
+ {
+ $this->splitByDay($row->ts_create_seconds);
+ $link = $this->linkToSnapshot($this->renderSummary($row->summary), $row);
+
+ if ($this->basket === null) {
+ $columns = [
+ [
+ new Link(
+ Html::tag('strong', $row->basket_name),
+ 'director/basket',
+ ['name' => $row->basket_name]
+ ),
+ Html::tag('br'),
+ $link,
+ ],
+ DateFormatter::formatTime($row->ts_create / 1000),
+ ];
+ } else {
+ $columns = [
+ $link,
+ DateFormatter::formatTime($row->ts_create / 1000),
+ ];
+ }
+ return $this::row($columns);
+ }
+
+ protected function renderSummary($summary)
+ {
+ $summary = Json::decode($summary);
+ if ($summary === null) {
+ return '-';
+ }
+ $result = [];
+ if (! is_object($summary) && ! is_array($summary)) {
+ throw new RuntimeException(sprintf(
+ 'Got invalid basket summary: %s ',
+ var_export($summary, 1)
+ ));
+ }
+
+ foreach ($summary as $type => $count) {
+ $result[] = sprintf(
+ '%dx %s',
+ $count,
+ $type
+ );
+ }
+
+ if (empty($result)) {
+ return '-';
+ }
+
+ return implode(', ', $result);
+ }
+
+ protected function linkToSnapshot($caption, $row)
+ {
+ return new Link($caption, 'director/basket/snapshot', [
+ 'checksum' => bin2hex($this->wantBinaryValue($row->content_checksum)),
+ 'ts' => $row->ts_create,
+ 'name' => $row->basket_name,
+ ]);
+ }
+
+ public function prepareQuery()
+ {
+ $query = $this->db()->select()->from([
+ 'b' => 'director_basket'
+ ], [
+ 'b.uuid',
+ 'b.basket_name',
+ 'bs.ts_create',
+ 'ts_create_seconds' => '(bs.ts_create / 1000)',
+ 'bs.content_checksum',
+ 'bc.summary',
+ ])->join(
+ ['bs' => 'director_basket_snapshot'],
+ 'bs.basket_uuid = b.uuid',
+ []
+ )->join(
+ ['bc' => 'director_basket_content'],
+ 'bc.checksum = bs.content_checksum',
+ []
+ )->order('bs.ts_create DESC');
+
+ if ($this->basket !== null) {
+ $query->where('b.uuid = ?', $this->quoteBinary($this->basket->get('uuid')));
+ }
+
+ return $query;
+ }
+}
diff --git a/library/Director/Web/Table/BasketTable.php b/library/Director/Web/Table/BasketTable.php
new file mode 100644
index 0000000..25e37e0
--- /dev/null
+++ b/library/Director/Web/Table/BasketTable.php
@@ -0,0 +1,50 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+
+class BasketTable extends ZfQueryBasedTable
+{
+ protected $searchColumns = [
+ 'basket_name',
+ ];
+
+ public function renderRow($row)
+ {
+ $tr = $this::row([
+ new Link(
+ $row->basket_name,
+ 'director/basket',
+ ['name' => $row->basket_name]
+ ),
+ $row->cnt_snapshots
+ ]);
+
+ return $tr;
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [
+ $this->translate('Basket'),
+ $this->translate('Snapshots'),
+ ];
+ }
+
+ public function prepareQuery()
+ {
+ return $this->db()->select()->from([
+ 'b' => 'director_basket'
+ ], [
+ 'b.uuid',
+ 'b.basket_name',
+ 'cnt_snapshots' => 'COUNT(bs.basket_uuid)',
+ ])->joinLeft(
+ ['bs' => 'director_basket_snapshot'],
+ 'bs.basket_uuid = b.uuid',
+ []
+ )->group('b.uuid')->order('b.basket_name');
+ }
+}
diff --git a/library/Director/Web/Table/BranchActivityTable.php b/library/Director/Web/Table/BranchActivityTable.php
new file mode 100644
index 0000000..e7131ef
--- /dev/null
+++ b/library/Director/Web/Table/BranchActivityTable.php
@@ -0,0 +1,116 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use gipfl\Format\LocalTimeFormat;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Db\Branch\BranchActivity;
+use Icinga\Module\Director\Util;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+use Ramsey\Uuid\UuidInterface;
+
+class BranchActivityTable extends ZfQueryBasedTable
+{
+ protected $extraParams = [];
+
+ /** @var UuidInterface */
+ protected $branchUuid;
+
+ /** @var ?UuidInterface */
+ protected $objectUuid;
+
+ /** @var LocalTimeFormat */
+ protected $timeFormat;
+
+ protected $linkToObject = true;
+
+ public function __construct(UuidInterface $branchUuid, $db, UuidInterface $objectUuid = null)
+ {
+ $this->branchUuid = $branchUuid;
+ $this->objectUuid = $objectUuid;
+ $this->timeFormat = new LocalTimeFormat();
+ parent::__construct($db);
+ }
+
+ public function assemble()
+ {
+ $this->getAttributes()->add('class', 'activity-log');
+ }
+
+ public function renderRow($row)
+ {
+ $ts = (int) floor(BranchActivity::fixFakeTimestamp($row->timestamp_ns) / 1000000);
+ $this->splitByDay($ts);
+ $activity = BranchActivity::fromDbRow($row);
+ return $this::tr([
+ $this::td($this->makeBranchLink($activity))->setSeparator(' '),
+ $this::td($this->timeFormat->getTime($ts))
+ ])->addAttributes(['class' => ['action-' . $activity->getAction(), 'branched']]);
+ }
+
+ public function disableObjectLink()
+ {
+ $this->linkToObject = false;
+ return $this;
+ }
+
+ protected function linkObject(BranchActivity $activity)
+ {
+ if (! $this->linkToObject) {
+ return $activity->getObjectName();
+ }
+ // $type, UuidInterface $uuid
+ // Later on replacing, service_set -> serviceset
+ $type = preg_replace('/^icinga_/', '', $activity->getObjectTable());
+ return Link::create(
+ $activity->getObjectName(),
+ 'director/' . str_replace('_', '', $type),
+ ['uuid' => $activity->getObjectUuid()->toString()],
+ ['title' => $this->translate('Jump to this object')]
+ );
+ }
+
+ protected function makeBranchLink(BranchActivity $activity)
+ {
+ $type = preg_replace('/^icinga_/', '', $activity->getObjectTable());
+
+ if (Util::hasPermission('director/showconfig')) {
+ // Later on replacing, service_set -> serviceset
+ return [
+ '[' . $activity->getAuthor() . ']',
+ Link::create(
+ $activity->getAction(),
+ 'director/branch/activity',
+ array_merge(['ts' => $activity->getTimestampNs()], $this->extraParams),
+ ['title' => $this->translate('Show details related to this change')]
+ ),
+ str_replace('_', ' ', $type),
+ $this->linkObject($activity)
+ ];
+ } else {
+ return sprintf(
+ '[%s] %s %s "%s"',
+ $activity->getAuthor(),
+ $activity->getAction(),
+ $type,
+ $activity->getObjectName()
+ );
+ }
+ }
+
+ public function prepareQuery()
+ {
+ /** @var Db $connection */
+ $connection = $this->connection();
+ $query = $this->db()->select()->from(['ba' => 'director_branch_activity'], 'ba.*')
+ ->join(['b' => 'director_branch'], 'b.uuid = ba.branch_uuid', ['b.owner'])
+ ->where('branch_uuid = ?', $connection->quoteBinary($this->branchUuid->getBytes()))
+ ->order('timestamp_ns DESC');
+ if ($this->objectUuid) {
+ $query->where('ba.object_uuid = ?', $connection->quoteBinary($this->objectUuid->getBytes()));
+ }
+
+ return $query;
+ }
+}
diff --git a/library/Director/Web/Table/BranchedIcingaCommandArgumentTable.php b/library/Director/Web/Table/BranchedIcingaCommandArgumentTable.php
new file mode 100644
index 0000000..3d5dbcb
--- /dev/null
+++ b/library/Director/Web/Table/BranchedIcingaCommandArgumentTable.php
@@ -0,0 +1,78 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use gipfl\IcingaWeb2\Data\SimpleQueryPaginationAdapter;
+use gipfl\IcingaWeb2\Table\QueryBasedTable;
+use Icinga\Data\DataArray\ArrayDatasource;
+use Icinga\Module\Director\Db\Branch\Branch;
+use Icinga\Module\Director\Objects\IcingaCommand;
+use gipfl\IcingaWeb2\Link;
+
+class BranchedIcingaCommandArgumentTable extends QueryBasedTable
+{
+ /** @var IcingaCommand */
+ protected $command;
+
+ /** @var Branch */
+ protected $branch;
+
+ protected $searchColumns = [
+ 'ca.argument_name',
+ 'ca.argument_value',
+ ];
+
+ public function __construct(IcingaCommand $command, Branch $branch)
+ {
+ $this->command = $command;
+ $this->branch = $branch;
+ $this->getAttributes()->set('data-base-target', '_self');
+ }
+
+ public function renderRow($row)
+ {
+ return $this::row([
+ Link::create($row->argument_name, 'director/command/arguments', [
+ 'argument' => $row->argument_name,
+ 'uuid' => $this->command->getUniqueId()->toString(),
+ ]),
+ $row->argument_value
+ ]);
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [
+ $this->translate('Argument'),
+ $this->translate('Value'),
+ ];
+ }
+
+ protected function getPaginationAdapter()
+ {
+ return new SimpleQueryPaginationAdapter($this->getQuery());
+ }
+
+ public function getQuery()
+ {
+ return $this->prepareQuery();
+ }
+
+ protected function fetchQueryRows()
+ {
+ return $this->getQuery()->fetchAll();
+ }
+
+ protected function prepareQuery()
+ {
+ $list = [];
+ foreach ($this->command->arguments()->toPlainObject() as $name => $argument) {
+ $new = (object) [];
+ $new->argument_name = $name;
+ $new->argument_value = isset($argument->value) ? $argument->value : null;
+ $list[] = $new;
+ }
+
+ return (new ArrayDatasource($list))->select();
+ }
+}
diff --git a/library/Director/Web/Table/ChoicesTable.php b/library/Director/Web/Table/ChoicesTable.php
new file mode 100644
index 0000000..4ba2460
--- /dev/null
+++ b/library/Director/Web/Table/ChoicesTable.php
@@ -0,0 +1,65 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Module\Director\Db;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+use gipfl\IcingaWeb2\Url;
+
+class ChoicesTable extends ZfQueryBasedTable
+{
+ protected $searchColumns = ['o.object_name'];
+
+ protected $type;
+
+ /**
+ * @param $type
+ * @param Db $db
+ * @return static
+ */
+ public static function create($type, Db $db)
+ {
+ $class = __NAMESPACE__ . '\\ChoicesTable' . ucfirst($type);
+ if (! class_exists($class)) {
+ $class = __CLASS__;
+ }
+
+ /** @var static $table */
+ $table = new $class($db);
+ $table->type = $type;
+ return $table;
+ }
+
+ public function getType()
+ {
+ return $this->type;
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [$this->translate('Name')];
+ }
+
+ public function renderRow($row)
+ {
+ $type = $this->getType();
+ $url = Url::fromPath("director/templatechoice/${type}", [
+ 'name' => $row->object_name
+ ]);
+
+ return $this::row([
+ Link::create($row->object_name, $url)
+ ]);
+ }
+
+ protected function prepareQuery()
+ {
+ $type = $this->getType();
+ $table = "icinga_${type}_template_choice";
+ return $this->db()
+ ->select()
+ ->from(['o' => $table], 'object_name')
+ ->order('o.object_name');
+ }
+}
diff --git a/library/Director/Web/Table/ConfigFileDiffTable.php b/library/Director/Web/Table/ConfigFileDiffTable.php
new file mode 100644
index 0000000..1d14d5e
--- /dev/null
+++ b/library/Director/Web/Table/ConfigFileDiffTable.php
@@ -0,0 +1,140 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Util;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+
+class ConfigFileDiffTable extends ZfQueryBasedTable
+{
+ use DbHelper;
+
+ protected $leftChecksum;
+
+ protected $rightChecksum;
+
+ /**
+ * @param $leftSum
+ * @param $rightSum
+ * @param Db $connection
+ * @return static
+ */
+ public static function load($leftSum, $rightSum, Db $connection)
+ {
+ $table = new static($connection);
+ $table->getAttributes()->add('class', 'config-diff');
+ return $table->setLeftChecksum($leftSum)
+ ->setRightChecksum($rightSum);
+ }
+
+ public function renderRow($row)
+ {
+ $tr = $this::row([
+ $this->getFileFiffLink($row),
+ $row->file_path,
+ ]);
+
+ $tr->getAttributes()->add('class', 'file-' . $row->file_action);
+ return $tr;
+ }
+
+ protected function getFileFiffLink($row)
+ {
+ $params = array('file_path' => $row->file_path);
+
+ if ($row->file_checksum_left === $row->file_checksum_right) {
+ $params['config_checksum'] = $row->config_checksum_right;
+ } elseif ($row->file_checksum_left === null) {
+ $params['config_checksum'] = $row->config_checksum_right;
+ } elseif ($row->file_checksum_right === null) {
+ $params['config_checksum'] = $row->config_checksum_left;
+ } else {
+ $params['left'] = $row->config_checksum_left;
+ $params['right'] = $row->config_checksum_right;
+ return Link::create(
+ $row->file_action,
+ 'director/config/filediff',
+ $params
+ );
+ }
+
+ return Link::create($row->file_action, 'director/config/file', $params);
+ }
+
+ public function setLeftChecksum($checksum)
+ {
+ $this->leftChecksum = $checksum;
+ return $this;
+ }
+
+ public function setRightChecksum($checksum)
+ {
+ $this->rightChecksum = $checksum;
+ return $this;
+ }
+
+ public function getTitles()
+ {
+ return array(
+ $this->translate('Action'),
+ $this->translate('File'),
+ );
+ }
+
+ public function prepareQuery()
+ {
+ $db = $this->db();
+
+ $left = $db->select()
+ ->from(
+ array('cfl' => 'director_generated_config_file'),
+ array(
+ 'file_path' => 'COALESCE(cfl.file_path, cfr.file_path)',
+ 'config_checksum_left' => $this->dbHexFunc('cfl.config_checksum'),
+ 'config_checksum_right' => $this->dbHexFunc('cfr.config_checksum'),
+ 'file_checksum_left' => $this->dbHexFunc('cfl.file_checksum'),
+ 'file_checksum_right' => $this->dbHexFunc('cfr.file_checksum'),
+ 'file_action' => '(CASE WHEN cfr.config_checksum IS NULL'
+ . " THEN 'removed' WHEN cfl.file_checksum = cfr.file_checksum"
+ . " THEN 'unmodified' ELSE 'modified' END)",
+ )
+ )->joinLeft(
+ array('cfr' => 'director_generated_config_file'),
+ $db->quoteInto(
+ 'cfl.file_path = cfr.file_path AND cfr.config_checksum = ?',
+ $this->quoteBinary(hex2bin($this->rightChecksum))
+ ),
+ array()
+ )->where(
+ 'cfl.config_checksum = ?',
+ $this->quoteBinary(hex2bin($this->leftChecksum))
+ );
+
+ $right = $db->select()
+ ->from(
+ array('cfl' => 'director_generated_config_file'),
+ array(
+ 'file_path' => 'COALESCE(cfr.file_path, cfl.file_path)',
+ 'config_checksum_left' => $this->dbHexFunc('cfl.config_checksum'),
+ 'config_checksum_right' => $this->dbHexFunc('cfr.config_checksum'),
+ 'file_checksum_left' => $this->dbHexFunc('cfl.file_checksum'),
+ 'file_checksum_right' => $this->dbHexFunc('cfr.file_checksum'),
+ 'file_action' => "('created')",
+ )
+ )->joinRight(
+ array('cfr' => 'director_generated_config_file'),
+ $db->quoteInto(
+ 'cfl.file_path = cfr.file_path AND cfl.config_checksum = ?',
+ $this->quoteBinary(hex2bin($this->leftChecksum))
+ ),
+ array()
+ )->where(
+ 'cfr.config_checksum = ?',
+ $this->quoteBinary(hex2bin($this->rightChecksum))
+ )->where('cfl.file_checksum IS NULL');
+
+ return $db->select()->union(array($left, $right))->order('file_path');
+ }
+}
diff --git a/library/Director/Web/Table/CoreApiFieldsTable.php b/library/Director/Web/Table/CoreApiFieldsTable.php
new file mode 100644
index 0000000..24a6521
--- /dev/null
+++ b/library/Director/Web/Table/CoreApiFieldsTable.php
@@ -0,0 +1,106 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use gipfl\IcingaWeb2\Link;
+use ipl\Html\Table;
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Url;
+
+class CoreApiFieldsTable extends Table
+{
+ use TranslationHelper;
+
+ protected $defaultAttributes = [
+ 'class' => ['common-table'/*, 'table-row-selectable'*/],
+ //'data-base-target' => '_next',
+ ];
+
+ protected $fields;
+
+ /** @var Url */
+ protected $url;
+
+ public function __construct($fields, Url $url)
+ {
+ $this->url = $url;
+ $this->fields = $fields;
+ }
+
+ public function assemble()
+ {
+ if (empty($this->fields)) {
+ return;
+ }
+ $this->add(Html::tag('thead', Html::tag('tr', Html::wrapEach($this->getColumnsToBeRendered(), 'th'))));
+ foreach ($this->fields as $name => $field) {
+ $tr = $this::tr([
+ $this::td($name),
+ $this::td(Link::create(
+ $field->type,
+ $this->url->with('type', $field->type)
+ )),
+ $this::td($field->id)
+ // $this::td($field->array_rank),
+ // $this::td($this->renderKeyValue($field->attributes))
+ ]);
+ $this->addAttributeColumns($tr, $field->attributes);
+ $this->add($tr);
+ }
+ }
+
+ protected function addAttributeColumns(BaseHtmlElement $tr, $attrs)
+ {
+ $tr->add([
+ $this->makeBooleanColumn($attrs->state),
+ $this->makeBooleanColumn($attrs->config),
+ $this->makeBooleanColumn($attrs->required),
+ $this->makeBooleanColumn(isset($attrs->deprecated) ? $attrs->deprecated : null),
+ $this->makeBooleanColumn($attrs->no_user_modify),
+ $this->makeBooleanColumn($attrs->no_user_view),
+ $this->makeBooleanColumn($attrs->navigation),
+ ]);
+ }
+
+ protected function makeBooleanColumn($value)
+ {
+ if ($value === null) {
+ return $this::td('-');
+ }
+
+ return $this::td($value ? Html::tag('strong', 'true') : 'false');
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [
+ $this->translate('Name'),
+ $this->translate('Type'),
+ $this->translate('Id'),
+ // $this->translate('Array Rank'),
+ // $this->translate('Attributes')
+ $this->translate('State'),
+ $this->translate('Config'),
+ $this->translate('Required'),
+ $this->translate('Deprecated'),
+ $this->translate('Protected'),
+ $this->translate('Hidden'),
+ $this->translate('Nav'),
+ ];
+ }
+
+ protected function renderKeyValue($values)
+ {
+ $parts = [];
+ foreach ((array) $values as $key => $value) {
+ if (is_bool($value)) {
+ $value = $value ? 'true' : 'false';
+ }
+ $parts[] = "$key: $value";
+ }
+
+ return implode(', ', $parts);
+ }
+}
diff --git a/library/Director/Web/Table/CoreApiObjectsTable.php b/library/Director/Web/Table/CoreApiObjectsTable.php
new file mode 100644
index 0000000..c2cefea
--- /dev/null
+++ b/library/Director/Web/Table/CoreApiObjectsTable.php
@@ -0,0 +1,60 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Module\Director\Objects\IcingaEndpoint;
+use gipfl\IcingaWeb2\Link;
+use ipl\Html\Html;
+use ipl\Html\Table;
+use gipfl\Translation\TranslationHelper;
+
+class CoreApiObjectsTable extends Table
+{
+ use TranslationHelper;
+
+ protected $defaultAttributes = [
+ 'class' => ['common-table', 'table-row-selectable'],
+ 'data-base-target' => '_next',
+ ];
+
+ /** @var IcingaEndpoint */
+ protected $endpoint;
+
+ protected $objects;
+
+ protected $type;
+
+ public function __construct($objects, IcingaEndpoint $endpoint, $type)
+ {
+ $this->objects = $objects;
+ $this->endpoint = $endpoint;
+ $this->type = $type;
+ }
+
+ public function assemble()
+ {
+ if (empty($this->objects)) {
+ return;
+ }
+ $this->add(Html::tag('thead', Html::tag('tr', Html::wrapEach($this->getColumnsToBeRendered(), 'th'))));
+ foreach ($this->objects as $name) {
+ $this->add($this::tr($this::td(Link::create(
+ str_replace('!', ': ', $name),
+ 'director/inspect/object',
+ [
+ 'name' => $name,
+ 'type' => $this->type->name,
+ 'plural' => $this->type->plural_name,
+ 'endpoint' => $this->endpoint->getObjectName()
+ ]
+ ))));
+ }
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [
+ $this->translate('Name'),
+ ];
+ }
+}
diff --git a/library/Director/Web/Table/CoreApiPrototypesTable.php b/library/Director/Web/Table/CoreApiPrototypesTable.php
new file mode 100644
index 0000000..78fd964
--- /dev/null
+++ b/library/Director/Web/Table/CoreApiPrototypesTable.php
@@ -0,0 +1,43 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use ipl\Html\Html;
+use ipl\Html\Table;
+use gipfl\Translation\TranslationHelper;
+
+class CoreApiPrototypesTable extends Table
+{
+ use TranslationHelper;
+
+ protected $defaultAttributes = ['class' => ['common-table']];
+
+ protected $prototypes;
+
+ protected $typeName;
+
+ public function __construct($prototypes, $typeName)
+ {
+ $this->prototypes = $prototypes;
+ $this->typeName = $typeName;
+ }
+
+ public function assemble()
+ {
+ if (empty($this->prototypes)) {
+ return;
+ }
+ $this->add(Html::tag('thead', Html::tag('tr', Html::wrapEach($this->getColumnsToBeRendered(), 'th'))));
+ $type = $this->typeName;
+ foreach ($this->prototypes as $name) {
+ $this->add($this::tr($this::td("$type.$name()")));
+ }
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [
+ $this->translate('Name'),
+ ];
+ }
+}
diff --git a/library/Director/Web/Table/CustomvarTable.php b/library/Director/Web/Table/CustomvarTable.php
new file mode 100644
index 0000000..f9a3844
--- /dev/null
+++ b/library/Director/Web/Table/CustomvarTable.php
@@ -0,0 +1,102 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use ipl\Html\Html;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+use Zend_Db_Adapter_Abstract as ZfDbAdapter;
+use Zend_Db_Select as ZfDbSelect;
+
+class CustomvarTable extends ZfQueryBasedTable
+{
+ protected $searchColumns = array(
+ 'varname',
+ );
+
+ public function renderRow($row)
+ {
+ $tr = $this::row([
+ new Link(
+ $row->varname,
+ 'director/customvar/variants',
+ ['name' => $row->varname]
+ )
+ ]);
+
+ foreach ($this->getObjectTypes() as $type) {
+ $tr->add($this::td(Html::tag('nobr', null, sprintf(
+ $this->translate('%d / %d'),
+ $row->{"cnt_$type"},
+ $row->{"distinct_$type"}
+ ))));
+ }
+
+ return $tr;
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return array(
+ $this->translate('Variable name'),
+ $this->translate('Distinct Commands'),
+ $this->translate('Hosts'),
+ $this->translate('Services'),
+ $this->translate('Service Sets'),
+ $this->translate('Notifications'),
+ $this->translate('Users'),
+ );
+ }
+
+ protected function getObjectTypes()
+ {
+ return ['command', 'host', 'service', 'service_set', 'notification', 'user'];
+ }
+
+ public function prepareQuery()
+ {
+ $db = $this->db();
+ $varsColumns = ['varname' => 'v.varname'];
+ $varsTypes = $this->getObjectTypes();
+ foreach ($varsTypes as $type) {
+ $varsColumns["cnt_$type"] = '(0)';
+ $varsColumns["distinct_$type"] = '(0)';
+ }
+ $varsQueries = [];
+ foreach ($varsTypes as $type) {
+ $varsQueries[] = $this->makeVarSub($type, $varsColumns, $db);
+ }
+
+ $union = $db->select()->union($varsQueries, ZfDbSelect::SQL_UNION_ALL);
+
+ $columns = ['varname' => 'u.varname'];
+ foreach ($varsTypes as $column) {
+ $columns["cnt_$column"] = "SUM(u.cnt_$column)";
+ $columns["distinct_$column"] = "SUM(u.distinct_$column)";
+ }
+ return $db->select()->from(
+ array('u' => $union),
+ $columns
+ )->group('u.varname')->order('u.varname ASC')->limit(100);
+ }
+
+ /**
+ * @param string $type
+ * @param array $columns
+ * @param ZfDbAdapter $db
+ * @return ZfDbSelect
+ */
+ protected function makeVarSub($type, array $columns, ZfDbAdapter $db)
+ {
+ $columns["cnt_$type"] = 'COUNT(*)';
+ $columns["distinct_$type"] = 'COUNT(DISTINCT varvalue)';
+ return $db->select()->from(
+ ['v' => "icinga_${type}_var"],
+ $columns
+ )->join(
+ ['o' => "icinga_${type}"],
+ "o.id = v.${type}_id",
+ []
+ )->where('o.object_type != ?', 'external_object')->group('varname');
+ }
+}
diff --git a/library/Director/Web/Table/CustomvarVariantsTable.php b/library/Director/Web/Table/CustomvarVariantsTable.php
new file mode 100644
index 0000000..80fca70
--- /dev/null
+++ b/library/Director/Web/Table/CustomvarVariantsTable.php
@@ -0,0 +1,125 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\PlainObjectRenderer;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+use Zend_Db_Adapter_Abstract as ZfDbAdapter;
+use Zend_Db_Select as ZfDbSelect;
+
+class CustomvarVariantsTable extends ZfQueryBasedTable
+{
+ protected $searchColumns = ['varvalue'];
+
+ protected $varName;
+
+ public static function create(Db $db, $varName)
+ {
+ $table = new static($db);
+ $table->varName = $varName;
+ $table->getAttributes()->set('class', 'common-table');
+ return $table;
+ }
+
+ public function renderRow($row)
+ {
+ if ($row->format === 'json') {
+ $value = PlainObjectRenderer::render(json_decode($row->varvalue));
+ } else {
+ $value = $row->varvalue;
+ }
+ $tr = $this::row([
+ /* new Link(
+ $value,
+ 'director/customvar/value',
+ ['name' => $row->varvalue]
+ )*/
+ $value
+ ]);
+
+ foreach ($this->getObjectTypes() as $type) {
+ $cnt = (int) $row->{"cnt_$type"};
+ if ($cnt === 0) {
+ $cnt = '-';
+ }
+ $tr->add($this::td($cnt));
+ }
+
+ return $tr;
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return array(
+ $this->translate('Variable Value'),
+ $this->translate('Commands'),
+ $this->translate('Hosts'),
+ $this->translate('Services'),
+ $this->translate('Service Sets'),
+ $this->translate('Notifications'),
+ $this->translate('Users'),
+ );
+ }
+
+ protected function getObjectTypes()
+ {
+ return ['command', 'host', 'service', 'service_set', 'notification', 'user'];
+ }
+
+ public function prepareQuery()
+ {
+ $db = $this->db();
+ $varsColumns = ['varvalue' => 'v.varvalue'];
+ $varsTypes = $this->getObjectTypes();
+ foreach ($varsTypes as $type) {
+ $varsColumns["cnt_$type"] = '(0)';
+ }
+ $varsQueries = [];
+ foreach ($varsTypes as $type) {
+ $varsQueries[] = $this->makeVarSub($type, $varsColumns, $db);
+ }
+
+ $union = $db->select()->union($varsQueries, ZfDbSelect::SQL_UNION_ALL);
+
+ $columns = [
+ 'varvalue' => 'u.varvalue',
+ 'format' => 'u.format',
+ ];
+ foreach ($varsTypes as $column) {
+ $columns["cnt_$column"] = "SUM(u.cnt_$column)";
+ }
+ return $db->select()->from(['u' => $union], $columns)
+ ->group('u.varvalue')->group('u.format')
+ ->order('u.varvalue ASC')
+ ->order('u.format ASC')
+ ->limit(100);
+ }
+
+ /**
+ * @param string $type
+ * @param array $columns
+ * @param ZfDbAdapter $db
+ * @return ZfDbSelect
+ */
+ protected function makeVarSub($type, array $columns, ZfDbAdapter $db)
+ {
+ $columns["cnt_$type"] = 'COUNT(*)';
+ $columns['format'] = 'v.format';
+ return $db->select()->from(
+ ['v' => "icinga_${type}_var"],
+ $columns
+ )->join(
+ ['o' => "icinga_${type}"],
+ "o.id = v.${type}_id",
+ []
+ )->where(
+ 'v.varname = ?',
+ $this->varName
+ )->where(
+ 'o.object_type != ?',
+ 'external_object'
+ )->group('varvalue')->group('v.format');
+ }
+}
diff --git a/library/Director/Web/Table/DatafieldCategoryTable.php b/library/Director/Web/Table/DatafieldCategoryTable.php
new file mode 100644
index 0000000..6f07939
--- /dev/null
+++ b/library/Director/Web/Table/DatafieldCategoryTable.php
@@ -0,0 +1,64 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+use ipl\Html\Html;
+
+class DatafieldCategoryTable extends ZfQueryBasedTable
+{
+ protected $searchColumns = [
+ 'dfc.category_name',
+ 'dfc.description',
+ ];
+
+ public function getColumns()
+ {
+ return array(
+ 'id' => 'dfc.id',
+ 'category_name' => 'dfc.category_name',
+ 'description' => 'dfc.description',
+ 'assigned_fields' => 'COUNT(df.id)',
+ );
+ }
+
+ public function renderRow($row)
+ {
+ $main = [Link::create(
+ $row->category_name,
+ 'director/datafieldcategory/edit',
+ ['name' => $row->category_name]
+ )];
+
+ if ($row->description !== null && strlen($row->description)) {
+ $main[] = Html::tag('br');
+ $main[] = Html::tag('small', $row->description);
+ }
+ return $this::tr([
+ $this::td($main),
+ $this::td($row->assigned_fields)
+ ]);
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [
+ $this->translate('Category Name'),
+ $this->translate('# Used'),
+ ];
+ }
+
+ public function prepareQuery()
+ {
+ $db = $this->db();
+ return $db->select()->from(
+ ['dfc' => 'director_datafield_category'],
+ $this->getColumns()
+ )->joinLeft(
+ ['df' => 'director_datafield'],
+ 'df.category_id = dfc.id',
+ []
+ )->group('dfc.id')->group('dfc.category_name')->order('category_name ASC');
+ }
+}
diff --git a/library/Director/Web/Table/DatafieldTable.php b/library/Director/Web/Table/DatafieldTable.php
new file mode 100644
index 0000000..4b321d7
--- /dev/null
+++ b/library/Director/Web/Table/DatafieldTable.php
@@ -0,0 +1,118 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+use Zend_Db_Adapter_Abstract as ZfDbAdapter;
+use Zend_Db_Select as ZfDbSelect;
+
+class DatafieldTable extends ZfQueryBasedTable
+{
+ protected $searchColumns = [
+ 'df.varname',
+ 'df.caption',
+ ];
+
+ public function getColumns()
+ {
+ return [
+ 'id' => 'df.id',
+ 'varname' => 'df.varname',
+ 'caption' => 'df.caption',
+ 'description' => 'df.description',
+ 'datatype' => 'df.datatype',
+ 'category' => 'dfc.category_name',
+ 'assigned_fields' => 'SUM(used_fields.cnt)',
+ 'assigned_vars' => 'SUM(used_vars.cnt)',
+ ];
+ }
+
+ public function renderRow($row)
+ {
+ return $this::tr([
+ $this::td(Link::create(
+ $row->caption,
+ 'director/datafield/edit',
+ ['id' => $row->id]
+ )),
+ $this::td($row->varname),
+ $this::td($row->category),
+ $this::td($row->assigned_fields),
+ $this::td($row->assigned_vars)
+ ]);
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [
+ $this->translate('Label'),
+ $this->translate('Field name'),
+ $this->translate('Category'),
+ $this->translate('# Used'),
+ $this->translate('# Vars'),
+ ];
+ }
+
+ public function prepareQuery()
+ {
+ $db = $this->db();
+ $fieldTypes = ['command', 'host', 'notification', 'service', 'user'];
+ $varsTypes = ['command', 'host', 'notification', 'service', 'service_set', 'user'];
+
+ $fieldsQueries = [];
+ foreach ($fieldTypes as $type) {
+ $fieldsQueries[] = $this->makeDatafieldSub($type, $db);
+ }
+
+ $varsQueries = [];
+ foreach ($varsTypes as $type) {
+ $varsQueries[] = $this->makeVarSub($type, $db);
+ }
+
+ return $db->select()->from(
+ ['df' => 'director_datafield'],
+ $this->getColumns()
+ )->joinLeft(
+ ['dfc' => 'director_datafield_category'],
+ 'df.category_id = dfc.id',
+ []
+ )->joinLeft(
+ ['used_fields' => $db->select()->union($fieldsQueries, ZfDbSelect::SQL_UNION_ALL)],
+ 'used_fields.datafield_id = df.id',
+ []
+ )->joinLeft(
+ ['used_vars' => $db->select()->union($varsQueries, ZfDbSelect::SQL_UNION_ALL)],
+ 'used_vars.varname = df.varname',
+ []
+ )->group('df.id')->group('df.varname')->group('dfc.category_name')->order('caption ASC');
+ }
+
+ /**
+ * @param $type
+ * @param ZfDbAdapter $db
+ *
+ * @return ZfDbSelect
+ */
+ protected function makeDatafieldSub($type, ZfDbAdapter $db)
+ {
+ return $db->select()->from("icinga_${type}_field", [
+ 'cnt' => 'COUNT(*)',
+ 'datafield_id'
+ ])->group('datafield_id');
+ }
+
+ /**
+ * @param $type
+ * @param ZfDbAdapter $db
+ *
+ * @return ZfDbSelect
+ */
+ protected function makeVarSub($type, ZfDbAdapter $db)
+ {
+ return $db->select()->from("icinga_${type}_var", [
+ 'cnt' => 'COUNT(*)',
+ 'varname'
+ ])->group('varname');
+ }
+}
diff --git a/library/Director/Web/Table/DatalistEntryTable.php b/library/Director/Web/Table/DatalistEntryTable.php
new file mode 100644
index 0000000..70167c7
--- /dev/null
+++ b/library/Director/Web/Table/DatalistEntryTable.php
@@ -0,0 +1,73 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Module\Director\Objects\DirectorDatalist;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+
+class DatalistEntryTable extends ZfQueryBasedTable
+{
+ protected $datalist;
+
+ protected $searchColumns = [
+ 'entry_name',
+ 'entry_value'
+ ];
+
+ public function setList(DirectorDatalist $list)
+ {
+ $this->datalist = $list;
+
+ return $this;
+ }
+
+ public function getList()
+ {
+ return $this->datalist;
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'list_name' => 'l.list_name',
+ 'list_id' => 'le.list_id',
+ 'entry_name' => 'le.entry_name',
+ 'entry_value' => 'le.entry_value',
+ ];
+ }
+
+ public function renderRow($row)
+ {
+ return $this::tr([
+ $this::td(Link::create($row->entry_name, 'director/data/listentry/edit', [
+ 'list' => $row->list_name,
+ 'entry_name' => $row->entry_name,
+ ])),
+ $this::td($row->entry_value)
+ ]);
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [
+ 'entry_name' => $this->translate('Key'),
+ 'entry_value' => $this->translate('Label'),
+ ];
+ }
+
+ public function prepareQuery()
+ {
+ return $this->db()->select()->from(
+ ['le' => 'director_datalist_entry'],
+ $this->getColumns()
+ )->join(
+ ['l' => 'director_datalist'],
+ 'l.id = le.list_id',
+ []
+ )->where(
+ 'le.list_id = ?',
+ $this->getList()->id
+ )->order('le.entry_name ASC');
+ }
+}
diff --git a/library/Director/Web/Table/DatalistTable.php b/library/Director/Web/Table/DatalistTable.php
new file mode 100644
index 0000000..7b35fe0
--- /dev/null
+++ b/library/Director/Web/Table/DatalistTable.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+
+class DatalistTable extends ZfQueryBasedTable
+{
+ protected $searchColumns = ['list_name'];
+
+ public function getColumns()
+ {
+ return [
+ 'id' => 'l.id',
+ 'list_name' => 'l.list_name',
+ ];
+ }
+
+ public function renderRow($row)
+ {
+ return $this::tr($this::td(Link::create(
+ $row->list_name,
+ 'director/data/listentry',
+ array('list' => $row->list_name)
+ )));
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [$this->translate('List name')];
+ }
+
+ public function prepareQuery()
+ {
+ return $this->db()->select()->from(
+ ['l' => 'director_datalist'],
+ $this->getColumns()
+ )->order('list_name ASC');
+ }
+}
diff --git a/library/Director/Web/Table/DbHelper.php b/library/Director/Web/Table/DbHelper.php
new file mode 100644
index 0000000..573f946
--- /dev/null
+++ b/library/Director/Web/Table/DbHelper.php
@@ -0,0 +1,67 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Zend_Db_Expr as Expr;
+
+trait DbHelper
+{
+ public function dbHexFunc($column)
+ {
+ if ($this->isPgsql()) {
+ return sprintf("LOWER(ENCODE(%s, 'hex'))", $column);
+ } else {
+ return sprintf("LOWER(HEX(%s))", $column);
+ }
+ }
+
+ public function quoteBinary($binary)
+ {
+ if ($binary === '') {
+ return '';
+ }
+
+ if (is_array($binary)) {
+ return array_map([$this, 'quoteBinary'], $binary);
+ }
+
+ if ($this->isPgsql()) {
+ return new Expr("'\\x" . bin2hex($binary) . "'");
+ }
+
+ return new Expr('0x' . bin2hex($binary));
+ }
+
+ public function isPgsql()
+ {
+ return $this->db() instanceof \Zend_Db_Adapter_Pdo_Pgsql;
+ }
+
+ public function isMysql()
+ {
+ return $this->db() instanceof \Zend_Db_Adapter_Pdo_Mysql;
+ }
+
+ public function wantBinaryValue($value)
+ {
+ if (is_resource($value)) {
+ return stream_get_contents($value);
+ }
+
+ return $value;
+ }
+
+ public function getChecksum($checksum)
+ {
+ return bin2hex($this->wantBinaryValue($checksum));
+ }
+
+ public function getShortChecksum($checksum)
+ {
+ if ($checksum === null) {
+ return null;
+ }
+
+ return substr($this->getChecksum($checksum), 0, 7);
+ }
+}
diff --git a/library/Director/Web/Table/Dependency/DependencyInfoTable.php b/library/Director/Web/Table/Dependency/DependencyInfoTable.php
new file mode 100644
index 0000000..28aa856
--- /dev/null
+++ b/library/Director/Web/Table/Dependency/DependencyInfoTable.php
@@ -0,0 +1,101 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table\Dependency;
+
+use Icinga\Application\Modules\Module;
+use Icinga\Module\Director\Application\DependencyChecker;
+use Icinga\Web\Url;
+
+class DependencyInfoTable
+{
+ protected $module;
+
+ protected $checker;
+
+ public function __construct(DependencyChecker $checker, Module $module)
+ {
+ $this->module = $module;
+ $this->checker = $checker;
+ }
+
+ protected function linkToModule($name, $icon)
+ {
+ return Html::link(
+ Html::escape($name),
+ Html::webUrl('config/module', ['name' => $name]),
+ [
+ 'class' => "icon-$icon"
+ ]
+ );
+ }
+
+ public function render()
+ {
+ $html = '<table class="common-table table-row-selectable">
+<thead>
+<tr>
+ <th>' . Html::escape($this->translate('Module name')) . '</th>
+ <th>' . Html::escape($this->translate('Required')) . '</th>
+ <th>' . Html::escape($this->translate('Installed')) . '</th>
+</tr>
+</thead>
+<tbody data-base-target="_next">
+';
+ foreach ($this->checker->getDependencies($this->module) as $dependency) {
+ $name = $dependency->getName();
+ $isLibrary = substr($name, 0, 11) === 'icinga-php-';
+ $rowAttributes = $isLibrary ? ['data-base-target' => '_self'] : null;
+ if ($dependency->isSatisfied()) {
+ if ($dependency->isSatisfied()) {
+ $icon = 'ok';
+ } else {
+ $icon = 'cancel';
+ }
+ $link = $isLibrary ? $this->noLink($name, $icon) : $this->linkToModule($name, $icon);
+ $installed = $dependency->getInstalledVersion();
+ } elseif ($dependency->isInstalled()) {
+ $installed = sprintf('%s (%s)', $dependency->getInstalledVersion(), $this->translate('disabled'));
+ $link = $this->linkToModule($name, 'cancel');
+ } else {
+ $installed = $this->translate('missing');
+ $repository = $isLibrary ? $name : "icingaweb2-module-$name";
+ $link = sprintf(
+ '%s (%s)',
+ $this->noLink($name, 'cancel'),
+ Html::linkToGitHub(Html::escape($this->translate('more')), 'Icinga', $repository)
+ );
+ }
+
+ $html .= $this->htmlRow([
+ $link,
+ Html::escape($dependency->getRequirement()),
+ Html::escape($installed)
+ ], $rowAttributes);
+ }
+
+ return $html . '</tbody>
+</table>
+';
+ }
+
+ protected function noLink($label, $icon)
+ {
+ return Html::link(Html::escape($label), Url::fromRequest()->with('rnd', rand(1, 100000)), [
+ 'class' => "icon-$icon"
+ ]);
+ }
+
+ protected function translate($string)
+ {
+ return \mt('director', $string);
+ }
+
+ protected function htmlRow(array $cols, $rowAttributes)
+ {
+ $content = '';
+ foreach ($cols as $escapedContent) {
+ $content .= Html::tag('td', null, $escapedContent);
+ }
+ return Html::tag('tr', $rowAttributes, $content);
+ }
+}
diff --git a/library/Director/Web/Table/Dependency/Html.php b/library/Director/Web/Table/Dependency/Html.php
new file mode 100644
index 0000000..092f799
--- /dev/null
+++ b/library/Director/Web/Table/Dependency/Html.php
@@ -0,0 +1,74 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table\Dependency;
+
+use Icinga\Web\Url;
+use InvalidArgumentException;
+
+/**
+ * Minimal HTML helper, as we might be forced to run without ipl
+ */
+class Html
+{
+ public static function tag($tag, $attributes = [], $escapedContent = null)
+ {
+ $result = "<$tag";
+ if (! empty($attributes)) {
+ foreach ($attributes as $name => $value) {
+ if (! preg_match('/^[a-z][a-z0-9:-]*$/i', $name)) {
+ throw new InvalidArgumentException("Invalid attribute name: '$name'");
+ }
+
+ $result .= " $name=\"" . self::escapeAttributeValue($value) . '"';
+ }
+ }
+
+ return "$result>$escapedContent</$tag>";
+ }
+
+ public static function webUrl($path, $params)
+ {
+ return Url::fromPath($path, $params);
+ }
+
+ public static function link($escapedLabel, $url, $attributes = [])
+ {
+ return static::tag('a', [
+ 'href' => $url,
+ ] + $attributes, $escapedLabel);
+ }
+
+ public static function linkToGitHub($escapedLabel, $namespace, $repository)
+ {
+ return static::link(
+ $escapedLabel,
+ 'https://github.com/' . urlencode($namespace) . '/' . urlencode($repository),
+ [
+ 'target' => '_blank',
+ 'rel' => 'noreferrer',
+ 'class' => 'icon-forward'
+ ]
+ );
+ }
+
+ protected static function escapeAttributeValue($value)
+ {
+ $value = str_replace('"', '&quot;', $value);
+ // Escape ambiguous ampersands
+ return preg_replace_callback('/&[0-9A-Z]+;/i', function ($match) {
+ $subject = $match[0];
+
+ if (htmlspecialchars_decode($subject, ENT_COMPAT | ENT_HTML5) === $subject) {
+ // Ambiguous ampersand
+ return str_replace('&', '&amp;', $subject);
+ }
+
+ return $subject;
+ }, $value);
+ }
+
+ public static function escape($any)
+ {
+ return htmlspecialchars($any);
+ }
+}
diff --git a/library/Director/Web/Table/DependencyTemplateUsageTable.php b/library/Director/Web/Table/DependencyTemplateUsageTable.php
new file mode 100644
index 0000000..d7537c5
--- /dev/null
+++ b/library/Director/Web/Table/DependencyTemplateUsageTable.php
@@ -0,0 +1,22 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+class DependencyTemplateUsageTable extends TemplateUsageTable
+{
+ public function getTypes()
+ {
+ return [
+ 'templates' => $this->translate('Templates'),
+ 'applyrules' => $this->translate('Apply Rules'),
+ ];
+ }
+
+ protected function getTypeSummaryDefinitions()
+ {
+ return [
+ 'templates' => $this->getSummaryLine('template'),
+ 'applyrules' => $this->getSummaryLine('apply'),
+ ];
+ }
+}
diff --git a/library/Director/Web/Table/DeploymentLogTable.php b/library/Director/Web/Table/DeploymentLogTable.php
new file mode 100644
index 0000000..2d5cb94
--- /dev/null
+++ b/library/Director/Web/Table/DeploymentLogTable.php
@@ -0,0 +1,90 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+use Icinga\Date\DateFormatter;
+
+class DeploymentLogTable extends ZfQueryBasedTable
+{
+ use DbHelper;
+
+ protected $activeStageName;
+
+ public function setActiveStageName($name)
+ {
+ $this->activeStageName = $name;
+ return $this;
+ }
+
+ public function assemble()
+ {
+ $this->getAttributes()->add('class', 'deployment-log');
+ }
+
+ public function renderRow($row)
+ {
+ $this->splitByDay($row->start_time);
+
+ $shortSum = $this->getShortChecksum($row->config_checksum);
+ $tr = $this::tr([
+ $this::td(Link::create(
+ $shortSum === null ? $row->peer_identity : [$row->peer_identity, " ($shortSum)"],
+ 'director/deployment',
+ ['id' => $row->id]
+ )),
+ $this::td(DateFormatter::formatTime($row->start_time))
+ ])->addAttributes(['class' => $this->getMyRowClasses($row)]);
+
+ return $tr;
+ }
+
+ protected function getMyRowClasses($row)
+ {
+ if ($row->startup_succeeded === 'y') {
+ $classes = ['succeeded'];
+ } elseif ($row->startup_succeeded === 'n') {
+ $classes = ['failed'];
+ } elseif ($row->stage_collected === null) {
+ $classes = ['pending'];
+ } elseif ($row->dump_succeeded === 'y') {
+ $classes = ['sent'];
+ } else {
+ // TODO: does this ever be stored?
+ $classes = ['notsent'];
+ }
+
+ if ($this->activeStageName !== null
+ && $row->stage_name === $this->activeStageName
+ ) {
+ $classes[] = 'running';
+ }
+
+ return $classes;
+ }
+
+ public function getColumns()
+ {
+ $columns = [
+ 'id' => 'l.id',
+ 'peer_identity' => 'l.peer_identity',
+ 'start_time' => 'UNIX_TIMESTAMP(l.start_time)',
+ 'stage_collected' => 'l.stage_collected',
+ 'dump_succeeded' => 'l.dump_succeeded',
+ 'stage_name' => 'l.stage_name',
+ 'startup_succeeded' => 'l.startup_succeeded',
+ 'config_checksum' => 'l.config_checksum',
+ ];
+
+ return $columns;
+ }
+
+ public function prepareQuery()
+ {
+ return $this->db()->select()->from(
+ array('l' => 'director_deployment_log'),
+ $this->getColumns()
+ )->order('l.start_time DESC')->limit(100);
+ }
+}
diff --git a/library/Director/Web/Table/FilterableByUsage.php b/library/Director/Web/Table/FilterableByUsage.php
new file mode 100644
index 0000000..5e8695f
--- /dev/null
+++ b/library/Director/Web/Table/FilterableByUsage.php
@@ -0,0 +1,10 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+interface FilterableByUsage
+{
+ public function showOnlyUsed();
+
+ public function showOnlyUnUsed();
+}
diff --git a/library/Director/Web/Table/GeneratedConfigFileTable.php b/library/Director/Web/Table/GeneratedConfigFileTable.php
new file mode 100644
index 0000000..97f7091
--- /dev/null
+++ b/library/Director/Web/Table/GeneratedConfigFileTable.php
@@ -0,0 +1,120 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\IcingaConfig\IcingaConfig;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+
+class GeneratedConfigFileTable extends ZfQueryBasedTable
+{
+ use DbHelper;
+
+ protected $searchColumns = ['file_path'];
+
+ protected $deploymentId;
+
+ protected $activeFile;
+
+ /** @var IcingaConfig */
+ protected $config;
+
+ public static function load(IcingaConfig $config, Db $db)
+ {
+ $table = new static($db);
+ $table->config = $config;
+ $table->getAttributes()->set('data-base-target', '_self');
+ return $table;
+ }
+
+ public function renderRow($row)
+ {
+ $counts = implode(' / ', [
+ $row->cnt_object,
+ $row->cnt_template,
+ $row->cnt_apply
+ ]);
+
+ $tr = $this::row([
+ $this->getFileLink($row),
+ $counts,
+ $row->size
+ ]);
+
+ if ($row->file_path === $this->activeFile) {
+ $tr->getAttributes()->add('class', 'active');
+ }
+
+ return $tr;
+ }
+
+ public function setActiveFilename($filename)
+ {
+ $this->activeFile = $filename;
+ return $this;
+ }
+
+ protected function getFileLink($row)
+ {
+ $params = [
+ 'config_checksum' => $row->config_checksum,
+ 'file_path' => $row->file_path
+ ];
+
+ if ($this->deploymentId) {
+ $params['deployment_id'] = $this->deploymentId;
+ }
+
+ return Link::create($row->file_path, 'director/config/file', $params);
+ }
+
+ public function setDeploymentId($id)
+ {
+ if ($id) {
+ $this->deploymentId = (int) $id;
+ }
+
+ return $this;
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [
+ $this->translate('File'),
+ $this->translate('Object/Tpl/Apply'),
+ $this->translate('Size'),
+ ];
+ }
+
+ public function prepareQuery()
+ {
+ $columns = [
+ 'file_path' => 'cf.file_path',
+ 'size' => 'LENGTH(f.content)',
+ 'cnt_object' => 'f.cnt_object',
+ 'cnt_template' => 'f.cnt_template',
+ 'cnt_apply' => 'f.cnt_apply',
+ 'cnt_all' => "f.cnt_object || ' / ' || f.cnt_template || ' / ' || f.cnt_apply",
+ 'checksum' => 'LOWER(HEX(f.checksum))',
+ 'config_checksum' => 'LOWER(HEX(cf.config_checksum))',
+ ];
+
+ if ($this->isPgsql()) {
+ $columns['checksum'] = "LOWER(ENCODE(f.checksum, 'hex'))";
+ $columns['config_checksum'] = "LOWER(ENCODE(cf.config_checksum, 'hex'))";
+ }
+
+ return $this->db()->select()->from(
+ ['cf' => 'director_generated_config_file'],
+ $columns
+ )->join(
+ ['f' => 'director_generated_file'],
+ 'cf.file_checksum = f.checksum',
+ []
+ )->where(
+ 'config_checksum = ?',
+ $this->quoteBinary($this->config->getChecksum())
+ )->order('cf.file_path ASC');
+ }
+}
diff --git a/library/Director/Web/Table/GroupMemberTable.php b/library/Director/Web/Table/GroupMemberTable.php
new file mode 100644
index 0000000..b0814ad
--- /dev/null
+++ b/library/Director/Web/Table/GroupMemberTable.php
@@ -0,0 +1,201 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use gipfl\IcingaWeb2\Table\Extension\MultiSelect;
+use Icinga\Data\Filter\Filter;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\IcingaConfig\AssignRenderer;
+use Icinga\Module\Director\Objects\IcingaObjectGroup;
+use Exception;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+use gipfl\IcingaWeb2\Url;
+
+class GroupMemberTable extends ZfQueryBasedTable
+{
+ use MultiSelect;
+
+ protected $searchColumns = [
+ 'o.object_name',
+ // membership_type
+ ];
+
+ protected $type;
+
+ /** @var IcingaObjectGroup */
+ protected $group;
+
+ /**
+ * @param $type
+ * @param Db $db
+ * @return static
+ */
+ public static function create($type, Db $db)
+ {
+ $class = __NAMESPACE__ . '\\GroupMemberTable' . ucfirst($type);
+ if (! class_exists($class)) {
+ $class = __CLASS__;
+ }
+
+ /** @var static $table */
+ $table = new $class($db);
+ $table->type = $type;
+ return $table;
+ }
+ public function assemble()
+ {
+ if ($this->type === 'host') {
+ $this->enableMultiSelect(
+ 'director/hosts/edit',
+ 'director/hosts',
+ ['name']
+ );
+ }
+ }
+
+ public function setGroup(IcingaObjectGroup $group)
+ {
+ $this->group = $group;
+ return $this;
+ }
+
+ public function getType()
+ {
+ return $this->type;
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ if ($this->group === null) {
+ return [
+ $this->translate('Group'),
+ $this->translate('Member'),
+ $this->translate('via')
+ ];
+ } else {
+ return [
+ $this->translate('Member'),
+ $this->translate('via')
+ ];
+ }
+ }
+
+ public function renderRow($row)
+ {
+ $type = $this->getType();
+ if ($row->object_type === 'apply') {
+ $params = [
+ 'id' => $row->id
+ ];
+ } elseif (isset($row->host_id)) {
+ // I would prefer to see host=<name> and set=<name>, but joining
+ // them here is pointless. We should use DeferredHtml for these,
+ // remember hosts/sets we need and fetch them in a single query at
+ // rendering time. For now, this works fine - just... the URLs are
+ // not so nice
+ $params = [
+ 'name' => $row->object_name,
+ 'host_id' => $row->host_id
+ ];
+ } elseif (isset($row->service_set_id)) {
+ $params = [
+ 'name' => $row->object_name,
+ 'set_id' => $row->service_set_id
+ ];
+ } else {
+ $params = [
+ 'name' => $row->object_name
+ ];
+ }
+
+ $url = Url::fromPath("director/${type}", $params);
+
+ $tr = $this::tr();
+
+ if ($this->group === null) {
+ $tr->add($this::td($row->group_name));
+ }
+ $link = Link::create($row->object_name, $url);
+ if ($row->object_type === 'apply') {
+ $link = [
+ $link,
+ ' (where ',
+ $this->renderApplyFilter($row->assign_filter),
+ ')'
+ ];
+ }
+
+ $tr->add([
+ $this::td($link),
+ $this::td($row->membership_type)
+ ]);
+
+ return $tr;
+ }
+
+ protected function renderApplyFilter($assignFilter)
+ {
+ try {
+ $string = AssignRenderer::forFilter(
+ Filter::fromQueryString($assignFilter)
+ )->renderAssign();
+ // Do not prefix it
+ $string = preg_replace('/^assign where /', '', $string);
+ } catch (Exception $e) {
+ // ignore errors in filter rendering
+ $string = 'Error in Filter rendering: ' . $e->getMessage();
+ }
+
+ return $string;
+ }
+
+ protected function prepareQuery()
+ {
+ // select h.object_name, hg.object_name,
+ // CASE WHEN hgh.host_id IS NULL THEN 'apply' ELSE 'direct' END AS assi
+ // from icinga_hostgroup_host_resolved hgr join icinga_host h on h.id = hgr.host_id
+ // join icinga_hostgroup hg on hgr.hostgroup_id = hg.id
+ // left join icinga_hostgroup_host hgh on hgh.host_id = h.id and hgh.hostgroup_id = hg.id;
+
+ $type = $this->getType();
+ $columns = [
+ 'o.id',
+ 'o.object_type',
+ 'o.object_name',
+ 'membership_type' => "CASE WHEN go.${type}_id IS NULL THEN 'apply' ELSE 'direct' END"
+ ];
+
+ if ($this->group === null) {
+ $columns = ['group_name' => 'g.object_name'] + $columns;
+ }
+ if ($type === 'service') {
+ $columns[] = 'o.assign_filter';
+ $columns[] = 'o.host_id';
+ $columns[] = 'o.service_set_id';
+ }
+
+ $query = $this->db()->select()->from(
+ ['gro' => "icinga_${type}group_${type}_resolved"],
+ $columns
+ )->join(
+ ['o' => "icinga_${type}"],
+ "o.id = gro.${type}_id",
+ []
+ )->join(
+ ['g' => "icinga_${type}group"],
+ "gro.${type}group_id = g.id",
+ []
+ )->joinLeft(
+ ['go' => "icinga_${type}group_${type}"],
+ "go.${type}_id = o.id AND go.${type}group_id = g.id",
+ []
+ )->order('o.object_name');
+
+ if ($this->group !== null) {
+ $query->where('g.id = ?', $this->group->get('id'));
+ }
+
+ return $query;
+ }
+}
diff --git a/library/Director/Web/Table/HostTemplateUsageTable.php b/library/Director/Web/Table/HostTemplateUsageTable.php
new file mode 100644
index 0000000..2d1ee2f
--- /dev/null
+++ b/library/Director/Web/Table/HostTemplateUsageTable.php
@@ -0,0 +1,22 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+class HostTemplateUsageTable extends TemplateUsageTable
+{
+ public function getTypes()
+ {
+ return [
+ 'templates' => $this->translate('Templates'),
+ 'objects' => $this->translate('Objects'),
+ ];
+ }
+
+ protected function getTypeSummaryDefinitions()
+ {
+ return [
+ 'templates' => $this->getSummaryLine('template'),
+ 'objects' => $this->getSummaryLine('object'),
+ ];
+ }
+}
diff --git a/library/Director/Web/Table/IcingaAppliedServiceTable.php b/library/Director/Web/Table/IcingaAppliedServiceTable.php
new file mode 100644
index 0000000..b669296
--- /dev/null
+++ b/library/Director/Web/Table/IcingaAppliedServiceTable.php
@@ -0,0 +1,49 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Module\Director\Objects\IcingaService;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+
+class IcingaAppliedServiceTable extends ZfQueryBasedTable
+{
+ protected $service;
+
+ protected $searchColumns = array(
+ 'service',
+ );
+
+ public function setService(IcingaService $service)
+ {
+ $this->service = $service;
+ return $this;
+ }
+
+ public function renderRow($row)
+ {
+ return $this::row([
+ new Link($row->service, 'director/service', ['id' => $row->id])
+ ]);
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [$this->translate('Servicename')];
+ }
+
+ public function prepareQuery()
+ {
+ return $this->db()->select()->from(
+ array('s' => 'icinga_service'),
+ array()
+ )->joinLeft(
+ array('si' => 'icinga_service_inheritance'),
+ 's.id = si.service_id',
+ array()
+ )->where(
+ 'si.parent_service_id = ?',
+ $this->service->id
+ )->where('s.object_type = ?', 'apply');
+ }
+}
diff --git a/library/Director/Web/Table/IcingaCommandArgumentTable.php b/library/Director/Web/Table/IcingaCommandArgumentTable.php
new file mode 100644
index 0000000..37cbc78
--- /dev/null
+++ b/library/Director/Web/Table/IcingaCommandArgumentTable.php
@@ -0,0 +1,89 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Data\DataArray\ArrayDatasource;
+use Icinga\Module\Director\Data\Json;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Db\Branch\Branch;
+use Icinga\Module\Director\Db\Branch\BranchModificationStore;
+use Icinga\Module\Director\Objects\IcingaCommand;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+
+class IcingaCommandArgumentTable extends ZfQueryBasedTable
+{
+ /** @var IcingaCommand */
+ protected $command;
+
+ /** @var Branch */
+ protected $branch;
+
+ protected $searchColumns = [
+ 'ca.argument_name',
+ 'ca.argument_value',
+ ];
+
+ public function __construct(IcingaCommand $command, Branch $branch)
+ {
+ $this->command = $command;
+ $this->branch = $branch;
+ parent::__construct($command->getConnection());
+ $this->getAttributes()->set('data-base-target', '_self');
+ }
+
+ public function renderRow($row)
+ {
+ return $this::row([
+ Link::create($row->argument_name, 'director/command/arguments', [
+ 'argument' => $row->argument_name,
+ 'name' => $this->command->getObjectName()
+ ]),
+ $row->argument_value
+ ]);
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [
+ $this->translate('Argument'),
+ $this->translate('Value'),
+ ];
+ }
+
+ public function prepareQuery()
+ {
+ $db = $this->db();
+ if ($this->branch->isBranch()) {
+ return (new ArrayDatasource((array) $this->command->arguments()->toPlainObject()))->select();
+ /** @var Db $connection */
+ $connection = $this->connection();
+ $store = new BranchModificationStore($connection, 'command');
+ $modification = $store->loadOptionalModificationByName(
+ $this->command->getObjectName(),
+ $this->branch->getUuid()
+ );
+ if ($modification) {
+ $props = $modification->getProperties()->jsonSerialize();
+ if (isset($props->arguments)) {
+ return new ArrayDatasource((array) $this->command->arguments()->toPlainObject());
+ }
+ }
+ }
+ $id = $this->command->get('id');
+ if ($id === null) {
+ return new ArrayDatasource([]);
+ }
+ return $this->db()->select()->from(
+ ['ca' => 'icinga_command_argument'],
+ [
+ 'id' => 'ca.id',
+ 'argument_name' => "COALESCE(ca.argument_name, '(none)')",
+ 'argument_value' => 'ca.argument_value',
+ ]
+ )->where(
+ 'ca.command_id = ?',
+ $id
+ )->order('ca.sort_order')->order('ca.argument_name')->limit(100);
+ }
+}
diff --git a/library/Director/Web/Table/IcingaHostAppliedForServiceTable.php b/library/Director/Web/Table/IcingaHostAppliedForServiceTable.php
new file mode 100644
index 0000000..0d2f8e8
--- /dev/null
+++ b/library/Director/Web/Table/IcingaHostAppliedForServiceTable.php
@@ -0,0 +1,117 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use ipl\Html\Html;
+use Icinga\Data\DataArray\ArrayDatasource;
+use Icinga\Module\Director\CustomVariable\CustomVariableDictionary;
+use Icinga\Module\Director\Objects\IcingaHost;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\SimpleQueryBasedTable;
+
+class IcingaHostAppliedForServiceTable extends SimpleQueryBasedTable
+{
+ protected $title;
+
+ protected $host;
+
+ /** @var CustomVariableDictionary */
+ protected $cv;
+
+ protected $searchColumns = [
+ 'service',
+ ];
+
+ /** @var bool */
+ protected $readonly = false;
+
+ /** @var string|null */
+ protected $highlightedService;
+
+ /**
+ * @param IcingaHost $host
+ * @param CustomVariableDictionary $dict
+ * @return static
+ */
+ public static function load(IcingaHost $host, CustomVariableDictionary $dict)
+ {
+ $table = (new static())->setHost($host)->setDictionary($dict);
+ $table->getAttributes()->set('data-base-target', '_self');
+ return $table;
+ }
+
+ public function setDictionary(CustomVariableDictionary $dict)
+ {
+ $this->cv = $dict;
+ return $this;
+ }
+
+ public function setTitle($title)
+ {
+ $this->title = $title;
+ return $this;
+ }
+
+ public function setHost(IcingaHost $host)
+ {
+ $this->host = $host;
+ return $this;
+ }
+
+ /**
+ * Show no related links
+ *
+ * @param bool $readonly
+ * @return $this
+ */
+ public function setReadonly($readonly = true)
+ {
+ $this->readonly = (bool) $readonly;
+
+ return $this;
+ }
+
+ public function highlightService($service)
+ {
+ $this->highlightedService = $service;
+
+ return $this;
+ }
+
+ public function renderRow($row)
+ {
+ if ($this->readonly) {
+ if ($this->highlightedService === $row->service) {
+ $link = Html::tag('span', ['class' => 'icon-right-big'], $row->service);
+ } else {
+ $link = $row->service;
+ }
+ } else {
+ $link = Link::create($row->service, 'director/host/appliedservice', [
+ 'name' => $this->host->object_name,
+ 'service' => $row->service,
+ ]);
+ }
+
+ return $this::row([$link]);
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [
+ $this->title ?: $this->translate('Service name'),
+ ];
+ }
+
+ public function prepareQuery()
+ {
+ $data = [];
+ foreach ($this->cv->getValue() as $key => $var) {
+ $data[] = (object) array(
+ 'service' => $key,
+ );
+ }
+
+ return (new ArrayDatasource($data))->select();
+ }
+}
diff --git a/library/Director/Web/Table/IcingaHostAppliedServicesTable.php b/library/Director/Web/Table/IcingaHostAppliedServicesTable.php
new file mode 100644
index 0000000..415903b
--- /dev/null
+++ b/library/Director/Web/Table/IcingaHostAppliedServicesTable.php
@@ -0,0 +1,207 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use ipl\Html\Html;
+use Icinga\Data\DataArray\ArrayDatasource;
+use Icinga\Data\Filter\Filter;
+use Icinga\Exception\IcingaException;
+use Icinga\Module\Director\IcingaConfig\AssignRenderer;
+use Icinga\Module\Director\Objects\HostApplyMatches;
+use Icinga\Module\Director\Objects\IcingaHost;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\SimpleQueryBasedTable;
+
+class IcingaHostAppliedServicesTable extends SimpleQueryBasedTable
+{
+ protected $title;
+
+ /** @var IcingaHost */
+ protected $host;
+
+ /** @var \Zend_Db_Adapter_Abstract */
+ protected $db;
+
+ /** @var bool */
+ protected $readonly = false;
+
+ /** @var string|null */
+ protected $highlightedService;
+
+ private $allApplyRules;
+
+ /**
+ * @param IcingaHost $host
+ * @return static
+ */
+ public static function load(IcingaHost $host)
+ {
+ $table = (new static())->setHost($host);
+ $table->getAttributes()->set('data-base-target', '_self');
+ return $table;
+ }
+
+ public function setTitle($title)
+ {
+ $this->title = $title;
+ return $this;
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [$this->title];
+ }
+
+ public function setHost(IcingaHost $host)
+ {
+ $this->host = $host;
+ $this->db = $host->getDb();
+ return $this;
+ }
+
+ /**
+ * Show no related links
+ *
+ * @param bool $readonly
+ * @return $this
+ */
+ public function setReadonly($readonly = true)
+ {
+ $this->readonly = (bool) $readonly;
+
+ return $this;
+ }
+
+ public function highlightService($service)
+ {
+ $this->highlightedService = $service;
+
+ return $this;
+ }
+
+ public function renderRow($row)
+ {
+ $classes = [];
+ if ($row->blacklisted === 'y') {
+ $classes[] = 'strike-links';
+ }
+ if ($row->disabled === 'y') {
+ $classes[] = 'disabled';
+ }
+
+ $attributes = empty($classes) ? null : ['class' => $classes];
+
+ if ($this->readonly) {
+ if ($this->highlightedService === $row->name) {
+ $link = Html::tag('a', ['class' => 'icon-right-big'], $row->name);
+ } else {
+ $link = Html::tag('a', $row->name);
+ }
+ } else {
+ $applyFor = '';
+ if (! empty($row->apply_for)) {
+ $applyFor = sprintf('(apply for %s) ', $row->apply_for);
+ }
+
+ $link = Link::create(sprintf(
+ $this->translate('%s %s(%s)'),
+ $row->name,
+ $applyFor,
+ $this->renderApplyFilter($row->filter)
+ ), 'director/host/appliedservice', [
+ 'name' => $this->host->getObjectName(),
+ 'service_id' => $row->id,
+ ]);
+ }
+
+ return $this::row([$link], $attributes);
+ }
+
+ /**
+ * @param Filter $assignFilter
+ *
+ * @return string
+ */
+ protected function renderApplyFilter(Filter $assignFilter)
+ {
+ try {
+ $string = AssignRenderer::forFilter($assignFilter)->renderAssign();
+ } catch (IcingaException $e) {
+ $string = 'Error in Filter rendering: ' . $e->getMessage();
+ }
+
+ return $string;
+ }
+
+ /**
+ * @return \Icinga\Data\SimpleQuery
+ */
+ public function prepareQuery()
+ {
+ $services = [];
+ $matcher = HostApplyMatches::prepare($this->host);
+ foreach ($this->getAllApplyRules() as $rule) {
+ if ($matcher->matchesFilter($rule->filter)) {
+ $services[] = $rule;
+ }
+ }
+
+ $ds = new ArrayDatasource($services);
+ return $ds->select()->columns([
+ 'id' => 'id',
+ 'uuid' => 'uuid',
+ 'name' => 'name',
+ 'filter' => 'filter',
+ 'disabled' => 'disabled',
+ 'blacklisted' => 'blacklisted',
+ 'assign_filter' => 'assign_filter',
+ 'apply_for' => 'apply_for',
+ ]);
+ }
+
+ /***
+ * @return array
+ */
+ protected function getAllApplyRules()
+ {
+ if ($this->allApplyRules === null) {
+ $this->allApplyRules = $this->fetchAllApplyRules();
+ foreach ($this->allApplyRules as $rule) {
+ $rule->filter = Filter::fromQueryString($rule->assign_filter);
+ }
+ }
+
+ return $this->allApplyRules;
+ }
+
+ /**
+ * @return array
+ */
+ protected function fetchAllApplyRules()
+ {
+ $db = $this->db;
+ $hostId = $this->host->get('id');
+ $query = $db->select()->from(
+ ['s' => 'icinga_service'],
+ [
+ 'id' => 's.id',
+ 'uuid' => 's.uuid',
+ 'name' => 's.object_name',
+ 'assign_filter' => 's.assign_filter',
+ 'apply_for' => 's.apply_for',
+ 'disabled' => 's.disabled',
+ 'blacklisted' => $hostId ? "CASE WHEN hsb.service_id IS NULL THEN 'n' ELSE 'y' END" : "('n')",
+ ]
+ )->where('object_type = ? AND assign_filter IS NOT NULL', 'apply')
+ ->order('s.object_name');
+ if ($hostId) {
+ $query->joinLeft(
+ ['hsb' => 'icinga_host_service_blacklist'],
+ $db->quoteInto('s.id = hsb.service_id AND hsb.host_id = ?', $hostId),
+ []
+ );
+ }
+
+ return $db->fetchAll($query);
+ }
+}
diff --git a/library/Director/Web/Table/IcingaHostsMatchingFilterTable.php b/library/Director/Web/Table/IcingaHostsMatchingFilterTable.php
new file mode 100644
index 0000000..8d225bf
--- /dev/null
+++ b/library/Director/Web/Table/IcingaHostsMatchingFilterTable.php
@@ -0,0 +1,71 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use gipfl\IcingaWeb2\Data\SimpleQueryPaginationAdapter;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\QueryBasedTable;
+use Icinga\Data\DataArray\ArrayDatasource;
+use Icinga\Data\Filter\Filter;
+use Icinga\Data\SimpleQuery;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Resolver\IcingaHostObjectResolver;
+
+class IcingaHostsMatchingFilterTable extends QueryBasedTable
+{
+ protected $searchColumns = [
+ 'object_name',
+ ];
+
+ /** @var ArrayDatasource */
+ protected $dataSource;
+
+ public static function load(Filter $filter, Db $db)
+ {
+ $table = new static();
+ $table->dataSource = new ArrayDatasource(
+ (new IcingaHostObjectResolver($db->getDbAdapter()))
+ ->fetchObjectsMatchingFilter($filter)
+ );
+
+ return $table;
+ }
+
+ public function renderRow($row)
+ {
+ return $this::row([
+ Link::create(
+ $row->object_name,
+ 'director/host',
+ ['name' => $row->object_name]
+ )
+ ]);
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [
+ $this->translate('Hostname'),
+ ];
+ }
+
+ protected function getPaginationAdapter()
+ {
+ return new SimpleQueryPaginationAdapter($this->getQuery());
+ }
+
+ public function getQuery()
+ {
+ return $this->prepareQuery();
+ }
+
+ protected function fetchQueryRows()
+ {
+ return $this->dataSource->fetchAll($this->getQuery());
+ }
+
+ protected function prepareQuery()
+ {
+ return new SimpleQuery($this->dataSource, ['object_name']);
+ }
+}
diff --git a/library/Director/Web/Table/IcingaObjectDatafieldTable.php b/library/Director/Web/Table/IcingaObjectDatafieldTable.php
new file mode 100644
index 0000000..f97692e
--- /dev/null
+++ b/library/Director/Web/Table/IcingaObjectDatafieldTable.php
@@ -0,0 +1,87 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Data\DataArray\ArrayDatasource;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Icinga\Module\Director\Web\Form\IcingaObjectFieldLoader;
+use Icinga\Web\Url;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\SimpleQueryBasedTable;
+
+class IcingaObjectDatafieldTable extends SimpleQueryBasedTable
+{
+ protected $object;
+
+ /** @var int */
+ protected $objectId;
+
+ public function __construct(IcingaObject $object)
+ {
+ $this->object = $object;
+ $this->objectId = (int) $object->id;
+ return $this;
+ }
+
+ protected $searchColumns = array(
+ 'varname',
+ 'caption'
+ );
+
+ public function getColumns()
+ {
+ return array(
+ 'object_id',
+ 'var_filter',
+ 'is_required',
+ 'id',
+ 'varname',
+ 'caption',
+ 'description',
+ 'datatype',
+ 'format',
+ );
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return array(
+ 'caption' => $this->translate('Label'),
+ 'varname' => $this->translate('Field name'),
+ 'is_required' => $this->translate('Mandatory'),
+ );
+ }
+
+ public function renderRow($row)
+ {
+ $definedOnThis = (int) $row->object_id === $this->objectId;
+ if ($definedOnThis) {
+ $caption = new Link(
+ $row->caption,
+ Url::fromRequest()->with('field_id', $row->id)
+ );
+ } else {
+ $caption = $row->caption;
+ }
+
+ $row = $this::row([
+ $caption,
+ $row->varname,
+ $row->is_required
+ ]);
+
+ if (! $definedOnThis) {
+ $row->getAttributes()->add('class', 'disabled');
+ }
+
+ return $row;
+ }
+
+ public function prepareQuery()
+ {
+ $loader = new IcingaObjectFieldLoader($this->object);
+ $fields = $loader->fetchFieldDetailsForObject($this->object);
+ $ds = new ArrayDatasource($fields);
+ return $ds->select();
+ }
+}
diff --git a/library/Director/Web/Table/IcingaScheduledDowntimeRangeTable.php b/library/Director/Web/Table/IcingaScheduledDowntimeRangeTable.php
new file mode 100644
index 0000000..cd8f8b1
--- /dev/null
+++ b/library/Director/Web/Table/IcingaScheduledDowntimeRangeTable.php
@@ -0,0 +1,67 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Module\Director\Objects\IcingaScheduledDowntime;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+
+class IcingaScheduledDowntimeRangeTable extends ZfQueryBasedTable
+{
+ /** @var IcingaScheduledDowntime */
+ protected $downtime;
+
+ protected $searchColumns = [
+ 'range_key',
+ 'range_value',
+ ];
+
+ /**
+ * @param IcingaScheduledDowntime $downtime
+ * @return static
+ */
+ public static function load(IcingaScheduledDowntime $downtime)
+ {
+ $table = new static($downtime->getConnection());
+ $table->downtime = $downtime;
+ $table->getAttributes()->set('data-base-target', '_self');
+
+ return $table;
+ }
+
+ public function renderRow($row)
+ {
+ return $this::row([
+ Link::create(
+ $row->range_key,
+ 'director/scheduled-downtime/ranges',
+ [
+ 'name' => $this->downtime->getObjectName(),
+ 'range' => $row->range_key,
+ 'range_type' => 'include'
+ ]
+ ),
+ $row->range_value
+ ]);
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [
+ $this->translate('Day(s)'),
+ $this->translate('Timeperiods'),
+ ];
+ }
+
+ public function prepareQuery()
+ {
+ return $this->db()->select()->from(
+ ['r' => 'icinga_scheduled_downtime_range'],
+ [
+ 'scheduled_downtime_id' => 'r.scheduled_downtime_id',
+ 'range_key' => 'r.range_key',
+ 'range_value' => 'r.range_value',
+ ]
+ )->where('r.scheduled_downtime_id = ?', $this->downtime->id);
+ }
+}
diff --git a/library/Director/Web/Table/IcingaServiceSetHostTable.php b/library/Director/Web/Table/IcingaServiceSetHostTable.php
new file mode 100644
index 0000000..9fc3c61
--- /dev/null
+++ b/library/Director/Web/Table/IcingaServiceSetHostTable.php
@@ -0,0 +1,64 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Module\Director\Objects\IcingaServiceSet;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+
+class IcingaServiceSetHostTable extends ZfQueryBasedTable
+{
+ protected $set;
+
+ protected $searchColumns = array(
+ 'host',
+ );
+
+ public static function load(IcingaServiceSet $set)
+ {
+ $table = new static($set->getConnection());
+ $table->set = $set;
+ return $table;
+ }
+
+ public function renderRow($row)
+ {
+ return $this::row([
+ Link::create(
+ $row->host,
+ 'director/host',
+ ['name' => $row->host]
+ )
+ ]);
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [
+ $this->translate('Hostname'),
+ ];
+ }
+
+ public function prepareQuery()
+ {
+ return $this->db()->select()->from(
+ ['h' => 'icinga_host'],
+ [
+ 'id' => 'h.id',
+ 'host' => 'h.object_name',
+ 'object_type' => 'h.object_type',
+ ]
+ )->joinLeft(
+ ['ssh' => 'icinga_service_set'],
+ 'ssh.host_id = h.id',
+ []
+ )->joinLeft(
+ ['ssih' => 'icinga_service_set_inheritance'],
+ 'ssih.service_set_id = ssh.id',
+ []
+ )->where(
+ 'ssih.parent_service_set_id = ?',
+ $this->set->id
+ )->order('h.object_name');
+ }
+}
diff --git a/library/Director/Web/Table/IcingaServiceSetServiceTable.php b/library/Director/Web/Table/IcingaServiceSetServiceTable.php
new file mode 100644
index 0000000..c205e66
--- /dev/null
+++ b/library/Director/Web/Table/IcingaServiceSetServiceTable.php
@@ -0,0 +1,259 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Module\Director\Data\Db\ServiceSetQueryBuilder;
+use Icinga\Module\Director\Db;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use Icinga\Module\Director\Forms\RemoveLinkForm;
+use Icinga\Module\Director\Objects\IcingaHost;
+use Icinga\Module\Director\Objects\IcingaServiceSet;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+use gipfl\IcingaWeb2\Url;
+
+class IcingaServiceSetServiceTable extends ZfQueryBasedTable
+{
+ use TableWithBranchSupport;
+
+ /** @var IcingaServiceSet */
+ protected $set;
+
+ protected $title;
+
+ /** @var IcingaHost */
+ protected $host;
+
+ /** @var IcingaHost */
+ protected $affectedHost;
+
+ protected $searchColumns = [
+ 'service',
+ ];
+
+ /** @var bool */
+ protected $readonly = false;
+
+ /** @var string|null */
+ protected $highlightedService;
+
+ /**
+ * @param IcingaServiceSet $set
+ * @return static
+ */
+ public static function load(IcingaServiceSet $set)
+ {
+ $table = new static($set->getConnection());
+ $table->set = $set;
+ $table->getAttributes()->set('data-base-target', '_self');
+ return $table;
+ }
+
+ /**
+ * @param string $title
+ * @return $this
+ */
+ public function setTitle($title)
+ {
+ $this->title = $title;
+ return $this;
+ }
+
+ /**
+ * @param IcingaHost $host
+ * @return $this
+ */
+ public function setHost(IcingaHost $host)
+ {
+ $this->host = $host;
+ return $this;
+ }
+
+ /**
+ * @param IcingaHost $host
+ * @return $this
+ */
+ public function setAffectedHost(IcingaHost $host)
+ {
+ $this->affectedHost = $host;
+ return $this;
+ }
+
+ /**
+ * Show no related links
+ *
+ * @param bool $readonly
+ * @return $this
+ */
+ public function setReadonly($readonly = true)
+ {
+ $this->readonly = (bool) $readonly;
+
+ return $this;
+ }
+
+ public function highlightService($service)
+ {
+ $this->highlightedService = $service;
+
+ return $this;
+ }
+
+ /**
+ * @param $row
+ * @return BaseHtmlElement
+ */
+ protected function getServiceLink($row)
+ {
+ if ($this->readonly) {
+ if ($this->highlightedService === $row->service) {
+ return Html::tag('span', ['class' => 'ro-service icon-right-big'], $row->service);
+ }
+
+ return Html::tag('span', ['class' => 'ro-service'], $row->service);
+ }
+
+ if ($this->affectedHost) {
+ $params = [
+ 'uuid' => $this->affectedHost->getUniqueId()->toString(),
+ 'service' => $row->service,
+ 'set' => $row->service_set
+ ];
+ $url = 'director/host/servicesetservice';
+ } else {
+ $params = [
+ 'name' => $row->service,
+ 'set' => $row->service_set
+ ];
+ $url = 'director/service';
+ }
+
+ return Link::create(
+ $row->service,
+ $url,
+ $params
+ );
+ }
+
+ public function renderRow($row)
+ {
+ $tr = $this::row([
+ $this->getServiceLink($row)
+ ]);
+ $classes = $this->getRowClasses($row);
+ if ($row->disabled === 'y') {
+ $classes[] = 'disabled';
+ }
+ if ($row->blacklisted === 'y') {
+ $classes[] = 'strike-links';
+ }
+ if (! empty($classes)) {
+ $tr->getAttributes()->add('class', $classes);
+ }
+
+ return $tr;
+ }
+
+ protected function getRowClasses($row)
+ {
+ if ($row->branch_uuid !== null) {
+ return ['branch_modified'];
+ }
+ return [];
+ }
+
+ protected function getTitle()
+ {
+ return $this->title ?: $this->translate('Servicename');
+ }
+
+ protected function renderTitleColumns()
+ {
+ if (! $this->host || ! $this->affectedHost) {
+ return Html::tag('th', $this->getTitle());
+ }
+
+ if ($this->readonly) {
+ $link = $this->createFakeRemoveLinkForReadonlyView();
+ } elseif ($this->affectedHost->get('id') !== $this->host->get('id')) {
+ $link = $this->linkToHost($this->host);
+ } else {
+ $link = $this->createRemoveLinkForm();
+ }
+
+ return $this::th([$this->getTitle(), $link]);
+ }
+
+ /**
+ * @return \Zend_Db_Select
+ * @throws \Zend_Db_Select_Exception
+ */
+ public function prepareQuery()
+ {
+ $connection = $this->connection();
+ assert($connection instanceof Db);
+ $builder = new ServiceSetQueryBuilder($connection, $this->branchUuid);
+ return $builder->selectServicesForSet($this->set)->limit(100);
+ }
+
+ protected function createFakeRemoveLinkForReadonlyView()
+ {
+ return Html::tag('span', [
+ 'class' => 'icon-paste',
+ 'style' => 'float: right; font-weight: normal',
+ ], $this->host->getObjectName());
+ }
+
+ protected function linkToHost(IcingaHost $host)
+ {
+ $hostname = $host->getObjectName();
+ return Link::create($hostname, 'director/host/services', ['name' => $hostname], [
+ 'class' => 'icon-paste',
+ 'style' => 'float: right; font-weight: normal',
+ 'data-base-target' => '_next',
+ 'title' => sprintf(
+ $this->translate('This set has been inherited from %s'),
+ $hostname
+ )
+ ]);
+ }
+
+ protected function createRemoveLinkForm()
+ {
+ $deleteLink = new RemoveLinkForm(
+ $this->translate('Remove'),
+ sprintf(
+ $this->translate('Remove "%s" from this host'),
+ $this->getTitle()
+ ),
+ Url::fromPath('director/host/services', [
+ 'name' => $this->host->getObjectName()
+ ]),
+ ['title' => $this->getTitle()]
+ );
+ $deleteLink->runOnSuccess(function () {
+ $conn = $this->set->getConnection();
+ $db = $conn->getDbAdapter();
+ $query = $db->select()->from(['ss' => 'icinga_service_set'], 'ss.id')
+ ->join(['ssih' => 'icinga_service_set_inheritance'], 'ssih.service_set_id = ss.id', [])
+ ->where('ssih.parent_service_set_id = ?', $this->set->get('id'))
+ ->where('ss.host_id = ?', $this->host->get('id'));
+ IcingaServiceSet::loadWithAutoIncId(
+ $db->fetchOne($query),
+ $conn
+ )->delete();
+ });
+ $deleteLink->handleRequest();
+ return $deleteLink;
+ }
+
+ public function removeQueryLimit()
+ {
+ $query = $this->getQuery();
+ $query->reset($query::LIMIT_OFFSET);
+ $query->reset($query::LIMIT_COUNT);
+
+ return $this;
+ }
+}
diff --git a/library/Director/Web/Table/IcingaTimePeriodRangeTable.php b/library/Director/Web/Table/IcingaTimePeriodRangeTable.php
new file mode 100644
index 0000000..5870e67
--- /dev/null
+++ b/library/Director/Web/Table/IcingaTimePeriodRangeTable.php
@@ -0,0 +1,61 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Module\Director\Objects\IcingaTimePeriod;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+
+class IcingaTimePeriodRangeTable extends ZfQueryBasedTable
+{
+ protected $period;
+
+ protected $searchColumns = array(
+ 'range_key',
+ 'range_value',
+ );
+
+ public static function load(IcingaTimePeriod $period)
+ {
+ $table = new static($period->getConnection());
+ $table->period = $period;
+ $table->getAttributes()->set('data-base-target', '_self');
+ return $table;
+ }
+
+ public function renderRow($row)
+ {
+ return $this::row([
+ Link::create(
+ $row->range_key,
+ 'director/timeperiod/ranges',
+ array(
+ 'name' => $this->period->object_name,
+ 'range' => $row->range_key,
+ 'range_type' => 'include'
+ )
+ ),
+ $row->range_value
+ ]);
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [
+ $this->translate('Day(s)'),
+ $this->translate('Timeperiods'),
+ ];
+ }
+
+ public function prepareQuery()
+ {
+ return $this->db()->select()->from(
+ ['r' => 'icinga_timeperiod_range'],
+ [
+ 'timeperiod_id' => 'r.timeperiod_id',
+ 'range_key' => 'r.range_key',
+ 'range_value' => 'r.range_value',
+ ]
+ )->where('r.timeperiod_id = ?', $this->period->id);
+ }
+}
diff --git a/library/Director/Web/Table/ImportedrowsTable.php b/library/Director/Web/Table/ImportedrowsTable.php
new file mode 100644
index 0000000..d5c9811
--- /dev/null
+++ b/library/Director/Web/Table/ImportedrowsTable.php
@@ -0,0 +1,103 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use ipl\Html\Html;
+use ipl\Html\ValidHtml;
+use gipfl\IcingaWeb2\Table\SimpleQueryBasedTable;
+use Icinga\Data\DataArray\ArrayDatasource;
+use Icinga\Module\Director\Objects\ImportRun;
+use Icinga\Module\Director\PlainObjectRenderer;
+
+class ImportedrowsTable extends SimpleQueryBasedTable
+{
+ protected $columns;
+
+ /** @var ImportRun */
+ protected $importRun;
+
+ protected $keyColumn;
+
+ public static function load(ImportRun $run)
+ {
+ $table = new static();
+ $table->setImportRun($run);
+ return $table;
+ }
+
+ public function setImportRun(ImportRun $run)
+ {
+ $this->importRun = $run;
+ return $this;
+ }
+
+ public function setColumns($columns)
+ {
+ $this->columns = $columns;
+ return $this;
+ }
+
+ protected function getKeyColumn()
+ {
+ if ($this->keyColumn === null) {
+ $this->keyColumn = $this->importRun->importSource()->get('key_column');
+ }
+
+ return $this->keyColumn;
+ }
+
+ public function getColumns()
+ {
+ if ($this->columns === null) {
+ $cols = $this->importRun->listColumnNames();
+
+ $keyColumn = $this->getKeyColumn();
+ if ($keyColumn !== null && ($pos = array_search($keyColumn, $cols)) !== false) {
+ unset($cols[$pos]);
+ array_unshift($cols, $keyColumn);
+ }
+ } else {
+ $cols = $this->columns;
+ }
+
+ return array_combine($cols, $cols);
+ }
+
+ public function renderRow($row)
+ {
+ // Find a better place!
+ if ($row === null) {
+ return null;
+ }
+ $tr = $this::tr();
+
+ foreach ($this->getColumnsToBeRendered() as $column) {
+ $td = $this::td();
+ if (property_exists($row, $column)) {
+ if (is_string($row->$column) || $row->$column instanceof ValidHtml) {
+ $td->setContent($row->$column);
+ } else {
+ $html = Html::tag('pre', null, PlainObjectRenderer::render($row->$column));
+ $td->setContent($html);
+ }
+ }
+ $tr->add($td);
+ }
+
+ return $tr;
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return $this->getColumns();
+ }
+
+ public function prepareQuery()
+ {
+ $ds = new ArrayDatasource(
+ $this->importRun->fetchRows($this->columns)
+ );
+
+ return $ds->select()->order($this->getKeyColumn());
+ }
+}
diff --git a/library/Director/Web/Table/ImportrunTable.php b/library/Director/Web/Table/ImportrunTable.php
new file mode 100644
index 0000000..e6c8a38
--- /dev/null
+++ b/library/Director/Web/Table/ImportrunTable.php
@@ -0,0 +1,90 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Module\Director\Objects\ImportSource;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+
+class ImportrunTable extends ZfQueryBasedTable
+{
+ use DbHelper;
+
+ /** @var ImportSource */
+ protected $source;
+
+ protected $searchColumns = [
+ 'source_name',
+ ];
+
+ public static function load(ImportSource $source)
+ {
+ $table = new static($source->getConnection());
+ $table->source = $source;
+ return $table;
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [
+ $this->translate('Source name'),
+ $this->translate('Timestamp'),
+ $this->translate('Imported rows'),
+ ];
+ }
+
+ public function renderRow($row)
+ {
+ return $this::row([
+ Link::create(
+ $row->source_name,
+ 'director/importrun',
+ ['id' => $row->id]
+ ),
+ $row->start_time,
+ $row->cnt_rows
+ ]);
+ }
+
+ public function prepareQuery()
+ {
+ $db = $this->db();
+ $columns = array(
+ 'id' => 'r.id',
+ 'source_id' => 's.id',
+ 'source_name' => 's.source_name',
+ 'start_time' => 'r.start_time',
+ 'rowset' => 'LOWER(HEX(rs.checksum))',
+ 'cnt_rows' => 'COUNT(rsr.row_checksum)',
+ );
+
+ if ($this->isPgsql()) {
+ $columns['rowset'] = "LOWER(ENCODE(rs.checksum, 'hex'))";
+ }
+
+ // TODO: Store row count to rowset
+ $query = $db->select()->from(
+ ['s' => 'import_source'],
+ $columns
+ )->join(
+ ['r' => 'import_run'],
+ 'r.source_id = s.id',
+ []
+ )->joinLeft(
+ ['rs' => 'imported_rowset'],
+ 'rs.checksum = r.rowset_checksum',
+ []
+ )->joinLeft(
+ ['rsr' => 'imported_rowset_row'],
+ 'rs.checksum = rsr.rowset_checksum',
+ []
+ )->group('r.id')->group('s.id')->group('rs.checksum')
+ ->order('r.start_time DESC');
+
+ if ($this->source) {
+ $query->where('r.source_id = ?', $this->source->get('id'));
+ }
+
+ return $query;
+ }
+}
diff --git a/library/Director/Web/Table/ImportsourceHookTable.php b/library/Director/Web/Table/ImportsourceHookTable.php
new file mode 100644
index 0000000..5ddb6f3
--- /dev/null
+++ b/library/Director/Web/Table/ImportsourceHookTable.php
@@ -0,0 +1,107 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use ipl\Html\ValidHtml;
+use Icinga\Data\DataArray\ArrayDatasource;
+use Icinga\Module\Director\Hook\ImportSourceHook;
+use Icinga\Module\Director\Import\SyncUtils;
+use Icinga\Module\Director\Objects\ImportSource;
+use Icinga\Module\Director\PlainObjectRenderer;
+use ipl\Html\Html;
+use gipfl\IcingaWeb2\Table\SimpleQueryBasedTable;
+
+class ImportsourceHookTable extends SimpleQueryBasedTable
+{
+ /** @var ImportSource */
+ protected $source;
+
+ protected $columnCache;
+
+ /** @var ImportSourceHook */
+ protected $sourceHook;
+
+ protected function assemble()
+ {
+ $this->getAttributes()->add('class', 'raw-data-table collapsed');
+ }
+
+ public function getColumns()
+ {
+ if ($this->columnCache === null) {
+ $this->columnCache = SyncUtils::getRootVariables(array_merge(
+ $this->sourceHook()->listColumns(),
+ $this->source->listModifierTargetProperties()
+ ));
+
+ sort($this->columnCache);
+
+ // prioritize key column
+ $keyColumn = $this->source->get('key_column');
+ if ($keyColumn !== null && ($pos = array_search($keyColumn, $this->columnCache)) !== false) {
+ unset($this->columnCache[$pos]);
+ array_unshift($this->columnCache, $keyColumn);
+ }
+ }
+
+ return $this->columnCache;
+ }
+
+ public function setImportSource(ImportSource $source)
+ {
+ $this->source = $source;
+ return $this;
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return $this->getColumns();
+ }
+
+ public function renderRow($row)
+ {
+ // Find a better place!
+ if ($row === null) {
+ return null;
+ }
+ if (\is_array($row)) {
+ $row = (object) $row;
+ }
+ $tr = $this::tr();
+
+ foreach ($this->getColumnsToBeRendered() as $column) {
+ $td = $this::td();
+ if (\property_exists($row, $column)) {
+ if (\is_string($row->$column) || $row->$column instanceof ValidHtml) {
+ $td->setContent($row->$column);
+ } else {
+ $html = Html::tag('pre', null, PlainObjectRenderer::render($row->$column));
+ $td->setContent($html);
+ }
+ }
+ $tr->add($td);
+ }
+
+ return $tr;
+ }
+
+ protected function sourceHook()
+ {
+ if ($this->sourceHook === null) {
+ $this->sourceHook = ImportSourceHook::forImportSource(
+ $this->source
+ );
+ }
+
+ return $this->sourceHook;
+ }
+
+ public function prepareQuery()
+ {
+ $data = $this->sourceHook()->fetchData();
+ $this->source->applyModifiers($data);
+
+ $ds = new ArrayDatasource($data);
+ return $ds->select();
+ }
+}
diff --git a/library/Director/Web/Table/ImportsourceTable.php b/library/Director/Web/Table/ImportsourceTable.php
new file mode 100644
index 0000000..1a93ef5
--- /dev/null
+++ b/library/Director/Web/Table/ImportsourceTable.php
@@ -0,0 +1,63 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+
+class ImportsourceTable extends ZfQueryBasedTable
+{
+ protected $searchColumns = [
+ 'source_name',
+ 'description',
+ ];
+
+ public function getColumnsToBeRendered()
+ {
+ return [
+ $this->translate('Source name'),
+ ];
+ }
+
+ protected function assemble()
+ {
+ $this->getAttributes()->add('class', 'syncstate');
+ parent::assemble();
+ }
+
+ public function renderRow($row)
+ {
+ $caption = [Link::create(
+ $row->source_name,
+ 'director/importsource',
+ ['id' => $row->id]
+ )];
+ if ($row->description !== null) {
+ $caption[] = ': ' . $row->description;
+ }
+
+ if ($row->import_state === 'failing' && $row->last_error_message) {
+ $caption[] = ' (' . $row->last_error_message . ')';
+ }
+
+ $tr = $this::row([$caption]);
+ $tr->getAttributes()->add('class', $row->import_state);
+
+ return $tr;
+ }
+
+ public function prepareQuery()
+ {
+ return $this->db()->select()->from(
+ ['s' => 'import_source'],
+ [
+ 'id' => 's.id',
+ 'source_name' => 's.source_name',
+ 'provider_class' => 's.provider_class',
+ 'import_state' => 's.import_state',
+ 'last_error_message' => 's.last_error_message',
+ 'description' => 's.description',
+ ]
+ )->order('source_name ASC');
+ }
+}
diff --git a/library/Director/Web/Table/JobTable.php b/library/Director/Web/Table/JobTable.php
new file mode 100644
index 0000000..81ba07b
--- /dev/null
+++ b/library/Director/Web/Table/JobTable.php
@@ -0,0 +1,82 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+
+class JobTable extends ZfQueryBasedTable
+{
+ protected $searchColumns = [
+ 'job_name',
+ ];
+
+ protected function assemble()
+ {
+ $this->getAttributes()->add('class', 'jobs');
+ parent::assemble();
+ }
+
+ public function renderRow($row)
+ {
+ $caption = [Link::create(
+ $row->job_name,
+ 'director/job',
+ ['id' => $row->id]
+ )];
+
+ if ($row->last_attempt_succeeded === 'n' && $row->last_error_message) {
+ $caption[] = ' (' . $row->last_error_message . ')';
+ }
+
+ $tr = $this::row([$caption]);
+ $tr->getAttributes()->add('class', $this->getJobClasses($row));
+
+ return $tr;
+ }
+
+ protected function getJobClasses($row)
+ {
+ if ($row->unixts_last_attempt === null) {
+ return 'pending';
+ }
+
+ if ($row->unixts_last_attempt + $row->run_interval < time()) {
+ return 'pending';
+ }
+
+ if ($row->last_attempt_succeeded === 'y') {
+ return 'ok';
+ } elseif ($row->last_attempt_succeeded === 'n') {
+ return 'critical';
+ } else {
+ return 'unknown';
+ }
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [
+ $this->translate('Job name'),
+ ];
+ }
+
+ public function prepareQuery()
+ {
+ return $this->db()->select()->from(
+ ['j' => 'director_job'],
+ [
+ 'id' => 'j.id',
+ 'job_name' => 'j.job_name',
+ 'job_class' => 'j.job_class',
+ 'disabled' => 'j.disabled',
+ 'run_interval' => 'j.run_interval',
+ 'last_attempt_succeeded' => 'j.last_attempt_succeeded',
+ 'ts_last_attempt' => 'j.ts_last_attempt',
+ 'unixts_last_attempt' => 'UNIX_TIMESTAMP(j.ts_last_attempt)',
+ 'ts_last_error' => 'j.ts_last_error',
+ 'last_error_message' => 'j.last_error_message',
+ ]
+ )->order('job_name');
+ }
+}
diff --git a/library/Director/Web/Table/NotificationTemplateUsageTable.php b/library/Director/Web/Table/NotificationTemplateUsageTable.php
new file mode 100644
index 0000000..da411a3
--- /dev/null
+++ b/library/Director/Web/Table/NotificationTemplateUsageTable.php
@@ -0,0 +1,22 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+class NotificationTemplateUsageTable extends TemplateUsageTable
+{
+ public function getTypes()
+ {
+ return [
+ 'templates' => $this->translate('Templates'),
+ 'applyrules' => $this->translate('Apply Rules'),
+ ];
+ }
+
+ protected function getTypeSummaryDefinitions()
+ {
+ return [
+ 'templates' => $this->getSummaryLine('template'),
+ 'applyrules' => $this->getSummaryLine('apply', 'o.host_id IS NULL'),
+ ];
+ }
+}
diff --git a/library/Director/Web/Table/ObjectSetTable.php b/library/Director/Web/Table/ObjectSetTable.php
new file mode 100644
index 0000000..2773841
--- /dev/null
+++ b/library/Director/Web/Table/ObjectSetTable.php
@@ -0,0 +1,211 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Authentication\Auth;
+use Icinga\Module\Director\Db;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+use gipfl\IcingaWeb2\Url;
+use Icinga\Module\Director\Db\DbSelectParenthesis;
+use Icinga\Module\Director\Restriction\FilterByNameRestriction;
+use ipl\Html\Html;
+use Ramsey\Uuid\Uuid;
+
+class ObjectSetTable extends ZfQueryBasedTable
+{
+ use TableWithBranchSupport;
+
+ protected $searchColumns = [
+ 'os.object_name',
+ 'os.description',
+ 'os.assign_filter',
+ 'o.object_name',
+ ];
+
+ private $type;
+
+ /** @var Auth */
+ private $auth;
+
+ public static function create($type, Db $db, Auth $auth)
+ {
+ $table = new static($db);
+ $table->type = $type;
+ $table->auth = $auth;
+ return $table;
+ }
+
+ public function getType()
+ {
+ return $this->type;
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [$this->translate('Name')];
+ }
+
+ public function renderRow($row)
+ {
+ $type = $this->getType();
+ $params = [
+ 'uuid' => Uuid::fromBytes(Db\DbUtil::binaryResult($row->uuid))->toString(),
+ ];
+
+ $url = Url::fromPath("director/${type}set", $params);
+
+ $classes = $this->getRowClasses($row);
+ $tr = static::tr([
+ static::td([
+ Link::create(sprintf(
+ $this->translate('%s (%d members)'),
+ $row->object_name,
+ $row->count_services
+ ), $url),
+ $row->description ? [Html::tag('br'), Html::tag('i', $row->description)] : null
+ ])
+ ]);
+ if (! empty($classes)) {
+ $tr->getAttributes()->add('class', $classes);
+ }
+
+ return $tr;
+ }
+
+ protected function getRowClasses($row)
+ {
+ if ($row->branch_uuid !== null) {
+ return ['branch_modified'];
+ }
+ return [];
+ }
+
+ protected function prepareQuery()
+ {
+ $type = $this->getType();
+
+ $table = "icinga_${type}_set";
+ $columns = [
+ 'id' => 'os.id',
+ 'uuid' => 'os.uuid',
+ 'branch_uuid' => '(NULL)',
+ 'object_name' => 'os.object_name',
+ 'object_type' => 'os.object_type',
+ 'assign_filter' => 'os.assign_filter',
+ 'description' => 'os.description',
+ 'count_services' => 'COUNT(DISTINCT o.uuid)',
+ ];
+ if ($this->branchUuid) {
+ $columns['branch_uuid'] = 'bos.branch_uuid';
+ $columns = $this->branchifyColumns($columns);
+ $this->stripSearchColumnAliases();
+ }
+
+ $query = $this->db()->select()->from(
+ ['os' => $table],
+ $columns
+ )->joinLeft(
+ ['o' => "icinga_${type}"],
+ "o.${type}_set_id = os.id",
+ []
+ );
+
+ $nameFilter = new FilterByNameRestriction(
+ $this->connection(),
+ $this->auth,
+ "${type}_set"
+ );
+ $nameFilter->applyToQuery($query, 'os');
+ /** @var Db $conn */
+ $conn = $this->connection();
+ if ($this->branchUuid) {
+ $right = clone($query);
+
+ $query->joinLeft(
+ ['bos' => "branched_$table"],
+ // TODO: PgHexFunc
+ $this->db()->quoteInto(
+ 'bos.uuid = os.uuid AND bos.branch_uuid = ?',
+ $conn->quoteBinary($this->branchUuid->getBytes())
+ ),
+ []
+ )->where("(bos.branch_deleted IS NULL OR bos.branch_deleted = 'n')");
+ $right->joinRight(
+ ['bos' => "branched_$table"],
+ 'bos.uuid = os.uuid',
+ []
+ )
+ ->where('os.uuid IS NULL')
+ ->where('bos.branch_uuid = ?', $conn->quoteBinary($this->branchUuid->getBytes()));
+ $query->group('COALESCE(os.uuid, bos.uuid)');
+ $right->group('COALESCE(os.uuid, bos.uuid)');
+ if ($conn->isPgsql()) {
+ // This is ugly, might want to modify the query - even a subselect looks better
+ $query->group('bos.uuid')->group('os.uuid')->group('os.id')->group('bos.branch_uuid');
+ $right->group('bos.uuid')->group('os.uuid')->group('os.id')->group('bos.branch_uuid');
+ }
+
+ $query = $this->db()->select()->union([
+ 'l' => new DbSelectParenthesis($query),
+ 'r' => new DbSelectParenthesis($right),
+ ]);
+ $query = $this->db()->select()->from(['u' => $query]);
+ $query->order('object_name')->limit(100);
+
+ $query
+ ->group('uuid')
+ ->where('object_type = ?', 'template')
+ ->order('object_name');
+ if ($conn->isPgsql()) {
+ // BS. Drop count? Sub-select? Better query?
+ $query
+ ->group('uuid')
+ ->group('id')
+ ->group('branch_uuid')
+ ->group('object_name')
+ ->group('object_type')
+ ->group('assign_filter')
+ ->group('description')
+ ->group('count_services');
+ };
+ } else {
+ // Disabled for now, check for correctness:
+ // $query->joinLeft(
+ // ['osi' => "icinga_${type}_set_inheritance"],
+ // "osi.parent_${type}_set_id = os.id",
+ // []
+ // )->joinLeft(
+ // ['oso' => "icinga_${type}_set"],
+ // "oso.id = oso.${type}_set_id",
+ // []
+ // );
+ // 'count_hosts' => 'COUNT(DISTINCT oso.id)',
+
+ $query
+ ->group('os.uuid')
+ ->where('os.object_type = ?', 'template')
+ ->order('os.object_name');
+ if ($conn->isPgsql()) {
+ // BS. Drop count? Sub-select? Better query?
+ $query
+ ->group('os.uuid')
+ ->group('os.id')
+ ->group('os.object_name')
+ ->group('os.object_type')
+ ->group('os.assign_filter')
+ ->group('os.description');
+ };
+ }
+
+ return $query;
+ }
+
+ /**
+ * @return Db
+ */
+ public function connection()
+ {
+ return parent::connection();
+ }
+}
diff --git a/library/Director/Web/Table/ObjectsTable.php b/library/Director/Web/Table/ObjectsTable.php
new file mode 100644
index 0000000..792cb6d
--- /dev/null
+++ b/library/Director/Web/Table/ObjectsTable.php
@@ -0,0 +1,315 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Authentication\Auth;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Db\DbSelectParenthesis;
+use Icinga\Module\Director\Db\IcingaObjectFilterHelper;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Icinga\Module\Director\Restriction\FilterByNameRestriction;
+use Icinga\Module\Director\Restriction\HostgroupRestriction;
+use Icinga\Module\Director\Restriction\ObjectRestriction;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+use gipfl\IcingaWeb2\Url;
+use Ramsey\Uuid\Uuid;
+use Zend_Db_Select as ZfSelect;
+
+class ObjectsTable extends ZfQueryBasedTable
+{
+ use TableWithBranchSupport;
+
+ /** @var ObjectRestriction[] */
+ protected $objectRestrictions;
+
+ protected $columns = [
+ 'object_name' => 'o.object_name',
+ 'object_type' => 'o.object_type',
+ 'disabled' => 'o.disabled',
+ 'uuid' => 'o.uuid',
+ ];
+
+ protected $searchColumns = ['o.object_name'];
+
+ protected $showColumns = ['object_name' => 'Name'];
+
+ protected $filterObjectType = 'object';
+
+ protected $type;
+
+ protected $baseObjectUrl;
+
+ /** @var IcingaObject */
+ protected $dummyObject;
+
+ protected $leftSubQuery;
+
+ protected $rightSubQuery;
+
+ /** @var Auth */
+ private $auth;
+
+ /**
+ * @param $type
+ * @param Db $db
+ * @return static
+ */
+ public static function create($type, Db $db)
+ {
+ $class = __NAMESPACE__ . '\\ObjectsTable' . ucfirst($type);
+ if (! class_exists($class)) {
+ $class = __CLASS__;
+ }
+
+ /** @var static $table */
+ $table = new $class($db);
+ $table->type = $type;
+ return $table;
+ }
+
+ public function getType()
+ {
+ return $this->type;
+ }
+
+ /**
+ * @param string $url
+ * @return $this
+ */
+ public function setBaseObjectUrl($url)
+ {
+ $this->baseObjectUrl = $url;
+
+ return $this;
+ }
+
+ /**
+ * @return Auth
+ */
+ public function getAuth()
+ {
+ return $this->auth;
+ }
+
+ public function setAuth(Auth $auth)
+ {
+ $this->auth = $auth;
+ return $this;
+ }
+
+ public function filterObjectType($type)
+ {
+ $this->filterObjectType = $type;
+ return $this;
+ }
+
+ public function addObjectRestriction(ObjectRestriction $restriction)
+ {
+ $this->objectRestrictions[$restriction->getName()] = $restriction;
+ return $this;
+ }
+
+ public function getColumns()
+ {
+ return $this->columns;
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return $this->showColumns;
+ }
+
+ public function filterTemplate(
+ IcingaObject $template,
+ $inheritance = Db\IcingaObjectFilterHelper::INHERIT_DIRECT
+ ) {
+ IcingaObjectFilterHelper::filterByTemplate(
+ $this->getQuery(),
+ $template,
+ 'o',
+ $inheritance
+ );
+
+ return $this;
+ }
+
+ protected function getMainLinkLabel($row)
+ {
+ return $row->object_name;
+ }
+
+ protected function renderObjectNameColumn($row)
+ {
+ $type = $this->baseObjectUrl;
+ $url = Url::fromPath("director/${type}", [
+ 'uuid' => Uuid::fromBytes($row->uuid)->toString()
+ ]);
+
+ return static::td(Link::create($this->getMainLinkLabel($row), $url));
+ }
+
+ protected function renderExtraColumns($row)
+ {
+ $columns = $this->getColumnsToBeRendered();
+ unset($columns['object_name']);
+ $cols = [];
+ foreach ($columns as $key => & $label) {
+ $cols[] = static::td($row->$key);
+ }
+
+ return $cols;
+ }
+
+ public function renderRow($row)
+ {
+ if (isset($row->uuid) && is_resource($row->uuid)) {
+ $row->uuid = stream_get_contents($row->uuid);
+ }
+ $tr = static::tr([
+ $this->renderObjectNameColumn($row),
+ $this->renderExtraColumns($row)
+ ]);
+
+ $classes = $this->getRowClasses($row);
+ if ($row->disabled === 'y') {
+ $classes[] = 'disabled';
+ }
+ if (! empty($classes)) {
+ $tr->getAttributes()->add('class', $classes);
+ }
+
+ return $tr;
+ }
+
+ protected function getRowClasses($row)
+ {
+ // TODO: remove isset, to figure out where it is missing
+ if (isset($row->branch_uuid) && $row->branch_uuid !== null) {
+ return ['branch_modified'];
+ }
+ return [];
+ }
+
+ protected function applyObjectTypeFilter(ZfSelect $query, ZfSelect $right = null)
+ {
+ if ($right) {
+ $right->where(
+ 'bo.object_type = ?',
+ $this->filterObjectType
+ );
+ }
+ return $query->where(
+ 'o.object_type = ?',
+ $this->filterObjectType
+ );
+ }
+
+ protected function applyRestrictions(ZfSelect $query)
+ {
+ foreach ($this->getRestrictions() as $restriction) {
+ $restriction->applyToQuery($query);
+ }
+
+ return $query;
+ }
+
+ protected function getRestrictions()
+ {
+ if ($this->objectRestrictions === null) {
+ $this->objectRestrictions = $this->loadRestrictions();
+ }
+
+ return $this->objectRestrictions;
+ }
+
+ protected function loadRestrictions()
+ {
+ /** @var Db $db */
+ $db = $this->connection();
+ $auth = $this->getAuth();
+
+ return [
+ new HostgroupRestriction($db, $auth),
+ new FilterByNameRestriction($db, $auth, $this->getDummyObject()->getShortTableName())
+ ];
+ }
+
+ /**
+ * @return IcingaObject
+ */
+ protected function getDummyObject()
+ {
+ if ($this->dummyObject === null) {
+ $type = $this->getType();
+ $this->dummyObject = IcingaObject::createByType($type);
+ }
+ return $this->dummyObject;
+ }
+
+ protected function prepareQuery()
+ {
+ $table = $this->getDummyObject()->getTableName();
+ if ($this->branchUuid) {
+ $this->columns['branch_uuid'] = 'bo.branch_uuid';
+ }
+
+ $columns = $this->getColumns();
+ if ($this->branchUuid) {
+ $columns = $this->branchifyColumns($columns);
+ $this->stripSearchColumnAliases();
+ }
+ $query = $this->db()->select()->from(['o' => $table], $columns);
+
+ if ($this->branchUuid) {
+ $right = clone($query);
+ // Hint: Right part has only those with object = null
+ // This means that restrictions on $right would hide all
+ // new rows. Dedicated restriction logic for the branch-only
+ // part of thw union are not required, we assume that restrictions
+ // for new objects have been checked once they have been created
+ $query = $this->applyRestrictions($query);
+ /** @var Db $conn */
+ $conn = $this->connection();
+ $query->joinLeft(
+ ['bo' => "branched_$table"],
+ // TODO: PgHexFunc
+ $this->db()->quoteInto(
+ 'bo.uuid = o.uuid AND bo.branch_uuid = ?',
+ $conn->quoteBinary($this->branchUuid->getBytes())
+ ),
+ []
+ )->where("(bo.branch_deleted IS NULL OR bo.branch_deleted = 'n')");
+ $this->applyObjectTypeFilter($query, $right);
+ $right->joinRight(
+ ['bo' => "branched_$table"],
+ 'bo.uuid = o.uuid',
+ []
+ )
+ ->where('o.uuid IS NULL')
+ ->where('bo.branch_uuid = ?', $conn->quoteBinary($this->branchUuid->getBytes()));
+ $this->leftSubQuery = $query;
+ $this->rightSubQuery = $right;
+ $query = $this->db()->select()->union([
+ 'l' => new DbSelectParenthesis($query),
+ 'r' => new DbSelectParenthesis($right),
+ ]);
+ $query = $this->db()->select()->from(['u' => $query]);
+ $query->order('object_name')->limit(100);
+ } else {
+ $this->applyObjectTypeFilter($query);
+ $query->order('o.object_name')->limit(100);
+ }
+
+ return $query;
+ }
+
+ public function removeQueryLimit()
+ {
+ $query = $this->getQuery();
+ $query->reset($query::LIMIT_OFFSET);
+ $query->reset($query::LIMIT_COUNT);
+
+ return $this;
+ }
+}
diff --git a/library/Director/Web/Table/ObjectsTableApiUser.php b/library/Director/Web/Table/ObjectsTableApiUser.php
new file mode 100644
index 0000000..2287c2f
--- /dev/null
+++ b/library/Director/Web/Table/ObjectsTableApiUser.php
@@ -0,0 +1,13 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Zend_Db_Select as ZfSelect;
+
+class ObjectsTableApiUser extends ObjectsTable
+{
+ protected function applyObjectTypeFilter(ZfSelect $query, ZfSelect $right = null)
+ {
+ return $query->where("o.object_type IN ('object', 'external_object')");
+ }
+}
diff --git a/library/Director/Web/Table/ObjectsTableCommand.php b/library/Director/Web/Table/ObjectsTableCommand.php
new file mode 100644
index 0000000..ebd89da
--- /dev/null
+++ b/library/Director/Web/Table/ObjectsTableCommand.php
@@ -0,0 +1,67 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Zend_Db_Select as ZfSelect;
+
+class ObjectsTableCommand extends ObjectsTable implements FilterableByUsage
+{
+ // TODO: Notifications separately?
+ protected $searchColumns = [
+ 'o.object_name',
+ 'o.command',
+ ];
+
+ protected $columns = [
+ 'uuid' => 'o.uuid',
+ 'object_name' => 'o.object_name',
+ 'object_type' => 'o.object_type',
+ 'disabled' => 'o.disabled',
+ 'command' => 'o.command',
+ ];
+
+ protected $showColumns = [
+ 'object_name' => 'Command',
+ 'command' => 'Command line'
+ ];
+
+ private $objectType;
+
+ public function setType($type)
+ {
+ $this->getQuery()->where('object_type = ?', $type);
+
+ return $this;
+ }
+
+ public function showOnlyUsed()
+ {
+ $this->getQuery()->where(
+ '('
+ . 'EXISTS (SELECT check_command_id FROM icinga_host WHERE check_command_id = o.id)'
+ . ' OR EXISTS (SELECT check_command_id FROM icinga_service WHERE check_command_id = o.id)'
+ . ' OR EXISTS (SELECT event_command_id FROM icinga_host WHERE event_command_id = o.id)'
+ . ' OR EXISTS (SELECT event_command_id FROM icinga_service WHERE event_command_id = o.id)'
+ . ' OR EXISTS (SELECT command_id FROM icinga_notification WHERE command_id = o.id)'
+ . ')'
+ );
+ }
+
+ public function showOnlyUnUsed()
+ {
+ $this->getQuery()->where(
+ '('
+ . 'NOT EXISTS (SELECT check_command_id FROM icinga_host WHERE check_command_id = o.id)'
+ . ' AND NOT EXISTS (SELECT check_command_id FROM icinga_service WHERE check_command_id = o.id)'
+ . ' AND NOT EXISTS (SELECT event_command_id FROM icinga_host WHERE event_command_id = o.id)'
+ . ' AND NOT EXISTS (SELECT event_command_id FROM icinga_service WHERE event_command_id = o.id)'
+ . ' AND NOT EXISTS (SELECT command_id FROM icinga_notification WHERE command_id = o.id)'
+ . ')'
+ );
+ }
+
+ protected function applyObjectTypeFilter(ZfSelect $query, ZfSelect $right = null)
+ {
+ return $query;
+ }
+}
diff --git a/library/Director/Web/Table/ObjectsTableEndpoint.php b/library/Director/Web/Table/ObjectsTableEndpoint.php
new file mode 100644
index 0000000..f73b38b
--- /dev/null
+++ b/library/Director/Web/Table/ObjectsTableEndpoint.php
@@ -0,0 +1,86 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use gipfl\IcingaWeb2\Icon;
+use Zend_Db_Select as ZfSelect;
+
+class ObjectsTableEndpoint extends ObjectsTable
+{
+ protected $searchColumns = [
+ 'o.object_name',
+ ];
+
+ protected $deploymentEndpoint;
+
+ public function getColumnsToBeRendered()
+ {
+ return array(
+ 'object_name' => $this->translate('Endpoint'),
+ 'host' => $this->translate('Host'),
+ 'zone' => $this->translate('Zone'),
+ 'object_type' => $this->translate('Type'),
+ );
+ }
+
+ public function getColumns()
+ {
+ return [
+ 'uuid' => 'o.uuid',
+ 'object_name' => 'o.object_name',
+ 'object_type' => 'o.object_type',
+ 'disabled' => 'o.disabled',
+ 'host' => "(CASE WHEN o.host IS NULL THEN NULL ELSE"
+ . " CONCAT(o.host || ':' || COALESCE(o.port, 5665)) END)",
+ 'zone' => 'z.object_name',
+ ];
+ }
+
+ protected function getMainLinkLabel($row)
+ {
+ if ($row->object_name === $this->deploymentEndpoint) {
+ return [
+ $row->object_name,
+ ' ',
+ Icon::create('upload', [
+ 'title' => $this->translate(
+ 'This is your Config master and will receive our Deployments'
+ )
+ ])
+ ];
+ } else {
+ return $row->object_name;
+ }
+ }
+
+ public function getRowClasses($row)
+ {
+ if ($row->object_name === $this->deploymentEndpoint) {
+ return array_merge(array('deployment-endpoint'), parent::getRowClasses($row));
+ } else {
+ return null;
+ }
+ }
+
+ protected function applyObjectTypeFilter(ZfSelect $query, ZfSelect $right = null)
+ {
+ return $query->where("o.object_type IN ('object', 'external_object')");
+ }
+
+ public function prepareQuery()
+ {
+ if ($this->deploymentEndpoint === null) {
+ /** @var \Icinga\Module\Director\Db $c */
+ $c = $this->connection();
+ if ($c->hasDeploymentEndpoint()) {
+ $this->deploymentEndpoint = $c->getDeploymentEndpointName();
+ }
+ }
+
+ return parent::prepareQuery()->joinLeft(
+ ['z' => 'icinga_zone'],
+ 'o.zone_id = z.id',
+ []
+ );
+ }
+}
diff --git a/library/Director/Web/Table/ObjectsTableHost.php b/library/Director/Web/Table/ObjectsTableHost.php
new file mode 100644
index 0000000..5128e04
--- /dev/null
+++ b/library/Director/Web/Table/ObjectsTableHost.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use gipfl\IcingaWeb2\Table\Extension\MultiSelect;
+
+class ObjectsTableHost extends ObjectsTable
+{
+ use MultiSelect;
+
+ protected $type = 'host';
+
+ protected $searchColumns = [
+ 'o.object_name',
+ 'o.display_name',
+ 'o.address',
+ ];
+
+ protected $columns = [
+ 'object_name' => 'o.object_name',
+ 'display_name' => 'o.display_name',
+ 'address' => 'o.address',
+ 'disabled' => 'o.disabled',
+ 'uuid' => 'o.uuid',
+ ];
+
+ protected $showColumns = [
+ 'object_name' => 'Hostname',
+ 'address' => 'Address'
+ ];
+
+ public function assemble()
+ {
+ $this->enableMultiSelect(
+ 'director/hosts/edit',
+ 'director/hosts',
+ ['uuid']
+ );
+ }
+}
diff --git a/library/Director/Web/Table/ObjectsTableHostTemplateChoice.php b/library/Director/Web/Table/ObjectsTableHostTemplateChoice.php
new file mode 100644
index 0000000..929e050
--- /dev/null
+++ b/library/Director/Web/Table/ObjectsTableHostTemplateChoice.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Zend_Db_Select as ZfSelect;
+
+class ObjectsTableHostTemplateChoice extends ObjectsTable
+{
+ protected $columns = [
+ 'object_name' => 'o.object_name',
+ 'templates' => 'GROUP_CONCAT(t.object_name)'
+ ];
+
+ protected function applyObjectTypeFilter(ZfSelect $query, ZfSelect $right = null)
+ {
+ return $query;
+ }
+
+ protected function prepareQuery()
+ {
+ return parent::prepareQuery()->joinLeft(
+ ['t' => 'icinga_host'],
+ 't.template_choice_id = o.id',
+ []
+ )->group('o.id');
+ }
+}
diff --git a/library/Director/Web/Table/ObjectsTableService.php b/library/Director/Web/Table/ObjectsTableService.php
new file mode 100644
index 0000000..2d4ad41
--- /dev/null
+++ b/library/Director/Web/Table/ObjectsTableService.php
@@ -0,0 +1,219 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Module\Director\Db\DbUtil;
+use Icinga\Module\Director\Objects\IcingaHost;
+use ipl\Html\Html;
+use gipfl\IcingaWeb2\Table\Extension\MultiSelect;
+use gipfl\IcingaWeb2\Link;
+use Ramsey\Uuid\Uuid;
+
+class ObjectsTableService extends ObjectsTable
+{
+ use MultiSelect;
+
+ /** @var IcingaHost */
+ protected $host;
+
+ protected $type = 'service';
+
+ protected $title;
+
+ /** @var IcingaHost */
+ protected $inheritedBy;
+
+ /** @var bool */
+ protected $readonly = false;
+
+ /** @var string|null */
+ protected $highlightedService;
+
+ protected $columns = [
+ 'object_name' => 'o.object_name',
+ 'disabled' => 'o.disabled',
+ 'host' => 'h.object_name',
+ 'host_id' => 'h.id',
+ 'host_object_type' => 'h.object_type',
+ 'host_disabled' => 'h.disabled',
+ 'id' => 'o.id',
+ 'uuid' => 'o.uuid',
+ 'blacklisted' => "CASE WHEN hsb.service_id IS NULL THEN 'n' ELSE 'y' END",
+ ];
+
+ protected $searchColumns = [
+ 'o.object_name',
+ 'h.object_name'
+ ];
+
+ public function assemble()
+ {
+ $this->enableMultiSelect(
+ 'director/services/edit',
+ 'director/services',
+ ['uuid']
+ );
+ }
+
+ public function setTitle($title)
+ {
+ $this->title = $title;
+ return $this;
+ }
+
+ public function setHost(IcingaHost $host)
+ {
+ $this->host = $host;
+ $this->getAttributes()->set('data-base-target', '_self');
+ return $this;
+ }
+
+ public function setInheritedBy(IcingaHost $host)
+ {
+ $this->inheritedBy = $host;
+ return $this;
+ }
+
+ /**
+ * Show no related links
+ *
+ * @param bool $readonly
+ * @return $this
+ */
+ public function setReadonly($readonly = true)
+ {
+ $this->readonly = (bool) $readonly;
+
+ return $this;
+ }
+
+ public function highlightService($service)
+ {
+ $this->highlightedService = $service;
+
+ return $this;
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ if ($this->title) {
+ return [$this->title];
+ }
+ if ($this->host) {
+ return [$this->translate('Servicename')];
+ }
+ return [
+ 'host' => $this->translate('Host'),
+ 'object_name' => $this->translate('Service Name'),
+ ];
+ }
+
+ public function renderRow($row)
+ {
+ $caption = $row->host === null
+ ? Html::tag('span', ['class' => 'error'], '- none -')
+ : $row->host;
+
+ $hostField = static::td($caption);
+ if ($row->host === null) {
+ $hostField->getAttributes()->add('class', 'error');
+ }
+ if ($this->host) {
+ $tr = static::tr([
+ static::td($this->getServiceLink($row))
+ ]);
+ } else {
+ $tr = static::tr([
+ $hostField,
+ static::td($this->getServiceLink($row))
+ ]);
+ }
+
+ $attributes = $tr->getAttributes();
+ $classes = $this->getRowClasses($row);
+ if ($row->host_disabled === 'y' || $row->disabled === 'y') {
+ $classes[] = 'disabled';
+ }
+ if ($row->blacklisted === 'y') {
+ $classes[] = 'strike-links';
+ }
+ $attributes->add('class', $classes);
+
+ return $tr;
+ }
+
+ protected function getInheritedServiceLink($row, $target)
+ {
+ $params = [
+ 'name' => $target->object_name,
+ 'service' => $row->object_name,
+ 'inheritedFrom' => $row->host,
+ ];
+
+ return Link::create(
+ $row->object_name,
+ 'director/host/inheritedservice',
+ $params
+ );
+ }
+
+ protected function getServiceLink($row)
+ {
+ if ($this->readonly) {
+ if ($this->highlightedService === $row->object_name) {
+ return Html::tag('span', ['class' => 'icon-right-big'], $row->object_name);
+ } else {
+ return $row->object_name;
+ }
+ }
+
+ $params = [
+ 'uuid' => Uuid::fromBytes(DbUtil::binaryResult($row->uuid))->toString(),
+ ];
+ if ($row->host !== null) {
+ $params['host'] = $row->host;
+ }
+ if ($target = $this->inheritedBy) {
+ return $this->getInheritedServiceLink($row, $target);
+ }
+
+ return Link::create(
+ $row->object_name,
+ 'director/service/edit',
+ $params
+ );
+ }
+
+ public function prepareQuery()
+ {
+ $query = parent::prepareQuery();
+ if ($this->branchUuid) {
+ $queries = [$this->leftSubQuery, $this->rightSubQuery];
+ } else {
+ $queries = [$query];
+ }
+
+ foreach ($queries as $subQuery) {
+ $subQuery->joinLeft(
+ ['h' => 'icinga_host'],
+ 'o.host_id = h.id',
+ []
+ )->joinLeft(
+ ['hsb' => 'icinga_host_service_blacklist'],
+ 'hsb.service_id = o.id AND hsb.host_id = o.host_id',
+ []
+ )->where('o.service_set_id IS NULL')
+ ->order('o.object_name')->order('h.object_name');
+
+ if ($this->host) {
+ if ($this->branchUuid) {
+ $subQuery->where('COALESCE(h.object_name, bo.host) = ?', $this->host->getObjectName());
+ } else {
+ $subQuery->where('h.id = ?', $this->host->get('id'));
+ }
+ }
+ }
+
+ return $query;
+ }
+}
diff --git a/library/Director/Web/Table/ObjectsTableZone.php b/library/Director/Web/Table/ObjectsTableZone.php
new file mode 100644
index 0000000..602cf0a
--- /dev/null
+++ b/library/Director/Web/Table/ObjectsTableZone.php
@@ -0,0 +1,13 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Zend_Db_Select as ZfSelect;
+
+class ObjectsTableZone extends ObjectsTable
+{
+ protected function applyObjectTypeFilter(ZfSelect $query, ZfSelect $right = null)
+ {
+ return $query;
+ }
+}
diff --git a/library/Director/Web/Table/PropertymodifierTable.php b/library/Director/Web/Table/PropertymodifierTable.php
new file mode 100644
index 0000000..bf9e4a3
--- /dev/null
+++ b/library/Director/Web/Table/PropertymodifierTable.php
@@ -0,0 +1,145 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Error;
+use Exception;
+use Icinga\Module\Director\Hook\ImportSourceHook;
+use Icinga\Module\Director\Objects\ImportSource;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\Extension\ZfSortablePriority;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+use gipfl\IcingaWeb2\Url;
+
+class PropertymodifierTable extends ZfQueryBasedTable
+{
+ use ZfSortablePriority;
+
+ protected $searchColumns = [
+ 'property_name',
+ 'target_property',
+ ];
+
+ /** @var ImportSource */
+ protected $source;
+
+ /** @var Url */
+ protected $url;
+
+ protected $keyColumn = 'id';
+
+ protected $priorityColumn = 'priority';
+
+ protected $readOnly = false;
+
+ public static function load(ImportSource $source, Url $url)
+ {
+ $table = new static($source->getConnection());
+ $table->source = $source;
+ $table->url = $url;
+ return $table;
+ }
+
+ public function setReadOnly($readOnly = true)
+ {
+ $this->readOnly = $readOnly;
+ return $this;
+ }
+
+ public function render()
+ {
+ if ($this->readOnly) {
+ return parent::render();
+ }
+ return $this->renderWithSortableForm();
+ }
+
+ protected function assemble()
+ {
+ $this->getAttributes()->set('data-base-target', '_self');
+ }
+
+ public function getColumns()
+ {
+ return array(
+ 'id' => 'm.id',
+ 'source_id' => 'm.source_id',
+ 'property_name' => 'm.property_name',
+ 'target_property' => 'm.target_property',
+ 'description' => 'm.description',
+ 'provider_class' => 'm.provider_class',
+ 'priority' => 'm.priority',
+ );
+ }
+
+ public function renderRow($row)
+ {
+ $caption = $row->property_name;
+ if ($row->target_property !== null) {
+ $caption .= ' -> ' . $row->target_property;
+ }
+ if ($row->description === null) {
+ $class = $row->provider_class;
+ try {
+ /** @var ImportSourceHook $hook */
+ $hook = new $class;
+ $caption .= ': ' . $hook->getName();
+ } catch (Exception $e) {
+ $caption = $this->createErrorCaption($caption, $e);
+ } catch (Error $e) {
+ $caption = $this->createErrorCaption($caption, $e);
+ }
+ } else {
+ $caption .= ': ' . $row->description;
+ }
+
+ $renderedRow = $this::row([
+ Link::create($caption, 'director/importsource/editmodifier', [
+ 'id' => $row->id,
+ 'source_id' => $row->source_id,
+ ]),
+ ]);
+ if ($this->readOnly) {
+ return $renderedRow;
+ }
+
+ return $this->addSortPriorityButtons(
+ $renderedRow,
+ $row
+ );
+ }
+
+ /**
+ * @param $caption
+ * @param Exception|Error $e
+ * @return array
+ */
+ protected function createErrorCaption($caption, $e)
+ {
+ return [
+ $caption,
+ ': ',
+ $this::tag('span', ['class' => 'error'], $e->getMessage())
+ ];
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ if ($this->readOnly) {
+ return [$this->translate('Property')];
+ }
+ return [
+ $this->translate('Property'),
+ $this->getSortPriorityTitle()
+ ];
+ }
+
+ public function prepareQuery()
+ {
+ return $this->db()->select()->from(
+ ['m' => 'import_row_modifier'],
+ $this->getColumns()
+ )->where('m.source_id = ?', $this->source->get('id'))
+ ->order('priority');
+ }
+}
diff --git a/library/Director/Web/Table/QuickTable.php b/library/Director/Web/Table/QuickTable.php
new file mode 100644
index 0000000..ff3edcc
--- /dev/null
+++ b/library/Director/Web/Table/QuickTable.php
@@ -0,0 +1,547 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Application\Icinga;
+use Icinga\Data\Filter\FilterAnd;
+use Icinga\Data\Filter\FilterChain;
+use Icinga\Data\Filter\FilterExpression;
+use Icinga\Data\Filter\FilterNot;
+use Icinga\Data\Filter\FilterOr;
+use Icinga\Data\Filter\Filter;
+use Icinga\Data\Selectable;
+use Icinga\Data\Paginatable;
+use Icinga\Exception\QueryException;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\PlainObjectRenderer;
+use Icinga\Web\Request;
+use gipfl\IcingaWeb2\Url;
+use Icinga\Web\View;
+use Icinga\Web\Widget;
+use Icinga\Web\Widget\Paginator;
+use ipl\Html\ValidHtml;
+use stdClass;
+use Zend_Db_Select as ZfDbSelect;
+
+abstract class QuickTable implements Paginatable, ValidHtml
+{
+ protected $view;
+
+ /** @var Db */
+ protected $connection;
+
+ protected $limit;
+
+ protected $offset;
+
+ /** @var Filter */
+ protected $filter;
+
+ protected $enforcedFilters = array();
+
+ protected $searchColumns = array();
+
+ protected function getRowClasses($row)
+ {
+ return array();
+ }
+
+ protected function getRowClassesString($row)
+ {
+ return $this->createClassAttribute($this->getRowClasses($row));
+ }
+
+ protected function createClassAttribute($classes)
+ {
+ $str = $this->createClassesString($classes);
+ if (strlen($str) > 0) {
+ return ' class="' . $str . '"';
+ } else {
+ return '';
+ }
+ }
+
+ private function createClassesString($classes)
+ {
+ if (is_string($classes)) {
+ $classes = array($classes);
+ }
+
+ if (empty($classes)) {
+ return '';
+ } else {
+ return implode(' ', $classes);
+ }
+ }
+
+ protected function getMultiselectProperties()
+ {
+ /* array(
+ * 'url' => 'director/hosts/edit',
+ * 'sourceUrl' => 'director/hosts',
+ * 'keys' => 'name'
+ * ) */
+
+ return array();
+ }
+
+ protected function renderMultiselectAttributes()
+ {
+ $props = $this->getMultiselectProperties();
+
+ if (empty($props)) {
+ return '';
+ }
+
+ $prefix = 'data-icinga-multiselect-';
+ $view = $this->view();
+ $parts = array();
+ $multi = array(
+ 'url' => $view->href($props['url']),
+ 'controllers' => $view->href($props['sourceUrl']),
+ 'data' => implode(',', $props['keys']),
+ );
+
+ foreach ($multi as $k => $v) {
+ $parts[] = $prefix . $k . '="' . $v . '"';
+ }
+
+ return ' ' . implode(' ', $parts);
+ }
+
+ protected function renderRow($row)
+ {
+ $htm = " <tr" . $this->getRowClassesString($row) . ">\n";
+ $firstCol = true;
+
+ foreach ($this->getTitles() as $key => $title) {
+ // Support missing columns
+ if (property_exists($row, $key)) {
+ $val = $row->$key;
+ } else {
+ $val = null;
+ }
+
+ $value = null;
+
+ if ($firstCol) {
+ if ($val !== null && $url = $this->getActionUrl($row)) {
+ $value = $this->view()->qlink($val, $this->getActionUrl($row));
+ }
+ $firstCol = false;
+ }
+
+ if ($value === null) {
+ if ($val === null) {
+ $value = '-';
+ } elseif (is_array($val) || $val instanceof stdClass || is_bool($val)) {
+ $value = '<pre>'
+ . $this->view()->escape(PlainObjectRenderer::render($val))
+ . '</pre>';
+ } else {
+ $value = $this->view()->escape($val);
+ }
+ }
+
+ $htm .= ' <td>' . $value . "</td>\n";
+ }
+
+ if ($this->hasAdditionalActions()) {
+ $htm .= ' <td class="actions">' . $this->renderAdditionalActions($row) . "</td>\n";
+ }
+
+ return $htm . " </tr>\n";
+ }
+
+ abstract protected function getTitles();
+
+ protected function getActionUrl($row)
+ {
+ return false;
+ }
+
+ public function setConnection(Selectable $connection)
+ {
+ $this->connection = $connection;
+ return $this;
+ }
+
+ /**
+ * @return ZfDbSelect
+ */
+ abstract protected function getBaseQuery();
+
+ public function fetchData()
+ {
+ $db = $this->db();
+ $query = $this->getBaseQuery()->columns($this->getColumns());
+
+ if ($this->hasLimit() || $this->hasOffset()) {
+ $query->limit($this->getLimit(), $this->getOffset());
+ }
+
+ $this->applyFiltersToQuery($query);
+
+ return $db->fetchAll($query);
+ }
+
+ protected function applyFiltersToQuery(ZfDbSelect $query)
+ {
+ $filter = null;
+ $enforced = $this->enforcedFilters;
+ if ($this->filter && ! $this->filter->isEmpty()) {
+ $filter = $this->filter;
+ } elseif (! empty($enforced)) {
+ $filter = array_shift($enforced);
+ }
+ if ($filter) {
+ foreach ($enforced as $f) {
+ $filter = $filter->andFilter($f);
+ }
+ $query->where($this->renderFilter($filter));
+ }
+
+ return $query;
+ }
+
+ public function getPaginator()
+ {
+ $paginator = new Paginator();
+ $paginator->setQuery($this);
+
+ return $paginator;
+ }
+
+ #[\ReturnTypeWillChange]
+ public function count()
+ {
+ $db = $this->db();
+ $query = clone($this->getBaseQuery());
+ $query->reset('order')->columns(array('COUNT(*)'));
+ $this->applyFiltersToQuery($query);
+
+ return $db->fetchOne($query);
+ }
+
+ public function limit($count = null, $offset = null)
+ {
+ $this->limit = $count;
+ $this->offset = $offset;
+
+ return $this;
+ }
+
+ public function hasLimit()
+ {
+ return $this->limit !== null;
+ }
+
+ public function getLimit()
+ {
+ return $this->limit;
+ }
+
+ public function hasOffset()
+ {
+ return $this->offset !== null;
+ }
+
+ public function getOffset()
+ {
+ return $this->offset;
+ }
+
+ public function hasAdditionalActions()
+ {
+ return method_exists($this, 'renderAdditionalActions');
+ }
+
+ /** @return Db */
+ protected function connection()
+ {
+ // TODO: Fail if missing? Require connection in constructor?
+ return $this->connection;
+ }
+
+ protected function db()
+ {
+ return $this->connection()->getDbAdapter();
+ }
+
+ protected function renderTitles($row)
+ {
+ $view = $this->view();
+ $htm = "<thead>\n <tr>\n";
+
+ foreach ($row as $title) {
+ $htm .= ' <th>' . $view->escape($title) . "</th>\n";
+ }
+
+ if ($this->hasAdditionalActions()) {
+ $htm .= ' <th class="actions">' . $view->translate('Actions') . "</th>\n";
+ }
+
+ return $htm . " </tr>\n</thead>\n";
+ }
+
+ protected function url($url, $params)
+ {
+ return Url::fromPath($url, $params);
+ }
+
+ protected function listTableClasses()
+ {
+ $classes = array('simple', 'common-table', 'table-row-selectable');
+ $multi = $this->getMultiselectProperties();
+ if (! empty($multi)) {
+ $classes[] = 'multiselect';
+ }
+
+ return $classes;
+ }
+
+ public function render()
+ {
+ $data = $this->fetchData();
+
+ $htm = '<table'
+ . $this->createClassAttribute($this->listTableClasses())
+ . $this->renderMultiselectAttributes()
+ . '>' . "\n"
+ . $this->renderTitles($this->getTitles())
+ . $this->beginTableBody();
+ foreach ($data as $row) {
+ $htm .= $this->renderRow($row);
+ }
+ return $htm . $this->endTableBody() . $this->endTable();
+ }
+
+ protected function beginTableBody()
+ {
+ return "<tbody>\n";
+ }
+
+ protected function endTableBody()
+ {
+ return "</tbody>\n";
+ }
+
+ protected function endTable()
+ {
+ return "</table>\n";
+ }
+
+ /**
+ * @return View
+ */
+ protected function view()
+ {
+ if ($this->view === null) {
+ $this->view = Icinga::app()->getViewRenderer()->view;
+ }
+ return $this->view;
+ }
+
+
+ public function setView($view)
+ {
+ $this->view = $view;
+ }
+
+ public function __toString()
+ {
+ return $this->render();
+ }
+
+ protected function getSearchColumns()
+ {
+ return $this->searchColumns;
+ }
+
+ abstract public function getColumns();
+
+ public function getFilterColumns()
+ {
+ $keys = array_keys($this->getColumns());
+ return array_combine($keys, $keys);
+ }
+
+ public function setFilter($filter)
+ {
+ $this->filter = $filter;
+ return $this;
+ }
+
+ public function enforceFilter($filter, $expression = null)
+ {
+ if (! $filter instanceof Filter) {
+ $filter = Filter::where($filter, $expression);
+ }
+ $this->enforcedFilters[] = $filter;
+ return $this;
+ }
+
+ public function getFilterEditor(Request $request)
+ {
+ $filterEditor = Widget::create('filterEditor')
+ ->setColumns(array_keys($this->getColumns()))
+ ->setSearchColumns($this->getSearchColumns())
+ ->preserveParams('limit', 'sort', 'dir', 'view', 'backend', '_dev')
+ ->ignoreParams('page')
+ ->handleRequest($request);
+
+ $filter = $filterEditor->getFilter();
+ $this->setFilter($filter);
+
+ return $filterEditor;
+ }
+
+ protected function mapFilterColumn($col)
+ {
+ $cols = $this->getColumns();
+ return $cols[$col];
+ }
+
+ protected function renderFilter(Filter $filter, $level = 0)
+ {
+ $str = '';
+ if ($filter instanceof FilterChain) {
+ if ($filter instanceof FilterAnd) {
+ $op = ' AND ';
+ } elseif ($filter instanceof FilterOr) {
+ $op = ' OR ';
+ } elseif ($filter instanceof FilterNot) {
+ $op = ' AND ';
+ $str .= ' NOT ';
+ } else {
+ throw new QueryException(
+ 'Cannot render filter: %s',
+ $filter
+ );
+ }
+ $parts = array();
+ if (! $filter->isEmpty()) {
+ foreach ($filter->filters() as $f) {
+ $filterPart = $this->renderFilter($f, $level + 1);
+ if ($filterPart !== '') {
+ $parts[] = $filterPart;
+ }
+ }
+ if (! empty($parts)) {
+ if ($level > 0) {
+ $str .= ' (' . implode($op, $parts) . ') ';
+ } else {
+ $str .= implode($op, $parts);
+ }
+ }
+ }
+ } else {
+ /** @var FilterExpression $filter */
+ $str .= $this->whereToSql(
+ $this->mapFilterColumn($filter->getColumn()),
+ $filter->getSign(),
+ $filter->getExpression()
+ );
+ }
+
+ return $str;
+ }
+
+ protected function escapeForSql($value)
+ {
+ // bindParam? bindValue?
+ if (is_array($value)) {
+ $ret = array();
+ foreach ($value as $val) {
+ $ret[] = $this->escapeForSql($val);
+ }
+ return implode(', ', $ret);
+ } else {
+ //if (preg_match('/^\d+$/', $value)) {
+ // return $value;
+ //} else {
+ return $this->db()->quote($value);
+ //}
+ }
+ }
+
+ protected function escapeWildcards($value)
+ {
+ return preg_replace('/\*/', '%', $value);
+ }
+
+ protected function valueToTimestamp($value)
+ {
+ // We consider integers as valid timestamps. Does not work for URL params
+ if (! is_string($value) || ctype_digit($value)) {
+ return $value;
+ }
+ $value = strtotime($value);
+ if (! $value) {
+ /*
+ NOTE: It's too late to throw exceptions, we might finish in __toString
+ throw new QueryException(sprintf(
+ '"%s" is not a valid time expression',
+ $value
+ ));
+ */
+ }
+ return $value;
+ }
+
+ protected function timestampForSql($value)
+ {
+ // TODO: do this db-aware
+ return $this->escapeForSql(date('Y-m-d H:i:s', $value));
+ }
+
+ /**
+ * Check for timestamp fields
+ *
+ * TODO: This is not here to do automagic timestamp stuff. One may
+ * override this function for custom voodoo, IdoQuery right now
+ * does. IMO we need to split whereToSql functionality, however
+ * I'd prefer to wait with this unless we understood how other
+ * backends will work. We probably should also rename this
+ * function to isTimestampColumn().
+ *
+ * @param string $field Field Field name to checked
+ * @return bool Whether this field expects timestamps
+ */
+ public function isTimestamp($field)
+ {
+ return false;
+ }
+
+ public function whereToSql($col, $sign, $expression)
+ {
+ if ($this->isTimestamp($col)) {
+ $expression = $this->valueToTimestamp($expression);
+ }
+
+ if (is_array($expression) && $sign === '=') {
+ // TODO: Should we support this? Doesn't work for blub*
+ return $col . ' IN (' . $this->escapeForSql($expression) . ')';
+ } elseif ($sign === '=' && strpos($expression, '*') !== false) {
+ if ($expression === '*') {
+ // We'll ignore such filters as it prevents index usage and because "*" means anything, anything means
+ // all whereas all means that whether we use a filter to match anything or no filter at all makes no
+ // difference, except for performance reasons...
+ return '';
+ }
+
+ return $col . ' LIKE ' . $this->escapeForSql($this->escapeWildcards($expression));
+ } elseif ($sign === '!=' && strpos($expression, '*') !== false) {
+ if ($expression === '*') {
+ // We'll ignore such filters as it prevents index usage and because "*" means nothing, so whether we're
+ // using a real column with a valid comparison here or just an expression which cannot be evaluated to
+ // true makes no difference, except for performance reasons...
+ return $this->escapeForSql(0);
+ }
+
+ return $col . ' NOT LIKE ' . $this->escapeForSql($this->escapeWildcards($expression));
+ } else {
+ return $col . ' ' . $sign . ' ' . $this->escapeForSql($expression);
+ }
+ }
+}
diff --git a/library/Director/Web/Table/ReadOnlyFormAvpTable.php b/library/Director/Web/Table/ReadOnlyFormAvpTable.php
new file mode 100644
index 0000000..c3b44f3
--- /dev/null
+++ b/library/Director/Web/Table/ReadOnlyFormAvpTable.php
@@ -0,0 +1,113 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Module\Director\PlainObjectRenderer;
+use Icinga\Module\Director\Web\Form\QuickForm;
+use Zend_Form_Element as ZfElement;
+use Zend_Form_DisplayGroup as ZfDisplayGroup;
+
+class ReadOnlyFormAvpTable
+{
+ protected $form;
+
+ public function __construct(QuickForm $form)
+ {
+ $this->form = $form;
+ }
+
+ protected function renderDisplayGroups(QuickForm $form)
+ {
+ $html = '';
+
+ foreach ($form->getDisplayGroups() as $group) {
+ $elements = $this->filterGroupElements($group);
+
+ if (empty($elements)) {
+ continue;
+ }
+
+ $html .= '<tr><th colspan="2" style="text-align: right">' . $group->getLegend() . '</th></tr>';
+ $html .= $this->renderElements($elements);
+ }
+
+ return $html;
+ }
+
+ /**
+ * @param ZfDisplayGroup $group
+ * @return ZfElement[]
+ */
+ protected function filterGroupElements(ZfDisplayGroup $group)
+ {
+ $blacklist = array('disabled', 'assign_filter');
+ $elements = array();
+ /** @var ZfElement $element */
+ foreach ($group->getElements() as $element) {
+ if ($element->getValue() === null) {
+ continue;
+ }
+
+ if ($element->getType() === 'Zend_Form_Element_Hidden') {
+ continue;
+ }
+
+ if (in_array($element->getName(), $blacklist)) {
+ continue;
+ }
+
+
+ $elements[] = $element;
+ }
+
+ return $elements;
+ }
+
+ protected function renderElements($elements)
+ {
+ $html = '';
+ foreach ($elements as $element) {
+ $html .= $this->renderElement($element);
+ }
+
+ return $html;
+ }
+
+ /**
+ * @param ZfElement $element
+ *
+ * @return string
+ */
+ protected function renderElement(ZfElement $element)
+ {
+ $value = $element->getValue();
+ return '<tr><th>'
+ . $this->escape($element->getLabel())
+ . '</th><td>'
+ . $this->renderValue($value)
+ . '</td></tr>';
+ }
+
+ protected function renderValue($value)
+ {
+ if (is_string($value)) {
+ return $this->escape($value);
+ } elseif (is_array($value)) {
+ return $this->escape(implode(', ', $value));
+ }
+ return $this->escape(PlainObjectRenderer::render($value));
+ }
+
+ protected function escape($string)
+ {
+ return htmlspecialchars($string);
+ }
+
+ public function render()
+ {
+ $this->form->initializeForObject();
+ return '<table class="name-value-table">' . "\n"
+ . $this->renderDisplayGroups($this->form)
+ . '</table>';
+ }
+}
diff --git a/library/Director/Web/Table/ServiceTemplateUsageTable.php b/library/Director/Web/Table/ServiceTemplateUsageTable.php
new file mode 100644
index 0000000..82f9643
--- /dev/null
+++ b/library/Director/Web/Table/ServiceTemplateUsageTable.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+class ServiceTemplateUsageTable extends TemplateUsageTable
+{
+ public function getTypes()
+ {
+ return [
+ 'templates' => $this->translate('Templates'),
+ 'objects' => $this->translate('Objects'),
+ 'applyrules' => $this->translate('Apply Rules'),
+ // 'setmembers' => $this->translate('Set Members'),
+ ];
+ }
+
+ protected function getTypeSummaryDefinitions()
+ {
+ return [
+ 'templates' => $this->getSummaryLine('template'),
+ 'objects' => $this->getSummaryLine('object'),
+ 'applyrules' => $this->getSummaryLine('apply', 'o.service_set_id IS NULL'),
+ // TODO: re-enable
+ // 'setmembers' => $this->getSummaryLine('apply', 'o.service_set_id IS NOT NULL'),
+ ];
+ }
+}
diff --git a/library/Director/Web/Table/SyncRunTable.php b/library/Director/Web/Table/SyncRunTable.php
new file mode 100644
index 0000000..e08aad7
--- /dev/null
+++ b/library/Director/Web/Table/SyncRunTable.php
@@ -0,0 +1,90 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use gipfl\Format\LocalTimeFormat;
+use Icinga\Module\Director\Objects\SyncRule;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+
+class SyncRunTable extends ZfQueryBasedTable
+{
+ /** @var SyncRule */
+ protected $rule;
+
+ protected $timeFormat;
+
+ public function __construct(SyncRule $rule)
+ {
+ parent::__construct($rule->getConnection());
+ $this->timeFormat = new LocalTimeFormat();
+ $this->getAttributes()
+ ->set('data-base-target', '_self')
+ ->add('class', 'history');
+ $this->rule = $rule;
+ }
+
+ public function renderRow($row)
+ {
+ $time = strtotime($row->start_time);
+ $this->renderDayIfNew($time);
+ return $this::tr([
+ $this::td($this->makeSummary($row)),
+ $this::td(new Link(
+ $this->timeFormat->getTime($time),
+ 'director/syncrule/history',
+ [
+ 'id' => $row->rule_id,
+ 'run_id' => $row->id,
+ ]
+ ))
+ ]);
+ }
+
+ protected function makeSummary($row)
+ {
+ $parts = [];
+ if ($row->objects_created > 0) {
+ $parts[] = sprintf(
+ $this->translate('%d created'),
+ $row->objects_created
+ );
+ }
+ if ($row->objects_modified > 0) {
+ $parts[] = sprintf(
+ $this->translate('%d modified'),
+ $row->objects_modified
+ );
+ }
+ if ($row->objects_deleted > 0) {
+ $parts[] = sprintf(
+ $this->translate('%d deleted'),
+ $row->objects_deleted
+ );
+ }
+
+ return implode(', ', $parts);
+ }
+
+ public function prepareQuery()
+ {
+ return $this->db()->select()->from(
+ array('sr' => 'sync_run'),
+ [
+ 'id' => 'sr.id',
+ 'rule_id' => 'sr.rule_id',
+ 'rule_name' => 'sr.rule_name',
+ 'start_time' => 'sr.start_time',
+ 'duration_ms' => 'sr.duration_ms',
+ 'objects_deleted' => 'sr.objects_deleted',
+ 'objects_created' => 'sr.objects_created',
+ 'objects_modified' => 'sr.objects_modified',
+ 'last_former_activity' => 'sr.last_former_activity',
+ 'last_related_activity' => 'sr.last_related_activity',
+ ]
+ )->where(
+ 'sr.rule_id = ?',
+ $this->rule->get('id')
+ )->order('start_time DESC');
+ }
+}
diff --git a/library/Director/Web/Table/SyncpropertyTable.php b/library/Director/Web/Table/SyncpropertyTable.php
new file mode 100644
index 0000000..79461ce
--- /dev/null
+++ b/library/Director/Web/Table/SyncpropertyTable.php
@@ -0,0 +1,97 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Module\Director\Objects\SyncRule;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\Extension\ZfSortablePriority;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+
+class SyncpropertyTable extends ZfQueryBasedTable
+{
+ use ZfSortablePriority;
+
+ /** @var SyncRule */
+ protected $rule;
+
+ protected $searchColumns = [
+ 'source_expression',
+ 'destination_field',
+ ];
+
+ protected $keyColumn = 'id';
+
+ protected $priorityColumn = 'priority';
+
+ public static function create(SyncRule $rule)
+ {
+ $table = new static($rule->getConnection());
+ $table->getAttributes()->set('data-base-target', '_self');
+ $table->rule = $rule;
+ return $table;
+ }
+
+ public function render()
+ {
+ return $this->renderWithSortableForm();
+ }
+
+ public function renderRow($row)
+ {
+ return $this->addSortPriorityButtons(
+ $this::row([
+ $row->source_name,
+ $row->source_expression,
+ new Link(
+ $row->destination_field,
+ 'director/syncrule/editproperty',
+ [
+ 'id' => $row->id,
+ 'rule_id' => $row->rule_id,
+ ]
+ ),
+ ]),
+ $row
+ );
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [
+ $this->translate('Source name'),
+ $this->translate('Source field'),
+ $this->translate('Destination'),
+ $this->getSortPriorityTitle()
+ ];
+ }
+
+ public function prepareQuery()
+ {
+ return $this->db()->select()->from(
+ ['p' => 'sync_property'],
+ [
+ 'id' => 'p.id',
+ 'rule_id' => 'p.rule_id',
+ 'rule_name' => 'r.rule_name',
+ 'source_id' => 'p.source_id',
+ 'source_name' => 's.source_name',
+ 'source_expression' => 'p.source_expression',
+ 'destination_field' => 'p.destination_field',
+ 'priority' => 'p.priority',
+ 'filter_expression' => 'p.filter_expression',
+ 'merge_policy' => 'p.merge_policy'
+ ]
+ )->join(
+ ['r' => 'sync_rule'],
+ 'r.id = p.rule_id',
+ []
+ )->join(
+ ['s' => 'import_source'],
+ 's.id = p.source_id',
+ []
+ )->where(
+ 'p.rule_id = ?',
+ $this->rule->get('id')
+ )->order('p.priority');
+ }
+}
diff --git a/library/Director/Web/Table/SyncruleTable.php b/library/Director/Web/Table/SyncruleTable.php
new file mode 100644
index 0000000..4a8e4e5
--- /dev/null
+++ b/library/Director/Web/Table/SyncruleTable.php
@@ -0,0 +1,67 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+
+class SyncruleTable extends ZfQueryBasedTable
+{
+ protected $searchColumns = [
+ 'rule_name',
+ 'description',
+ ];
+
+ protected function assemble()
+ {
+ $this->getAttributes()->add('class', 'syncstate');
+ parent::assemble();
+ }
+
+ public function renderRow($row)
+ {
+ $caption = [Link::create(
+ $row->rule_name,
+ 'director/syncrule',
+ ['id' => $row->id]
+ )];
+ if ($row->description !== null) {
+ $caption[] = ': ' . $row->description;
+ }
+
+ if ($row->sync_state === 'failing' && $row->last_error_message) {
+ $caption[] = ' (' . $row->last_error_message . ')';
+ }
+
+ $tr = $this::row([$caption, $row->object_type]);
+ $tr->getAttributes()->add('class', $row->sync_state);
+
+ return $tr;
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [
+ $this->translate('Rule name'),
+ $this->translate('Object type'),
+ ];
+ }
+
+ public function prepareQuery()
+ {
+ return $this->db()->select()->from(
+ ['s' => 'sync_rule'],
+ [
+ 'id' => 's.id',
+ 'rule_name' => 's.rule_name',
+ 'sync_state' => 's.sync_state',
+ 'object_type' => 's.object_type',
+ 'update_policy' => 's.update_policy',
+ 'purge_existing' => 's.purge_existing',
+ 'filter_expression' => 's.filter_expression',
+ 'last_error_message' => 's.last_error_message',
+ 'description' => 's.description',
+ ]
+ )->order('rule_name');
+ }
+}
diff --git a/library/Director/Web/Table/TableLoader.php b/library/Director/Web/Table/TableLoader.php
new file mode 100644
index 0000000..f7e378b
--- /dev/null
+++ b/library/Director/Web/Table/TableLoader.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Application\Icinga;
+use Icinga\Application\Modules\Module;
+use Icinga\Exception\ProgrammingError;
+
+class TableLoader
+{
+ /** @return QuickTable */
+ public static function load($name, Module $module = null)
+ {
+ if ($module === null) {
+ $basedir = Icinga::app()->getApplicationDir('tables');
+ $ns = '\\Icinga\\Web\\Tables\\';
+ } else {
+ $basedir = $module->getBaseDir() . '/application/tables';
+ $ns = '\\Icinga\\Module\\' . ucfirst($module->getName()) . '\\Tables\\';
+ }
+ if (preg_match('~^[a-z0-9/]+$~i', $name)) {
+ $parts = preg_split('~/~', $name);
+ $class = ucfirst(array_pop($parts)) . 'Table';
+ $file = sprintf('%s/%s/%s.php', rtrim($basedir, '/'), implode('/', $parts), $class);
+ if (file_exists($file)) {
+ require_once($file);
+ /** @var QuickTable $class */
+ $class = $ns . $class;
+ return new $class();
+ }
+ }
+ throw new ProgrammingError(sprintf('Cannot load %s (%s), no such table', $name, $file));
+ }
+}
diff --git a/library/Director/Web/Table/TableWithBranchSupport.php b/library/Director/Web/Table/TableWithBranchSupport.php
new file mode 100644
index 0000000..7c5b15c
--- /dev/null
+++ b/library/Director/Web/Table/TableWithBranchSupport.php
@@ -0,0 +1,69 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Module\Director\Db\Branch\Branch;
+use Ramsey\Uuid\UuidInterface;
+
+trait TableWithBranchSupport
+{
+
+ /** @var UuidInterface|null */
+ protected $branchUuid;
+
+ /**
+ * Convenience method, only UUID is required
+ *
+ * @param Branch|null $branch
+ * @return $this
+ */
+ public function setBranch(Branch $branch = null)
+ {
+ if ($branch && $branch->isBranch()) {
+ $this->setBranchUuid($branch->getUuid());
+ }
+
+ return $this;
+ }
+
+ public function setBranchUuid(UuidInterface $uuid = null)
+ {
+ $this->branchUuid = $uuid;
+
+ return $this;
+ }
+
+ protected function branchifyColumns($columns)
+ {
+ $result = [
+ 'uuid' => 'COALESCE(o.uuid, bo.uuid)'
+ ];
+ $ignore = ['o.id', 'os.id', 'o.service_set_id', 'os.host_id'];
+ foreach ($columns as $alias => $column) {
+ if (substr($column, 0, 2) === 'o.' && ! in_array($column, $ignore)) {
+ // bo.column, o.column
+ $column = "COALESCE(b$column, $column)";
+ }
+ if (substr($column, 0, 3) === 'os.' && ! in_array($column, $ignore)) {
+ // bo.column, o.column
+ $column = "COALESCE(b$column, $column)";
+ }
+
+ // Used in Service Tables:
+ if ($column === 'h.object_name' && $alias = 'host') {
+ $column = "COALESCE(bo.host, $column)";
+ }
+
+ $result[$alias] = $column;
+ }
+
+ return $result;
+ }
+
+ protected function stripSearchColumnAliases()
+ {
+ foreach ($this->searchColumns as &$column) {
+ $column = preg_replace('/^[a-z]+\./', '', $column);
+ }
+ }
+}
diff --git a/library/Director/Web/Table/TemplateUsageTable.php b/library/Director/Web/Table/TemplateUsageTable.php
new file mode 100644
index 0000000..66e56ea
--- /dev/null
+++ b/library/Director/Web/Table/TemplateUsageTable.php
@@ -0,0 +1,157 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Exception\ProgrammingError;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Icinga\Module\Director\Resolver\TemplateTree;
+use gipfl\IcingaWeb2\Link;
+use ipl\Html\Table;
+use gipfl\Translation\TranslationHelper;
+
+class TemplateUsageTable extends Table
+{
+ use TranslationHelper;
+
+ protected $defaultAttributes = ['class' => 'pivot'];
+
+ protected $objectType;
+
+ public function getTypes()
+ {
+ return [
+ 'templates' => $this->translate('Templates'),
+ 'objects' => $this->translate('Objects'),
+ ];
+ }
+
+ protected function getTypeSummaryDefinitions()
+ {
+ return [
+ 'templates' => $this->getSummaryLine('template'),
+ 'objects' => $this->getSummaryLine('object'),
+ ];
+ }
+
+ /**
+ * @param IcingaObject $template
+ * @return TemplateUsageTable
+ */
+ public static function forTemplate(IcingaObject $template)
+ {
+ $type = ucfirst($template->getShortTableName());
+ $class = __NAMESPACE__ . "\\${type}TemplateUsageTable";
+ if (class_exists($class)) {
+ return new $class($template);
+ } else {
+ return new static($template);
+ }
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [
+ '',
+ $this->translate('Direct'),
+ $this->translate('Indirect'),
+ $this->translate('Total')
+ ];
+ }
+
+ protected function __construct(IcingaObject $template)
+ {
+
+ if ($template->get('object_type') !== 'template') {
+ throw new ProgrammingError(
+ 'TemplateUsageTable expects a template, got %s',
+ $template->get('object_type')
+ );
+ }
+
+ $this->objectType = $objectType = $template->getShortTableName();
+ $types = $this->getTypes();
+ $usage = $this->getUsageSummary($template);
+
+ $used = false;
+ $rows = [];
+ foreach ($types as $type => $typeTitle) {
+ $tr = Table::tr(Table::th($typeTitle));
+ foreach (['direct', 'indirect', 'total'] as $inheritance) {
+ $count = $usage->$inheritance->$type;
+ if (! $used && $count > 0) {
+ $used = true;
+ }
+ $tr->add(
+ Table::td(
+ Link::create(
+ $count,
+ "director/${objectType}template/$type",
+ [
+ 'name' => $template->getObjectName(),
+ 'inheritance' => $inheritance
+ ]
+ )
+ )
+ );
+ }
+ $rows[] = $tr;
+ }
+
+ if ($used) {
+ $this->add($rows);
+ } else {
+ $this->add($this->translate('This template is not in use'));
+ }
+ }
+
+ protected function getUsageSummary(IcingaObject $template)
+ {
+ $id = $template->getAutoincId();
+ $connection = $template->getConnection();
+ $db = $connection->getDbAdapter();
+ $oType = $this->objectType;
+ $tree = new TemplateTree($oType, $connection);
+ $ids = $tree->listDescendantIdsFor($template);
+ if (empty($ids)) {
+ $ids = [0];
+ }
+
+ $baseQuery = $db->select()->from(
+ ['o' => 'icinga_' . $oType],
+ $this->getTypeSummaryDefinitions()
+ )->joinLeft(
+ ['oi' => "icinga_${oType}_inheritance"],
+ "oi.${oType}_id = o.id",
+ []
+ );
+
+ $query = clone($baseQuery);
+ $direct = $db->fetchRow(
+ $query->where("oi.parent_${oType}_id = ?", $id)
+ );
+ $query = clone($baseQuery);
+ $indirect = $db->fetchRow(
+ $query->where("oi.parent_${oType}_id IN (?)", $ids)
+ );
+ //$indirect->templates = count($ids) - 1;
+ $total = [];
+ $types = array_keys($this->getTypes());
+ foreach ($types as $type) {
+ $total[$type] = $direct->$type + $indirect->$type;
+ }
+
+ return (object) [
+ 'direct' => $direct,
+ 'indirect' => $indirect,
+ 'total' => (object) $total
+ ];
+ }
+
+ protected function getSummaryLine($type, $extra = null)
+ {
+ if ($extra !== null) {
+ $extra = " AND $extra";
+ }
+ return "COALESCE(SUM(CASE WHEN o.object_type = '${type}'${extra} THEN 1 ELSE 0 END), 0)";
+ }
+}
diff --git a/library/Director/Web/Table/TemplatesTable.php b/library/Director/Web/Table/TemplatesTable.php
new file mode 100644
index 0000000..be195b2
--- /dev/null
+++ b/library/Director/Web/Table/TemplatesTable.php
@@ -0,0 +1,156 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Table;
+
+use Icinga\Authentication\Auth;
+use Icinga\Data\Filter\Filter;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Db\IcingaObjectFilterHelper;
+use Icinga\Module\Director\Objects\IcingaObject;
+use ipl\Html\Html;
+use gipfl\IcingaWeb2\Icon;
+use gipfl\IcingaWeb2\Link;
+use gipfl\IcingaWeb2\Table\Extension\MultiSelect;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+use gipfl\IcingaWeb2\Url;
+use gipfl\IcingaWeb2\Zf1\Db\FilterRenderer;
+use Ramsey\Uuid\Uuid;
+use Zend_Db_Select as ZfSelect;
+
+class TemplatesTable extends ZfQueryBasedTable implements FilterableByUsage
+{
+ use MultiSelect;
+
+ protected $searchColumns = ['o.object_name'];
+
+ private $type;
+
+ public static function create($type, Db $db)
+ {
+ $table = new static($db);
+ $table->type = strtolower($type);
+ return $table;
+ }
+
+ protected function assemble()
+ {
+ $type = $this->type;
+ $this->enableMultiSelect(
+ "director/${type}s/edittemplates",
+ "director/${type}template",
+ ['name']
+ );
+ }
+
+ public function getType()
+ {
+ return $this->type;
+ }
+
+ public function getColumnsToBeRendered()
+ {
+ return [$this->translate('Template Name')];
+ }
+
+ public function renderRow($row)
+ {
+ $name = $row->object_name;
+ $type = str_replace('_', '-', $this->getType());
+ $caption = $row->is_used === 'y' ? $name : [
+ $name,
+ Html::tag(
+ 'span',
+ ['style' => 'font-style: italic'],
+ $this->translate(' - not in use -')
+ )
+ ];
+
+ $url = Url::fromPath("director/${type}template/usage", [
+ 'name' => $name
+ ]);
+
+ return $this::row([
+ new Link($caption, $url),
+ [
+ new Link(new Icon('plus'), "director/$type/add", [
+ 'type' => 'object',
+ 'imports' => $name
+ ]),
+ new Link(new Icon('history'), "director/$type/history", [
+ 'uuid' => Uuid::fromBytes(Db\DbUtil::binaryResult($row->uuid))->toString(),
+ ])
+ ]
+ ]);
+ }
+
+ public function filterTemplate(
+ IcingaObject $template,
+ $inheritance = IcingaObjectFilterHelper::INHERIT_DIRECT
+ ) {
+ IcingaObjectFilterHelper::filterByTemplate(
+ $this->getQuery(),
+ $template,
+ 'o',
+ $inheritance
+ );
+
+ return $this;
+ }
+
+ public function showOnlyUsed()
+ {
+ $type = $this->getType();
+ $this->getQuery()->where(
+ "(EXISTS (SELECT ${type}_id FROM icinga_${type}_inheritance"
+ . " WHERE parent_${type}_id = o.id))"
+ );
+ }
+
+ public function showOnlyUnUsed()
+ {
+ $type = $this->getType();
+ $this->getQuery()->where(
+ "(NOT EXISTS (SELECT ${type}_id FROM icinga_${type}_inheritance"
+ . " WHERE parent_${type}_id = o.id))"
+ );
+ }
+
+ protected function applyRestrictions(ZfSelect $query)
+ {
+ $auth = Auth::getInstance();
+ $type = $this->type;
+ $restrictions = $auth->getRestrictions("director/$type/template/filter-by-name");
+ if (empty($restrictions)) {
+ return $query;
+ }
+
+ $filter = Filter::matchAny();
+ foreach ($restrictions as $restriction) {
+ $filter->addFilter(Filter::where('o.object_name', $restriction));
+ }
+
+ return FilterRenderer::applyToQuery($filter, $query);
+ }
+
+ protected function prepareQuery()
+ {
+ $type = $this->getType();
+ $used = "CASE WHEN EXISTS(SELECT 1 FROM icinga_${type}_inheritance oi"
+ . " WHERE oi.parent_${type}_id = o.id) THEN 'y' ELSE 'n' END";
+
+ $columns = [
+ 'object_name' => 'o.object_name',
+ 'uuid' => 'o.uuid',
+ 'id' => 'o.id',
+ 'is_used' => $used,
+ ];
+ $query = $this->db()->select()->from(
+ ['o' => "icinga_${type}"],
+ $columns
+ )->where(
+ "o.object_type = 'template'"
+ )->order('o.object_name');
+
+ return $this->applyRestrictions($query);
+ }
+}
diff --git a/library/Director/Web/Tabs/DataTabs.php b/library/Director/Web/Tabs/DataTabs.php
new file mode 100644
index 0000000..ac29310
--- /dev/null
+++ b/library/Director/Web/Tabs/DataTabs.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Tabs;
+
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Widget\Tabs;
+
+class DataTabs extends Tabs
+{
+ use TranslationHelper;
+
+ public function __construct()
+ {
+ // We are not a BaseElement, not yet
+ $this->assemble();
+ }
+
+ protected function assemble()
+ {
+ $this->add('datafield', [
+ 'label' => $this->translate('Data fields'),
+ 'url' => 'director/data/fields'
+ ])->add('datafieldcategory', [
+ 'label' => $this->translate('Data field categories'),
+ 'url' => 'director/data/fieldcategories'
+ ])->add('datalist', [
+ 'label' => $this->translate('Data lists'),
+ 'url' => 'director/data/lists'
+ ])->add('customvars', [
+ 'label' => $this->translate('Custom Variables'),
+ 'url' => 'director/data/vars'
+ ]);
+ }
+}
diff --git a/library/Director/Web/Tabs/ImportTabs.php b/library/Director/Web/Tabs/ImportTabs.php
new file mode 100644
index 0000000..e6c6807
--- /dev/null
+++ b/library/Director/Web/Tabs/ImportTabs.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Tabs;
+
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Widget\Tabs;
+
+class ImportTabs extends Tabs
+{
+ use TranslationHelper;
+
+ public function __construct()
+ {
+ $this->assemble();
+ }
+
+ protected function assemble()
+ {
+ $this->add('importsource', [
+ 'label' => $this->translate('Import source'),
+ 'url' => 'director/importsources'
+ ])->add('syncrule', [
+ 'label' => $this->translate('Sync rule'),
+ 'url' => 'director/syncrules'
+ ])->add('jobs', [
+ 'label' => $this->translate('Jobs'),
+ 'url' => 'director/jobs'
+ ]);
+ }
+}
diff --git a/library/Director/Web/Tabs/ImportsourceTabs.php b/library/Director/Web/Tabs/ImportsourceTabs.php
new file mode 100644
index 0000000..74dedb3
--- /dev/null
+++ b/library/Director/Web/Tabs/ImportsourceTabs.php
@@ -0,0 +1,58 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Tabs;
+
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Widget\Tabs;
+
+class ImportsourceTabs extends Tabs
+{
+ use TranslationHelper;
+
+ protected $id;
+
+ public function __construct($id = null)
+ {
+ $this->id = $id;
+ $this->assemble();
+ }
+
+ public function activateMainWithPostfix($postfix)
+ {
+ $mainTab = 'index';
+ $tab = $this->get($mainTab);
+ $tab->setLabel($tab->getLabel() . ": $postfix");
+ $this->activate($mainTab);
+
+ return $this;
+ }
+
+ protected function assemble()
+ {
+ if ($id = $this->id) {
+ $params = ['id' => $id];
+ $this->add('index', [
+ 'url' => 'director/importsource',
+ 'urlParams' => $params,
+ 'label' => $this->translate('Import source'),
+ ])->add('modifier', [
+ 'url' => 'director/importsource/modifier',
+ 'urlParams' => ['source_id' => $id],
+ 'label' => $this->translate('Modifiers'),
+ ])->add('history', [
+ 'url' => 'director/importsource/history',
+ 'urlParams' => $params,
+ 'label' => $this->translate('History'),
+ ])->add('preview', [
+ 'url' => 'director/importsource/preview',
+ 'urlParams' => $params,
+ 'label' => $this->translate('Preview'),
+ ]);
+ } else {
+ $this->add('add', [
+ 'url' => 'director/importsource/add',
+ 'label' => $this->translate('New import source'),
+ ])->activate('add');
+ }
+ }
+}
diff --git a/library/Director/Web/Tabs/InfraTabs.php b/library/Director/Web/Tabs/InfraTabs.php
new file mode 100644
index 0000000..8a65c4e
--- /dev/null
+++ b/library/Director/Web/Tabs/InfraTabs.php
@@ -0,0 +1,49 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Tabs;
+
+use Icinga\Authentication\Auth;
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Widget\Tabs;
+
+class InfraTabs extends Tabs
+{
+ use TranslationHelper;
+
+ /** @var Auth */
+ protected $auth;
+
+ public function __construct(Auth $auth)
+ {
+ $this->auth = $auth;
+ // We are not a BaseElement, not yet
+ $this->assemble();
+ }
+
+ protected function assemble()
+ {
+ $auth = $this->auth;
+
+ if ($auth->hasPermission('director/audit')) {
+ $this->add('activitylog', [
+ 'label' => $this->translate('Activity Log'),
+ 'url' => 'director/config/activities'
+ ]);
+ }
+
+ if ($auth->hasPermission('director/deploy')) {
+ $this->add('deploymentlog', [
+ 'label' => $this->translate('Deployments'),
+ 'url' => 'director/config/deployments'
+ ]);
+ }
+
+ if ($auth->hasPermission('director/admin')) {
+ $this->add('infrastructure', [
+ 'label' => $this->translate('Infrastructure'),
+ 'url' => 'director/dashboard',
+ 'urlParams' => ['name' => 'infrastructure']
+ ]);
+ }
+ }
+}
diff --git a/library/Director/Web/Tabs/MainTabs.php b/library/Director/Web/Tabs/MainTabs.php
new file mode 100644
index 0000000..5ea2e9b
--- /dev/null
+++ b/library/Director/Web/Tabs/MainTabs.php
@@ -0,0 +1,85 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Tabs;
+
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Widget\Tabs;
+use Icinga\Authentication\Auth;
+use Icinga\Module\Director\Web\Widget\Daemon\BackgroundDaemonState;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Health;
+use Icinga\Module\Director\Web\Widget\HealthCheckPluginOutput;
+
+class MainTabs extends Tabs
+{
+ use TranslationHelper;
+
+ protected $auth;
+
+ protected $dbResourceName;
+
+ public function __construct(Auth $auth, $dbResourceName)
+ {
+ $this->auth = $auth;
+ $this->dbResourceName = $dbResourceName;
+ $this->add('main', [
+ 'label' => $this->translate('Overview'),
+ 'url' => 'director'
+ ]);
+ if ($this->auth->hasPermission('director/admin')) {
+ $this->add('health', [
+ 'label' => $this->translate('Health'),
+ 'url' => 'director/health'
+ ])->add('daemon', [
+ 'label' => $this->translate('Daemon'),
+ 'url' => 'director/daemon'
+ ]);
+ }
+ }
+
+ public function render()
+ {
+ if ($this->auth->hasPermission('director/admin')) {
+ if ($this->getActiveName() !== 'health') {
+ $state = $this->getHealthState();
+ if ($state->isProblem()) {
+ $this->get('health')->setTagParams([
+ 'class' => 'state-' . strtolower($state->getName())
+ ]);
+ }
+ }
+
+ if ($this->getActiveName() !== 'daemon') {
+ try {
+ $daemon = new BackgroundDaemonState(Db::fromResourceName($this->dbResourceName));
+ if ($daemon->isRunning()) {
+ $state = 'ok';
+ } else {
+ $state = 'critical';
+ }
+ } catch (\Exception $e) {
+ $state = 'unknown';
+ }
+ if ($state !== 'ok') {
+ $this->get('daemon')->setTagParams([
+ 'class' => 'state-' . $state
+ ]);
+ }
+ }
+ }
+
+ return parent::render();
+ }
+
+ /**
+ * @return \Icinga\Module\Director\CheckPlugin\PluginState
+ */
+ protected function getHealthState()
+ {
+ $health = new Health();
+ $health->setDbResourceName($this->dbResourceName);
+ $output = new HealthCheckPluginOutput($health);
+
+ return $output->getState();
+ }
+}
diff --git a/library/Director/Web/Tabs/ObjectTabs.php b/library/Director/Web/Tabs/ObjectTabs.php
new file mode 100644
index 0000000..cbd3f15
--- /dev/null
+++ b/library/Director/Web/Tabs/ObjectTabs.php
@@ -0,0 +1,160 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Tabs;
+
+use Icinga\Authentication\Auth;
+use Icinga\Module\Director\Objects\IcingaObject;
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Widget\Tabs;
+
+class ObjectTabs extends Tabs
+{
+ use TranslationHelper;
+
+ /** @var string */
+ private $type;
+
+ /** @var Auth */
+ private $auth;
+
+ /** @var IcingaObject $object */
+ private $object;
+
+ private $allowedExternals = [
+ 'apiuser',
+ 'endpoint'
+ ];
+
+ public function __construct($type, Auth $auth, IcingaObject $object = null)
+ {
+ $this->type = $type;
+ $this->auth = $auth;
+ $this->object = $object;
+ // We are not a BaseElement, not yet
+ $this->assemble();
+ }
+
+ protected function assemble()
+ {
+ if (null === $this->object) {
+ $this->addTabsForNewObject();
+ } else {
+ $this->addTabsForExistingObject();
+ }
+ }
+
+ protected function addTabsForNewObject()
+ {
+ $type = $this->type;
+ $this->add('add', array(
+ 'url' => sprintf('director/%s/add', $type),
+ 'label' => sprintf($this->translate('Add %s'), ucfirst($type)),
+ ));
+ }
+
+ protected function addTabsForExistingObject()
+ {
+ $type = $this->type;
+ $auth = $this->auth;
+ $object = $this->object;
+ $params = $object->getUrlParams();
+
+ if (! $object->isExternal()
+ || in_array($object->getShortTableName(), $this->allowedExternals)
+ ) {
+ $this->add('modify', array(
+ 'url' => sprintf('director/%s', $type),
+ 'urlParams' => $params,
+ 'label' => $this->translate(ucfirst($type))
+ ));
+ }
+ if ($object->getShortTableName() === 'host') {
+ $this->add('services', [
+ 'url' => 'director/host/services',
+ 'urlParams' => $params,
+ 'label' => $this->translate('Services')
+ ]);
+ }
+
+ if ($auth->hasPermission('director/showconfig')) {
+ if ($object->getShortTableName() !== 'service'
+ || $object->get('service_set_id') === null
+ ) {
+ $this->add('render', array(
+ 'url' => sprintf('director/%s/render', $type),
+ 'urlParams' => $params,
+ 'label' => $this->translate('Preview'),
+ ));
+ }
+ }
+
+ if ($auth->hasPermission('director/audit')) {
+ $this->add('history', array(
+ 'url' => sprintf('director/%s/history', $type),
+ 'urlParams' => $params,
+ 'label' => $this->translate('History')
+ ));
+ }
+
+ if ($auth->hasPermission('director/admin') && $this->hasFields()) {
+ $this->add('fields', array(
+ 'url' => sprintf('director/%s/fields', $type),
+ 'urlParams' => $params,
+ 'label' => $this->translate('Fields')
+ ));
+ }
+
+ // TODO: remove table check once we resolve all group types
+ if ($object->isGroup() &&
+ ($object->getShortTableName() === 'hostgroup' || $object->getShortTableName() === 'servicegroup')
+ ) {
+ $this->add('membership', [
+ 'url' => sprintf('director/%s/membership', $type),
+ 'urlParams' => $params,
+ 'label' => $this->translate('Members')
+ ]);
+ }
+
+ if ($object->supportsRanges()) {
+ $this->add('ranges', [
+ 'url' => "director/${type}/ranges",
+ 'urlParams' => $params,
+ 'label' => $this->translate('Ranges')
+ ]);
+ }
+
+ if ($object->getShortTableName() === 'endpoint'
+ && $object->get('apiuser_id')
+ ) {
+ $this->add('inspect', [
+ 'url' => 'director/inspect/types',
+ 'urlParams' => ['endpoint' => $object->getObjectName()],
+ 'label' => $this->translate('Inspect')
+ ]);
+ $this->add('packages', [
+ 'url' => 'director/inspect/packages',
+ 'urlParams' => ['endpoint' => $object->getObjectName()],
+ 'label' => $this->translate('Packages')
+ ]);
+ }
+
+ if ($object->getShortTableName() === 'host' && $auth->hasPermission('director/hosts')) {
+ $this->add('agent', [
+ 'url' => 'director/host/agent',
+ 'urlParams' => $params,
+ 'label' => $this->translate('Agent')
+ ]);
+ }
+ }
+
+ protected function hasFields()
+ {
+ if (! ($object = $this->object)) {
+ return false;
+ }
+
+ return $object->hasBeenLoadedFromDb()
+ && $object->supportsFields()
+ && ($object->isTemplate() || $this->type === 'command');
+ }
+}
diff --git a/library/Director/Web/Tabs/ObjectsTabs.php b/library/Director/Web/Tabs/ObjectsTabs.php
new file mode 100644
index 0000000..4f9e5a8
--- /dev/null
+++ b/library/Director/Web/Tabs/ObjectsTabs.php
@@ -0,0 +1,85 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Tabs;
+
+use Icinga\Authentication\Auth;
+use Icinga\Module\Director\Objects\IcingaObject;
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Widget\Tabs;
+
+class ObjectsTabs extends Tabs
+{
+ use TranslationHelper;
+
+ public function __construct($type, Auth $auth, $typeUrl)
+ {
+ $object = IcingaObject::createByType($type);
+ if ($object->isGroup()) {
+ $object = IcingaObject::createByType(substr($typeUrl, 0, -5));
+ }
+ $shortName = $object->getShortTableName();
+
+ $plType = strtolower(preg_replace('/cys$/', 'cies', $shortName . 's'));
+ $plType = str_replace('_', '-', $plType);
+ if ($auth->hasPermission("director/${plType}")) {
+ $this->add('index', array(
+ 'url' => sprintf('director/%s', $plType),
+ 'label' => $this->translate(ucfirst($plType)),
+ ));
+ }
+
+ if ($object->getShortTableName() === 'command') {
+ $this->add('external', array(
+ 'url' => sprintf('director/%s', strtolower($plType)),
+ 'urlParams' => ['type' => 'external_object'],
+ 'label' => $this->translate('External'),
+ ));
+ }
+
+ if ($auth->hasPermission('director/admin') || (
+ $object->getShortTableName() === 'notification'
+ && $auth->hasPermission('director/notifications')
+ ) || (
+ $object->getShortTableName() === 'scheduled_downtime'
+ && $auth->hasPermission('director/scheduled-downtimes')
+ )) {
+ if ($object->supportsApplyRules()) {
+ $this->add('applyrules', array(
+ 'url' => sprintf('director/%s/applyrules', $plType),
+ 'label' => $this->translate('Apply')
+ ));
+ }
+ }
+
+ if ($auth->hasPermission('director/admin') && $type !== 'zone') {
+ if ($object->supportsImports()) {
+ $this->add('templates', array(
+ 'url' => sprintf('director/%s/templates', $plType),
+ 'label' => $this->translate('Templates'),
+ ));
+ }
+
+ if ($object->supportsGroups()) {
+ $this->add('groups', array(
+ 'url' => sprintf('director/%sgroups', $typeUrl),
+ 'label' => $this->translate('Groups')
+ ));
+ }
+ }
+
+ if ($auth->hasPermission('director/admin')) {
+ if ($object->supportsChoices()) {
+ $this->add('choices', array(
+ 'url' => sprintf('director/templatechoices/%s', $shortName),
+ 'label' => $this->translate('Choices')
+ ));
+ }
+ }
+ if ($object->supportsSets() && $auth->hasPermission("director/${typeUrl}sets")) {
+ $this->add('sets', array(
+ 'url' => sprintf('director/%s/sets', $plType),
+ 'label' => $this->translate('Sets')
+ ));
+ }
+ }
+}
diff --git a/library/Director/Web/Tabs/SyncRuleTabs.php b/library/Director/Web/Tabs/SyncRuleTabs.php
new file mode 100644
index 0000000..d64ff81
--- /dev/null
+++ b/library/Director/Web/Tabs/SyncRuleTabs.php
@@ -0,0 +1,54 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Tabs;
+
+use Icinga\Module\Director\Objects\SyncRule;
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Widget\Tabs;
+
+class SyncRuleTabs extends Tabs
+{
+ use TranslationHelper;
+
+ protected $rule;
+
+ public function __construct(SyncRule $rule = null)
+ {
+ $this->rule = $rule;
+ // We are not a BaseElement, not yet
+ $this->assemble();
+ }
+
+ protected function assemble()
+ {
+ if ($this->rule) {
+ $id = $this->rule->get('id');
+ $this->add('show', [
+ 'url' => 'director/syncrule',
+ 'urlParams' => ['id' => $id],
+ 'label' => $this->translate('Sync rule'),
+ ])->add('preview', [
+ 'url' => 'director/syncrule/preview',
+ 'urlParams' => ['id' => $id],
+ 'label' => $this->translate('Preview'),
+ ])->add('edit', [
+ 'url' => 'director/syncrule/edit',
+ 'urlParams' => ['id' => $id],
+ 'label' => $this->translate('Modify'),
+ ])->add('property', [
+ 'label' => $this->translate('Properties'),
+ 'url' => 'director/syncrule/property',
+ 'urlParams' => ['rule_id' => $id]
+ ])->add('history', [
+ 'label' => $this->translate('History'),
+ 'url' => 'director/syncrule/history',
+ 'urlParams' => ['id' => $id]
+ ]);
+ } else {
+ $this->add('add', [
+ 'url' => 'director/syncrule/add',
+ 'label' => $this->translate('Sync rule'),
+ ]);
+ }
+ }
+}
diff --git a/library/Director/Web/Tree/InspectTreeRenderer.php b/library/Director/Web/Tree/InspectTreeRenderer.php
new file mode 100644
index 0000000..54a177f
--- /dev/null
+++ b/library/Director/Web/Tree/InspectTreeRenderer.php
@@ -0,0 +1,97 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Tree;
+
+use Icinga\Module\Director\Objects\IcingaEndpoint;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use gipfl\IcingaWeb2\Link;
+use gipfl\Translation\TranslationHelper;
+
+class InspectTreeRenderer extends BaseHtmlElement
+{
+ use TranslationHelper;
+
+ protected $tag = 'ul';
+
+ protected $defaultAttributes = [
+ 'class' => 'tree',
+ 'data-base-target' => '_next',
+ ];
+
+ protected $tree;
+
+ /** @var IcingaEndpoint */
+ protected $endpoint;
+
+ public function __construct(IcingaEndpoint $endpoint)
+ {
+ $this->endpoint = $endpoint;
+ }
+
+ protected function getNodes()
+ {
+ $rootNodes = array();
+ $types = $this->endpoint->api()->getTypes();
+ foreach ($types as $name => $type) {
+ if (property_exists($type, 'base')) {
+ $base = $type->base;
+ if (! property_exists($types[$base], 'children')) {
+ $types[$base]->children = array();
+ }
+
+ $types[$base]->children[$name] = $type;
+ } else {
+ $rootNodes[$name] = $type;
+ }
+ }
+
+ return $rootNodes;
+ }
+
+ public function assemble()
+ {
+ $this->add($this->renderNodes($this->getNodes()));
+ }
+
+ protected function renderNodes($nodes, $showLinks = false, $level = 0)
+ {
+ $result = [];
+ foreach ($nodes as $child) {
+ $result[] = $this->renderNode($child, $showLinks, $level + 1);
+ }
+
+ if ($level === 0) {
+ return $result;
+ } else {
+ return Html::tag('ul', null, $result);
+ }
+ }
+
+ protected function renderNode($node, $forceLinks = false, $level = 0)
+ {
+ $name = $node->name;
+ $showLinks = $forceLinks || $name === 'ConfigObject';
+ $hasChildren = property_exists($node, 'children');
+ $li = Html::tag('li');
+ if (! $hasChildren) {
+ $li->getAttributes()->add('class', 'collapsed');
+ }
+
+ if ($hasChildren) {
+ $li->add(Html::tag('span', ['class' => 'handle']));
+ }
+
+ $class = $node->abstract ? 'icon-sitemap' : 'icon-doc-text';
+ $li->add(Link::create($name, 'director/inspect/type', [
+ 'endpoint' => $this->endpoint->getObjectName(),
+ 'type' => $name
+ ], ['class' => $class]));
+
+ if ($hasChildren) {
+ $li->add($this->renderNodes($node->children, $showLinks, $level + 1));
+ }
+
+ return $li;
+ }
+}
diff --git a/library/Director/Web/Tree/TemplateTreeRenderer.php b/library/Director/Web/Tree/TemplateTreeRenderer.php
new file mode 100644
index 0000000..e238ded
--- /dev/null
+++ b/library/Director/Web/Tree/TemplateTreeRenderer.php
@@ -0,0 +1,91 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Tree;
+
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Resolver\TemplateTree;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use gipfl\IcingaWeb2\Link;
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Widget\ControlsAndContent;
+
+class TemplateTreeRenderer extends BaseHtmlElement
+{
+ use TranslationHelper;
+
+ protected $tag = 'ul';
+
+ protected $defaultAttributes = [
+ 'class' => 'tree',
+ 'data-base-target' => '_next',
+ ];
+
+ protected $tree;
+
+ public function __construct(TemplateTree $tree)
+ {
+ $this->tree = $tree;
+ }
+
+ public static function showType($type, ControlsAndContent $controller, Db $db)
+ {
+ $controller->content()->add(
+ new static(new TemplateTree($type, $db))
+ );
+ }
+
+ public function renderContent()
+ {
+ $this->add(
+ $this->dumpTree(
+ array(
+ 'name' => $this->translate('Templates'),
+ 'children' => $this->tree->getTree()
+ )
+ )
+ );
+
+ return parent::renderContent();
+ }
+
+ protected function dumpTree($tree, $level = 0)
+ {
+ $hasChildren = ! empty($tree['children']);
+ $type = $this->tree->getType();
+
+ $li = Html::tag('li');
+ if (! $hasChildren) {
+ $li->getAttributes()->add('class', 'collapsed');
+ }
+
+ if ($hasChildren) {
+ $li->add(Html::tag('span', ['class' => 'handle']));
+ }
+
+ if ($level === 0) {
+ $li->add(Html::tag('a', [
+ 'name' => $tree['name'],
+ 'class' => 'icon-globe'
+ ], $tree['name']));
+ } else {
+ $li->add(Link::create(
+ $tree['name'],
+ "director/${type}template/usage",
+ array('name' => $tree['name']),
+ array('class' => 'icon-' .$type)
+ ));
+ }
+
+ if ($hasChildren) {
+ $li->add(
+ $ul = Html::tag('ul')
+ );
+ foreach ($tree['children'] as $child) {
+ $ul->add($this->dumpTree($child, $level + 1));
+ }
+ }
+
+ return $li;
+ }
+}
diff --git a/library/Director/Web/Widget/AbstractList.php b/library/Director/Web/Widget/AbstractList.php
new file mode 100644
index 0000000..ad1b9e3
--- /dev/null
+++ b/library/Director/Web/Widget/AbstractList.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Html\HtmlElement;
+
+class AbstractList extends BaseHtmlElement
+{
+ protected $contentSeparator = "\n";
+
+ /**
+ * AbstractList constructor.
+ * @param array $items
+ * @param null $attributes
+ */
+ public function __construct(array $items = [], $attributes = null)
+ {
+ foreach ($items as $item) {
+ $this->addItem($item);
+ }
+
+ if ($attributes !== null) {
+ $this->addAttributes($attributes);
+ }
+ }
+
+ /**
+ * @param Html|array|string $content
+ * @param Attributes|array $attributes
+ *
+ * @return $this
+ */
+ public function addItem($content, $attributes = null)
+ {
+ return $this->add(HtmlElement::create('li', $attributes, $content));
+ }
+}
diff --git a/library/Director/Web/Widget/ActivityLogInfo.php b/library/Director/Web/Widget/ActivityLogInfo.php
new file mode 100644
index 0000000..8454b26
--- /dev/null
+++ b/library/Director/Web/Widget/ActivityLogInfo.php
@@ -0,0 +1,634 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use gipfl\Json\JsonString;
+use Icinga\Module\Director\Objects\DirectorActivityLog;
+use ipl\Html\HtmlDocument;
+use ipl\Html\HtmlElement;
+use Icinga\Date\DateFormatter;
+use Icinga\Exception\ProgrammingError;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Forms\RestoreObjectForm;
+use Icinga\Module\Director\IcingaConfig\IcingaConfig;
+use Icinga\Module\Director\Objects\IcingaObject;
+use Icinga\Module\Director\Objects\IcingaService;
+use Icinga\Module\Director\Objects\IcingaServiceSet;
+use ipl\Html\Html;
+use gipfl\IcingaWeb2\Icon;
+use gipfl\IcingaWeb2\Link;
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Url;
+use gipfl\IcingaWeb2\Widget\NameValueTable;
+use gipfl\IcingaWeb2\Widget\Tabs;
+
+class ActivityLogInfo extends HtmlDocument
+{
+ use TranslationHelper;
+
+ protected $defaultTab;
+
+ /** @var Db */
+ protected $db;
+
+ /** @var string */
+ protected $type;
+
+ /** @var string */
+ protected $typeName;
+
+ /** @var string */
+ protected $name;
+
+ protected $entry;
+
+ protected $oldProperties;
+
+ protected $newProperties;
+
+ protected $oldObject;
+
+ /** @var Tabs */
+ protected $tabs;
+
+ /** @var int */
+ protected $id;
+
+ public function __construct(Db $db, $type = null, $name = null)
+ {
+ $this->db = $db;
+ if ($type !== null) {
+ $this->setType($type);
+ }
+ $this->name = $name;
+ }
+
+ public function setType($type)
+ {
+ $this->type = $type;
+ $this->typeName = $this->translate(
+ ucfirst(preg_replace('/^icinga_/', '', $type)) // really?
+ );
+
+ return $this;
+ }
+
+ /**
+ * @param Url $url
+ * @return HtmlElement
+ * @throws \Icinga\Exception\IcingaException
+ */
+ public function getPagination(Url $url)
+ {
+ /** @var Url $url */
+ $url = $url->without('checksum')->without('show');
+ $div = Html::tag('div', [
+ 'class' => 'pagination-control',
+ 'style' => 'float: right; width: 5em'
+ ]);
+
+ $ul = Html::tag('ul', ['class' => 'nav tab-nav']);
+ $li = Html::tag('li', ['class' => 'nav-item']);
+ $ul->add($li);
+ $neighbors = $this->getNeighbors();
+ $iconLeft = new Icon('angle-double-left');
+ $iconRight = new Icon('angle-double-right');
+ if ($neighbors->prev) {
+ $li->add(new Link($iconLeft, $url->with('id', $neighbors->prev)));
+ } else {
+ $li->add(Html::tag('span', ['class' => 'disabled'], $iconLeft));
+ }
+
+ $li = Html::tag('li', ['class' => 'nav-item']);
+ $ul->add($li);
+ if ($neighbors->next) {
+ $li->add(new Link($iconRight, $url->with('id', $neighbors->next)));
+ } else {
+ $li->add(Html::tag('span', ['class' => 'disabled'], $iconRight));
+ }
+
+ return $div->add($ul);
+ }
+
+ /**
+ * @param $tabName
+ * @return $this
+ * @throws \Icinga\Exception\Http\HttpNotFoundException
+ * @throws \Icinga\Exception\IcingaException
+ */
+ public function showTab($tabName)
+ {
+ if ($tabName === null) {
+ $tabName = $this->defaultTab;
+ }
+
+ $this->getTabs()->activate($tabName);
+ $this->add($this->getInfoTable());
+ if ($tabName === 'old') {
+ // $title = sprintf('%s former config', $this->entry->object_name);
+ $diffs = IcingaConfigDiff::getDiffs($this->oldConfig(), $this->emptyConfig());
+ } elseif ($tabName === 'new') {
+ // $title = sprintf('%s new config', $this->entry->object_name);
+ $diffs = IcingaConfigDiff::getDiffs($this->emptyConfig(), $this->newConfig());
+ } else {
+ $diffs = IcingaConfigDiff::getDiffs($this->oldConfig(), $this->newConfig());
+ }
+
+ $this->addDiffs($diffs);
+
+ return $this;
+ }
+
+ protected function emptyConfig()
+ {
+ return new IcingaConfig($this->db);
+ }
+
+ /**
+ * @param $diffs
+ * @throws \Icinga\Exception\IcingaException
+ */
+ protected function addDiffs($diffs)
+ {
+ foreach ($diffs as $file => $diff) {
+ $this->add(Html::tag('h3', null, $file))->add($diff);
+ }
+ }
+
+ /**
+ * @return RestoreObjectForm
+ * @throws \Icinga\Exception\IcingaException
+ */
+ protected function getRestoreForm()
+ {
+ return RestoreObjectForm::load()
+ ->setDb($this->db)
+ ->setObject($this->oldObject())
+ ->handleRequest();
+ }
+
+ public function setChecksum($checksum)
+ {
+ if ($checksum !== null) {
+ $this->entry = $this->db->fetchActivityLogEntry($checksum);
+ $this->id = (int) $this->entry->id;
+ }
+
+ return $this;
+ }
+
+ public function setId($id)
+ {
+ if ($id !== null) {
+ $this->entry = $this->db->fetchActivityLogEntryById($id);
+ $this->id = (int) $id;
+ }
+
+ return $this;
+ }
+
+ public function getNeighbors()
+ {
+ return $this->db->getActivitylogNeighbors(
+ $this->id,
+ $this->type,
+ $this->name
+ );
+ }
+
+ public function getCurrentObject()
+ {
+ return IcingaObject::loadByType(
+ $this->type,
+ $this->name,
+ $this->db
+ );
+ }
+
+ /**
+ * @return bool
+ * @deprecated No longer used?
+ */
+ public function objectStillExists()
+ {
+ return IcingaObject::existsByType(
+ $this->type,
+ $this->objectKey(),
+ $this->db
+ );
+ }
+
+ protected function oldProperties()
+ {
+ if ($this->oldProperties === null) {
+ if (property_exists($this->entry, 'old_properties')) {
+ $this->oldProperties = JsonString::decodeOptional($this->entry->old_properties);
+ }
+ if ($this->oldProperties === null) {
+ $this->oldProperties = new \stdClass;
+ }
+ }
+
+ return $this->oldProperties;
+ }
+
+ protected function newProperties()
+ {
+ if ($this->newProperties === null) {
+ if (property_exists($this->entry, 'new_properties')) {
+ $this->newProperties = JsonString::decodeOptional($this->entry->new_properties);
+ }
+ if ($this->newProperties === null) {
+ $this->newProperties = new \stdClass;
+ }
+ }
+
+ return $this->newProperties;
+ }
+
+ protected function getEntryProperty($key)
+ {
+ $entry = $this->entry;
+
+ if (property_exists($entry, $key)) {
+ return $entry->{$key};
+ } elseif (property_exists($this->newProperties(), $key)) {
+ return $this->newProperties->{$key};
+ } elseif (property_exists($this->oldProperties(), $key)) {
+ return $this->oldProperties->{$key};
+ } else {
+ return null;
+ }
+ }
+
+ protected function objectLinkParams()
+ {
+ $entry = $this->entry;
+
+ $params = ['name' => $entry->object_name];
+
+ if ($entry->object_type === 'icinga_service') {
+ if (($set = $this->getEntryProperty('service_set')) !== null) {
+ $params['set'] = $set;
+ return $params;
+ } elseif (($host = $this->getEntryProperty('host')) !== null) {
+ $params['host'] = $host;
+ return $params;
+ } else {
+ return $params;
+ }
+ } elseif ($entry->object_type === 'icinga_service_set') {
+ return $params;
+ } else {
+ return $params;
+ }
+ }
+
+ protected function getActionExtraHtml()
+ {
+ $entry = $this->entry;
+
+ $info = '';
+ $host = null;
+
+ if ($entry->object_type === 'icinga_service') {
+ if (($set = $this->getEntryProperty('service_set')) !== null) {
+ $info = Html::sprintf(
+ '%s "%s"',
+ $this->translate('on service set'),
+ Link::create(
+ $set,
+ 'director/serviceset',
+ ['name' => $set],
+ ['data-base-target' => '_next']
+ )
+ );
+ } else {
+ $host = $this->getEntryProperty('host');
+ }
+ } elseif ($entry->object_type === 'icinga_service_set') {
+ $host = $this->getEntryProperty('host');
+ }
+
+ if ($host !== null) {
+ $info = Html::sprintf(
+ '%s "%s"',
+ $this->translate('on host'),
+ Link::create(
+ $host,
+ 'director/host',
+ ['name' => $host],
+ ['data-base-target' => '_next']
+ )
+ );
+ }
+
+ return $info;
+ }
+
+ /**
+ * @return array
+ * @deprecated No longer used?
+ */
+ protected function objectKey()
+ {
+ $entry = $this->entry;
+ if ($entry->object_type === 'icinga_service' || $entry->object_type === 'icinga_service_set') {
+ // TODO: this is not correct. Activity needs to get (multi) key support
+ return ['name' => $entry->object_name];
+ }
+
+ return $entry->object_name;
+ }
+
+ /**
+ * @param Url|null $url
+ * @return Tabs
+ */
+ public function getTabs(Url $url = null)
+ {
+ if ($this->tabs === null) {
+ $this->tabs = $this->createTabs($url);
+ }
+
+ return $this->tabs;
+ }
+
+ /**
+ * @param Url $url
+ * @return Tabs
+ */
+ public function createTabs(Url $url)
+ {
+ $entry = $this->entry;
+ $tabs = new Tabs();
+ if ($entry->action_name === DirectorActivityLog::ACTION_MODIFY) {
+ $tabs->add('diff', [
+ 'label' => $this->translate('Diff'),
+ 'url' => $url->without('show')->with('id', $entry->id)
+ ]);
+
+ $this->defaultTab = 'diff';
+ }
+
+ if (in_array($entry->action_name, [
+ DirectorActivityLog::ACTION_CREATE,
+ DirectorActivityLog::ACTION_MODIFY,
+ ])) {
+ $tabs->add('new', [
+ 'label' => $this->translate('New object'),
+ 'url' => $url->with(['id' => $entry->id, 'show' => 'new'])
+ ]);
+
+ if ($this->defaultTab === null) {
+ $this->defaultTab = 'new';
+ }
+ }
+
+ if (in_array($entry->action_name, [
+ DirectorActivityLog::ACTION_DELETE,
+ DirectorActivityLog::ACTION_MODIFY,
+ ])) {
+ $tabs->add('old', [
+ 'label' => $this->translate('Former object'),
+ 'url' => $url->with(['id' => $entry->id, 'show' => 'old'])
+ ]);
+
+ if ($this->defaultTab === null) {
+ $this->defaultTab = 'old';
+ }
+ }
+
+ return $tabs;
+ }
+
+ /**
+ * @return IcingaObject
+ * @throws \Icinga\Exception\IcingaException
+ */
+ protected function oldObject()
+ {
+ if ($this->oldObject === null) {
+ $this->oldObject = $this->createObject(
+ $this->entry->object_type,
+ $this->entry->old_properties
+ );
+ }
+
+ return $this->oldObject;
+ }
+
+ /**
+ * @return IcingaObject
+ * @throws \Icinga\Exception\IcingaException
+ */
+ protected function newObject()
+ {
+ return $this->createObject(
+ $this->entry->object_type,
+ $this->entry->new_properties
+ );
+ }
+
+ protected function objectToConfig(IcingaObject $object)
+ {
+ if ($object instanceof IcingaService) {
+ return $this->previewService($object);
+ } else {
+ return $object->toSingleIcingaConfig();
+ }
+ }
+
+ protected function previewService(IcingaService $service)
+ {
+ if (($set = $service->get('service_set')) !== null) {
+ // simulate rendering of service in set
+ $set = IcingaServiceSet::load($set, $this->db);
+
+ $service->set('service_set_id', null);
+ if (($assign = $set->get('assign_filter')) !== null) {
+ $service->set('object_type', 'apply');
+ $service->set('assign_filter', $assign);
+ }
+ }
+
+ return $service->toSingleIcingaConfig();
+ }
+
+ /**
+ * @return IcingaConfig
+ * @throws \Icinga\Exception\IcingaException
+ */
+ protected function newConfig()
+ {
+ return $this->objectToConfig($this->newObject());
+ }
+
+ /**
+ * @return IcingaConfig
+ * @throws \Icinga\Exception\IcingaException
+ */
+ protected function oldConfig()
+ {
+ return $this->objectToConfig($this->oldObject());
+ }
+
+ protected function getLinkToObject()
+ {
+ // TODO: This logic is redundant and should be centralized
+ $entry = $this->entry;
+ $name = $entry->object_name;
+ $controller = preg_replace('/^icinga_/', '', $entry->object_type);
+
+ if ($controller === 'service_set') {
+ $controller = 'serviceset';
+ } elseif ($controller === 'scheduled_downtime') {
+ $controller = 'scheduled-downtime';
+ }
+
+ return Link::create(
+ $name,
+ 'director/' . $controller,
+ $this->objectLinkParams(),
+ ['data-base-target' => '_next']
+ );
+ }
+
+ /**
+ * @return NameValueTable
+ * @throws \Icinga\Exception\IcingaException
+ */
+ public function getInfoTable()
+ {
+ $entry = $this->entry;
+ $table = new NameValueTable();
+ $table->addNameValuePairs([
+ $this->translate('Author') => $entry->author,
+ $this->translate('Date') => DateFormatter::formatDateTime(
+ $entry->change_time_ts
+ ),
+
+ ]);
+ if (null === $this->name) {
+ $table->addNameValueRow(
+ $this->translate('Action'),
+ Html::sprintf(
+ '%s %s "%s" %s',
+ $entry->action_name,
+ $entry->object_type,
+ $this->getLinkToObject(),
+ $this->getActionExtraHtml()
+ )
+ );
+ } else {
+ $table->addNameValueRow(
+ $this->translate('Action'),
+ $entry->action_name
+ );
+ }
+
+ if ($comment = $this->getOptionalRangeComment()) {
+ $table->addNameValueRow(
+ $this->translate('Remark'),
+ $comment
+ );
+ }
+
+ if ($this->hasBeenEnabled()) {
+ $table->addNameValueRow(
+ $this->translate('Rendering'),
+ $this->translate('This object has been enabled')
+ );
+ } elseif ($this->hasBeenDisabled()) {
+ $table->addNameValueRow(
+ $this->translate('Rendering'),
+ $this->translate('This object has been disabled')
+ );
+ }
+
+ $table->addNameValueRow(
+ $this->translate('Checksum'),
+ $entry->checksum
+ );
+ if ($this->entry->old_properties) {
+ $table->addNameValueRow(
+ $this->translate('Actions'),
+ $this->getRestoreForm()
+ );
+ }
+
+ return $table;
+ }
+
+ public function hasBeenEnabled()
+ {
+ return false;
+ }
+
+ public function hasBeenDisabled()
+ {
+ return false;
+ }
+
+ /**
+ * @return string
+ * @throws ProgrammingError
+ */
+ public function getTitle()
+ {
+ switch ($this->entry->action_name) {
+ case DirectorActivityLog::ACTION_CREATE:
+ $msg = $this->translate('%s "%s" has been created');
+ break;
+ case DirectorActivityLog::ACTION_DELETE:
+ $msg = $this->translate('%s "%s" has been deleted');
+ break;
+ case DirectorActivityLog::ACTION_MODIFY:
+ $msg = $this->translate('%s "%s" has been modified');
+ break;
+ default:
+ throw new ProgrammingError(
+ 'Unable to deal with "%s" activity',
+ $this->entry->action_name
+ );
+ }
+
+ return sprintf($msg, $this->typeName, $this->entry->object_name);
+ }
+
+ protected function getOptionalRangeComment()
+ {
+ if ($this->id) {
+ $db = $this->db->getDbAdapter();
+ return $db->fetchOne(
+ $db->select()
+ ->from('director_activity_log_remark', 'remark')
+ ->where('first_related_activity <= ?', $this->id)
+ ->where('last_related_activity >= ?', $this->id)
+ );
+ }
+
+ return null;
+ }
+
+ /**
+ * @param $type
+ * @param $props
+ * @return IcingaObject
+ * @throws \Icinga\Exception\IcingaException
+ */
+ protected function createObject($type, $props)
+ {
+ $props = json_decode($props);
+ $newProps = ['object_name' => $props->object_name];
+ if (property_exists($props, 'object_type')) {
+ $newProps['object_type'] = $props->object_type;
+ }
+
+ return IcingaObject::createByType(
+ $type,
+ $newProps,
+ $this->db
+ )->setProperties((array) $props);
+ }
+}
diff --git a/library/Director/Web/Widget/AdditionalTableActions.php b/library/Director/Web/Widget/AdditionalTableActions.php
new file mode 100644
index 0000000..978f399
--- /dev/null
+++ b/library/Director/Web/Widget/AdditionalTableActions.php
@@ -0,0 +1,158 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use ipl\Html\Html;
+use ipl\Html\HtmlDocument;
+use gipfl\IcingaWeb2\Icon;
+use gipfl\IcingaWeb2\Link;
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Table\ZfQueryBasedTable;
+use gipfl\IcingaWeb2\Url;
+use Icinga\Authentication\Auth;
+use Icinga\Module\Director\Web\Table\FilterableByUsage;
+
+class AdditionalTableActions
+{
+ use TranslationHelper;
+
+ /** @var Auth */
+ protected $auth;
+
+ /** @var Url */
+ protected $url;
+
+ /** @var ZfQueryBasedTable */
+ protected $table;
+
+ public function __construct(Auth $auth, Url $url, ZfQueryBasedTable $table)
+ {
+ $this->auth = $auth;
+ $this->url = $url;
+ $this->table = $table;
+ }
+
+ public function appendTo(HtmlDocument $parent)
+ {
+ $links = [];
+ if ($this->hasPermission('director/admin')) {
+ $links[] = $this->createDownloadJsonLink();
+ }
+ if ($this->hasPermission('director/showsql')) {
+ $links[] = $this->createShowSqlToggle();
+ }
+
+ if ($this->table instanceof FilterableByUsage) {
+ $parent->add($this->showUsageFilter($this->table));
+ }
+
+ if (! empty($links)) {
+ $parent->add($this->moreOptions($links));
+ }
+
+ return $this;
+ }
+
+ protected function createDownloadJsonLink()
+ {
+ return Link::create(
+ $this->translate('Download as JSON'),
+ $this->url->with('format', 'json'),
+ null,
+ ['target' => '_blank']
+ );
+ }
+
+ protected function createShowSqlToggle()
+ {
+ if ($this->url->getParam('format') === 'sql') {
+ $link = Link::create(
+ $this->translate('Hide SQL'),
+ $this->url->without('format')
+ );
+ } else {
+ $link = Link::create(
+ $this->translate('Show SQL'),
+ $this->url->with('format', 'sql')
+ );
+ }
+
+ return $link;
+ }
+
+ protected function showUsageFilter(FilterableByUsage $table)
+ {
+ $active = $this->url->getParam('usage', 'all');
+ $links = [
+ Link::create($this->translate('all'), $this->url->without('usage')),
+ Link::create($this->translate('used'), $this->url->with('usage', 'used')),
+ Link::create($this->translate('unused'), $this->url->with('usage', 'unused')),
+ ];
+
+ if ($active === 'used') {
+ $table->showOnlyUsed();
+ } elseif ($active === 'unused') {
+ $table->showOnlyUnUsed();
+ }
+
+ $options = $this->ul(
+ $this->li([
+ Link::create(
+ sprintf($this->translate('Usage (%s)'), $active),
+ '#',
+ null,
+ [
+ 'class' => 'icon-sitemap'
+ ]
+ ),
+ $subUl = Html::tag('ul')
+ ]),
+ ['class' => 'nav']
+ );
+
+ foreach ($links as $link) {
+ $subUl->add($this->li($link));
+ }
+
+ return $options;
+ }
+
+ protected function moreOptions($links)
+ {
+ $options = $this->ul(
+ $this->li([
+ // TODO: extend link for dropdown-toggle from Web 2, doesn't
+ // seem to work: [..], null, ['class' => 'dropdown-toggle']
+ Link::create(Icon::create('down-open'), '#'),
+ $subUl = Html::tag('ul')
+ ]),
+ ['class' => 'nav']
+ );
+
+ foreach ($links as $link) {
+ $subUl->add($this->li($link));
+ }
+
+ return $options;
+ }
+
+ protected function ulLi($content)
+ {
+ return $this->ul($this->li($content));
+ }
+
+ protected function ul($content, $attributes = null)
+ {
+ return Html::tag('ul', $attributes, $content);
+ }
+
+ protected function li($content)
+ {
+ return Html::tag('li', null, $content);
+ }
+
+ protected function hasPermission($permission)
+ {
+ return $this->auth->hasPermission($permission);
+ }
+}
diff --git a/library/Director/Web/Widget/BackgroundDaemonDetails.php b/library/Director/Web/Widget/BackgroundDaemonDetails.php
new file mode 100644
index 0000000..b4c33dd
--- /dev/null
+++ b/library/Director/Web/Widget/BackgroundDaemonDetails.php
@@ -0,0 +1,131 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use gipfl\IcingaWeb2\Icon;
+use gipfl\IcingaWeb2\Widget\NameValueTable;
+use gipfl\Translation\TranslationHelper;
+use gipfl\Web\Widget\Hint;
+use Icinga\Date\DateFormatter;
+use Icinga\Module\Director\Daemon\RunningDaemonInfo;
+use Icinga\Util\Format;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Html\Table;
+
+class BackgroundDaemonDetails extends BaseHtmlElement
+{
+ use TranslationHelper;
+
+ protected $tag = 'div';
+
+ /** @var RunningDaemonInfo */
+ protected $info;
+
+ /** @var \stdClass TODO: get rid of this */
+ protected $daemon;
+
+ public function __construct(RunningDaemonInfo $info, $daemon)
+ {
+ $this->info = $info;
+ $this->daemon = $daemon;
+ }
+
+ protected function assemble()
+ {
+ $info = $this->info;
+ if ($info->hasBeenStopped()) {
+ $this->add(Hint::error(Html::sprintf(
+ $this->translate(
+ 'Daemon has been stopped %s, was running with PID %s as %s@%s'
+ ),
+ // $info->getHexUuid(),
+ $this->timeAgo($info->getTimestampStopped() / 1000),
+ Html::tag('strong', (string) $info->getPid()),
+ Html::tag('strong', $info->getUsername()),
+ Html::tag('strong', $info->getFqdn())
+ )));
+ } elseif ($info->isOutdated()) {
+ $this->add(Hint::error(Html::sprintf(
+ $this->translate(
+ 'Daemon keep-alive is outdated, was last seen running with PID %s as %s@%s %s'
+ ),
+ // $info->getHexUuid(),
+ Html::tag('strong', (string) $info->getPid()),
+ Html::tag('strong', $info->getUsername()),
+ Html::tag('strong', $info->getFqdn()),
+ $this->timeAgo($info->getLastUpdate() / 1000)
+ )));
+ } else {
+ $this->add(Hint::ok(Html::sprintf(
+ $this->translate(
+ 'Daemon is running with PID %s as %s@%s, last refresh happened %s'
+ ),
+ // $info->getHexUuid(),
+ Html::tag('strong', (string)$info->getPid()),
+ Html::tag('strong', $info->getUsername()),
+ Html::tag('strong', $info->getFqdn()),
+ $this->timeAgo($info->getLastUpdate() / 1000)
+ )));
+ $details = new NameValueTable();
+ $details->addNameValuePairs([
+ $this->translate('Startup Time') => DateFormatter::formatDateTime($info->getTimestampStarted() / 1000),
+ $this->translate('PID') => $info->getPid(),
+ $this->translate('Username') => $info->getUsername(),
+ $this->translate('FQDN') => $info->getFqdn(),
+ $this->translate('Running with systemd') => $info->isRunningWithSystemd()
+ ? $this->translate('yes')
+ : $this->translate('no'),
+ $this->translate('Binary') => $info->getBinaryPath()
+ . ($info->binaryRealpathDiffers() ? ' -> ' . $info->getBinaryRealpath() : ''),
+ $this->translate('PHP Binary') => $info->getPhpBinaryPath()
+ . ($info->phpBinaryRealpathDiffers() ? ' -> ' . $info->getPhpBinaryRealpath() : ''),
+ $this->translate('PHP Version') => $info->getPhpVersion(),
+ $this->translate('PHP Integer') => $info->has64bitIntegers()
+ ? '64bit'
+ : Html::sprintf(
+ '%sbit (%s)',
+ $info->getPhpIntegerSize() * 8,
+ Html::tag('span', ['class' => 'error'], $this->translate('unsupported'))
+ ),
+ ]);
+ $this->add($details);
+
+ $this->add(Html::tag('h2', $this->translate('Process List')));
+ if (\is_string($this->daemon->process_info)) {
+ // from DB:
+ $processes = \json_decode($this->daemon->process_info);
+ } else {
+ // via RPC:
+ $processes = $this->daemon->process_info;
+ }
+ $table = new Table();
+ $table->add(Html::tag('thead', Html::tag('tr', Html::wrapEach([
+ 'PID',
+ 'Command',
+ 'Memory'
+ ], 'th'))));
+ $table->setAttribute('class', 'common-table');
+ foreach ($processes as $pid => $process) {
+ $table->add($table::row([
+ [
+ Icon::create($process->running ? 'ok' : 'warning-empty'),
+ ' ',
+ $pid
+ ],
+ Html::tag('pre', $process->command),
+ $process->memory === false ? 'n/a' : Format::bytes($process->memory->rss)
+ ]));
+ }
+ $this->add($table);
+ }
+ }
+
+ protected function timeAgo($time)
+ {
+ return Html::tag('span', [
+ 'class' => 'time-ago',
+ 'title' => DateFormatter::formatDateTime($time)
+ ], DateFormatter::timeAgo($time));
+ }
+}
diff --git a/library/Director/Web/Widget/BranchedObjectHint.php b/library/Director/Web/Widget/BranchedObjectHint.php
new file mode 100644
index 0000000..ec16094
--- /dev/null
+++ b/library/Director/Web/Widget/BranchedObjectHint.php
@@ -0,0 +1,69 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use gipfl\Translation\TranslationHelper;
+use gipfl\Web\Widget\Hint;
+use Icinga\Authentication\Auth;
+use Icinga\Exception\NotFoundError;
+use Icinga\Module\Director\Db\Branch\Branch;
+use Icinga\Module\Director\Db\Branch\BranchedObject;
+use ipl\Html\Html;
+use ipl\Html\HtmlDocument;
+
+class BranchedObjectHint extends HtmlDocument
+{
+ use TranslationHelper;
+
+ public function __construct(Branch $branch, Auth $auth, BranchedObject $object = null)
+ {
+ if (! $branch->isBranch()) {
+ return;
+ }
+ $hook = Branch::requireHook();
+
+ $name = $branch->getName();
+ if (substr($name, 0, 1) === '/') {
+ $label = $this->translate('this configuration branch');
+ } else {
+ $label = $name;
+ }
+ $link = $hook->linkToBranch($branch, $auth, $label);
+ if ($object === null) {
+ $this->add(Hint::info(Html::sprintf($this->translate(
+ 'This object will be created in %s. It will not be part of any deployment'
+ . ' unless being merged'
+ ), $link)));
+ return;
+ }
+
+ if (! $object->hasBeenTouchedByBranch()) {
+ $this->add(Hint::info(Html::sprintf($this->translate(
+ 'Your changes will be stored in %s. The\'ll not be part of any deployment'
+ . ' unless being merged'
+ ), $link)));
+ return;
+ }
+
+ if ($object->hasBeenDeletedByBranch()) {
+ throw new NotFoundError('No such object available');
+ // Alternative, requires hiding other actions:
+ // $this->add(Hint::info(Html::sprintf(
+ // $this->translate('This object has been deleted in %s'),
+ // $link
+ // )));
+ } elseif ($object->hasBeenCreatedByBranch()) {
+ $this->add(Hint::info(Html::sprintf(
+ $this->translate('This object has been created in %s'),
+ $link
+ )));
+ } else {
+ $this->add(Hint::info(Html::sprintf(
+ $this->translate('This object has modifications visible only in %s'),
+ // TODO: Also link to object modifications
+ // $hook->linkToBranchedObject($this->translate('modifications'), $branch, $object, $auth),
+ $link
+ )));
+ }
+ }
+}
diff --git a/library/Director/Web/Widget/BranchedObjectsHint.php b/library/Director/Web/Widget/BranchedObjectsHint.php
new file mode 100644
index 0000000..d689178
--- /dev/null
+++ b/library/Director/Web/Widget/BranchedObjectsHint.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use gipfl\Translation\TranslationHelper;
+use gipfl\Web\Widget\Hint;
+use Icinga\Authentication\Auth;
+use Icinga\Module\Director\Db\Branch\Branch;
+use ipl\Html\Html;
+use ipl\Html\HtmlDocument;
+
+class BranchedObjectsHint extends HtmlDocument
+{
+ use TranslationHelper;
+
+ public function __construct(Branch $branch, Auth $auth)
+ {
+ if (! $branch->isBranch()) {
+ return;
+ }
+ $hook = Branch::requireHook();
+ $this->add(Hint::info(Html::sprintf(
+ $this->translate('Showing a branched view, with potential changes being visible only in this %s'),
+ $hook->linkToBranch($branch, $auth, $this->translate('configuration branch'))
+ )));
+ }
+}
diff --git a/library/Director/Web/Widget/Daemon/BackgroundDaemonState.php b/library/Director/Web/Widget/Daemon/BackgroundDaemonState.php
new file mode 100644
index 0000000..03e76b2
--- /dev/null
+++ b/library/Director/Web/Widget/Daemon/BackgroundDaemonState.php
@@ -0,0 +1,57 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget\Daemon;
+
+use Icinga\Module\Director\Daemon\RunningDaemonInfo;
+use Icinga\Module\Director\Db;
+
+class BackgroundDaemonState
+{
+ protected $db;
+
+ /** @var RunningDaemonInfo[] */
+ protected $instances;
+
+ public function __construct(Db $db)
+ {
+ $this->db = $db;
+ }
+
+ public function isRunning()
+ {
+ foreach ($this->getInstances() as $instance) {
+ if ($instance->isRunning()) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ protected function getInstances()
+ {
+ if ($this->instances === null) {
+ $this->instances = $this->fetchInfo();
+ }
+
+ return $this->instances;
+ }
+
+ /**
+ * @return RunningDaemonInfo[]
+ */
+ protected function fetchInfo()
+ {
+ $db = $this->db->getDbAdapter();
+ $daemons = $db->fetchAll(
+ $db->select()->from('director_daemon_info')->order('fqdn')->order('username')->order('pid')
+ );
+
+ $result = [];
+ foreach ($daemons as $info) {
+ $result[] = new RunningDaemonInfo($info);
+ }
+
+ return $result;
+ }
+}
diff --git a/library/Director/Web/Widget/DeployedConfigInfoHeader.php b/library/Director/Web/Widget/DeployedConfigInfoHeader.php
new file mode 100644
index 0000000..0e841f3
--- /dev/null
+++ b/library/Director/Web/Widget/DeployedConfigInfoHeader.php
@@ -0,0 +1,101 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use Icinga\Module\Director\Db\Branch\Branch;
+use ipl\Html\HtmlDocument;
+use Icinga\Module\Director\Core\DeploymentApiInterface;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Forms\DeployConfigForm;
+use Icinga\Module\Director\IcingaConfig\IcingaConfig;
+use ipl\Html\Html;
+use gipfl\IcingaWeb2\Link;
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Widget\NameValueTable;
+
+class DeployedConfigInfoHeader extends HtmlDocument
+{
+ use TranslationHelper;
+
+ /** @var IcingaConfig */
+ protected $config;
+
+ /** @var int */
+ protected $deploymentId;
+
+ /** @var Db */
+ protected $db;
+
+ /** @var DeploymentApiInterface */
+ protected $api;
+
+ /** @var Branch */
+ protected $branch;
+
+ public function __construct(
+ IcingaConfig $config,
+ Db $db,
+ DeploymentApiInterface $api,
+ Branch $branch,
+ $deploymentId = null
+ ) {
+ $this->config = $config;
+ $this->db = $db;
+ $this->api = $api;
+ $this->branch = $branch;
+ if ($deploymentId) {
+ $this->deploymentId = (int) $deploymentId;
+ }
+ }
+
+ /**
+ * @throws \Icinga\Exception\IcingaException
+ * @throws \Zend_Form_Exception
+ */
+ protected function assemble()
+ {
+ $config = $this->config;
+ if ($this->branch->isBranch()) {
+ $deployForm = null;
+ } else {
+ $deployForm = DeployConfigForm::load()
+ ->setDb($this->db)
+ ->setApi($this->api)
+ ->setChecksum($config->getHexChecksum())
+ ->setDeploymentId($this->deploymentId)
+ ->setAttrib('class', 'inline')
+ ->handleRequest();
+ }
+
+ $links = new NameValueTable();
+ $links->addNameValueRow(
+ $this->translate('Actions'),
+ [
+ $deployForm,
+ Html::tag('br'),
+ Link::create(
+ $this->translate('Last related activity'),
+ 'director/config/activity',
+ ['checksum' => $config->getLastActivityHexChecksum()],
+ ['class' => 'icon-clock', 'data-base-target' => '_next']
+ ),
+ Html::tag('br'),
+ Link::create(
+ $this->translate('Diff with other config'),
+ 'director/config/diff',
+ ['left' => $config->getHexChecksum()],
+ ['class' => 'icon-flapping', 'data-base-target' => '_self']
+ )
+ ]
+ )->addNameValueRow(
+ $this->translate('Statistics'),
+ sprintf(
+ $this->translate('%d files rendered in %0.2fs'),
+ count($config->getFiles()),
+ $config->getDuration() / 1000
+ )
+ );
+
+ $this->add($links);
+ }
+}
diff --git a/library/Director/Web/Widget/DeploymentInfo.php b/library/Director/Web/Widget/DeploymentInfo.php
new file mode 100644
index 0000000..110200f
--- /dev/null
+++ b/library/Director/Web/Widget/DeploymentInfo.php
@@ -0,0 +1,169 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use ipl\Html\HtmlDocument;
+use Icinga\Authentication\Auth;
+use Icinga\Module\Director\IcingaConfig\IcingaConfig;
+use Icinga\Module\Director\Objects\DirectorDeploymentLog;
+use Icinga\Module\Director\StartupLogRenderer;
+use Icinga\Util\Format;
+use Icinga\Web\Request;
+use ipl\Html\Html;
+use gipfl\IcingaWeb2\Icon;
+use gipfl\IcingaWeb2\Link;
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Widget\NameValueTable;
+use gipfl\IcingaWeb2\Widget\Tabs;
+
+class DeploymentInfo extends HtmlDocument
+{
+ use TranslationHelper;
+
+ /** @var DirectorDeploymentLog */
+ protected $deployment;
+
+ /** @var IcingaConfig */
+ protected $config;
+
+ /**
+ * DeploymentInfo constructor.
+ * @param DirectorDeploymentLog $deployment
+ */
+ public function __construct(DirectorDeploymentLog $deployment)
+ {
+ $this->deployment = $deployment;
+ if ($deployment->get('config_checksum') !== null) {
+ $this->config = IcingaConfig::load(
+ $deployment->get('config_checksum'),
+ $deployment->getConnection()
+ );
+ }
+ }
+
+ /**
+ * @param Auth $auth
+ * @param Request $request
+ * @return Tabs
+ */
+ public function getTabs(Auth $auth, Request $request)
+ {
+ $dep = $this->deployment;
+ $tabs = new Tabs();
+ $tabs->add('deployment', array(
+ 'label' => $this->translate('Deployment'),
+ 'url' => $request->getUrl()
+ ))->activate('deployment');
+
+ if ($dep->config_checksum !== null && $auth->hasPermission('director/showconfig')) {
+ $tabs->add('config', array(
+ 'label' => $this->translate('Config'),
+ 'url' => 'director/config/files',
+ 'urlParams' => array(
+ 'checksum' => $this->config->getHexChecksum(),
+ 'deployment_id' => $dep->id
+ )
+ ));
+ }
+
+ return $tabs;
+ }
+
+ protected function createInfoTable()
+ {
+ $dep = $this->deployment;
+ $table = new NameValueTable();
+ $table->addNameValuePairs([
+ $this->translate('Deployment time') => $dep->start_time,
+ $this->translate('Sent to') => $dep->peer_identity,
+ ]);
+ if ($this->config !== null) {
+ $table->addNameValuePairs([
+ $this->translate('Configuration') => $this->getConfigDetails(),
+ $this->translate('Duration') => $this->getDurationInfo(),
+ ]);
+ }
+ $table->addNameValuePairs([
+ $this->translate('Stage name') => $dep->stage_name,
+ $this->translate('Startup') => $this->getStartupInfo()
+ ]);
+
+ return $table;
+ }
+
+ protected function getDurationInfo()
+ {
+ return sprintf(
+ $this->translate('Rendered in %0.2fs, deployed in %0.2fs'),
+ $this->config->getDuration() / 1000,
+ $this->deployment->duration_dump / 1000
+ );
+ }
+
+ protected function getConfigDetails()
+ {
+ $cfg = $this->config;
+ $dep = $this->deployment;
+
+ return [
+ Link::create(
+ sprintf($this->translate('%d files'), $cfg->getFileCount()),
+ 'director/config/files',
+ [
+ 'checksum' => $cfg->getHexChecksum(),
+ 'deployment_id' => $dep->id
+ ]
+ ),
+ ', ',
+ sprintf(
+ $this->translate('%d objects, %d templates, %d apply rules'),
+ $cfg->getObjectCount(),
+ $cfg->getTemplateCount(),
+ $cfg->getApplyCount()
+ ),
+ ', ',
+ Format::bytes($cfg->getSize())
+ ];
+ }
+
+ protected function getStartupInfo()
+ {
+ $dep = $this->deployment;
+ if ($dep->startup_succeeded === null) {
+ if ($dep->stage_collected === null) {
+ return [$this->translate('Unknown, still waiting for config check outcome'), new Icon('spinner')];
+ } else {
+ return [$this->translate('Unknown, failed to collect related information'), new Icon('help')];
+ }
+ } elseif ($dep->startup_succeeded === 'y') {
+ return $this->colored('green', [$this->translate('Succeeded'), new Icon('ok')]);
+ } else {
+ return $this->colored('red', [$this->translate('Failed'), new Icon('cancel')]);
+ }
+ }
+
+ protected function colored($color, array $content)
+ {
+ return Html::tag('div', ['style' => "color: $color;"], $content)->setSeparator(' ');
+ }
+
+ public function render()
+ {
+ $this->add($this->createInfoTable());
+ if ($this->deployment->get('startup_succeeded') !== null) {
+ $this->addStartupLog();
+ }
+
+ return parent::render();
+ }
+
+ protected function addStartupLog()
+ {
+ $this->add(Html::tag('h2', null, $this->translate('Startup Log')));
+ $this->add(
+ Html::tag('pre', [
+ 'class' => 'logfile'
+ ], new StartupLogRenderer($this->deployment))
+ );
+ }
+}
diff --git a/library/Director/Web/Widget/Documentation.php b/library/Director/Web/Widget/Documentation.php
new file mode 100644
index 0000000..8665e30
--- /dev/null
+++ b/library/Director/Web/Widget/Documentation.php
@@ -0,0 +1,97 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use gipfl\IcingaWeb2\Link;
+use gipfl\Translation\TranslationHelper;
+use Icinga\Application\ApplicationBootstrap;
+use Icinga\Application\Icinga;
+use Icinga\Authentication\Auth;
+use ipl\Html\Html;
+
+class Documentation
+{
+ use TranslationHelper;
+
+ /** @var ApplicationBootstrap */
+ protected $app;
+
+ /** @var Auth */
+ protected $auth;
+
+ public function __construct(ApplicationBootstrap $app, Auth $auth)
+ {
+ $this->app = $app;
+ $this->auth = $auth;
+ }
+
+ public static function link($label, $module, $chapter, $title = null)
+ {
+ $doc = new static(Icinga::app(), Auth::getInstance());
+ return $doc->getModuleLink($label, $module, $chapter, $title);
+ }
+
+ public function getModuleLink($label, $module, $chapter, $title = null)
+ {
+ if ($title !== null) {
+ $title = sprintf(
+ $this->translate('Click to read our documentation: %s'),
+ $title
+ );
+ }
+ $linkToGitHub = false;
+ $baseParams = [
+ 'class' => 'icon-book',
+ 'title' => $title,
+ ];
+ if ($this->hasAccessToDocumentationModule()) {
+ return Link::create(
+ $label,
+ $this->getDirectorDocumentationUrl($chapter),
+ null,
+ ['data-base-target' => '_next'] + $baseParams
+ );
+ }
+
+ $baseParams['target'] = '_blank';
+ if ($linkToGitHub) {
+ return Html::tag('a', [
+ 'href' => $this->githubDocumentationUrl($module, $chapter),
+ ] + $baseParams, $label);
+ }
+
+ return Html::tag('a', [
+ 'href' => $this->icingaDocumentationUrl($module, $chapter),
+ ] + $baseParams, $label);
+ }
+
+ protected function getDirectorDocumentationUrl($chapter)
+ {
+ return 'doc/module/director/chapter/'
+ . \preg_replace('/^\d+-/', '', \rawurlencode($chapter));
+ }
+
+ protected function githubDocumentationUrl($module, $chapter)
+ {
+ return sprintf(
+ "https://github.com/Icinga/icingaweb2-module-%s/blob/master/doc/%s.md",
+ \rawurlencode($module),
+ \rawurlencode($chapter)
+ );
+ }
+
+ protected function icingaDocumentationUrl($module, $chapter)
+ {
+ return sprintf(
+ 'https://icinga.com/docs/%s/latest/doc/%s/',
+ \rawurlencode($module),
+ \rawurlencode($chapter)
+ );
+ }
+
+ protected function hasAccessToDocumentationModule()
+ {
+ return $this->app->getModuleManager()->hasLoaded('doc')
+ && $this->auth->hasPermission('module/doc');
+ }
+}
diff --git a/library/Director/Web/Widget/HealthCheckPluginOutput.php b/library/Director/Web/Widget/HealthCheckPluginOutput.php
new file mode 100644
index 0000000..83ac102
--- /dev/null
+++ b/library/Director/Web/Widget/HealthCheckPluginOutput.php
@@ -0,0 +1,94 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use ipl\Html\Html;
+use ipl\Html\HtmlDocument;
+use ipl\Html\HtmlString;
+use gipfl\Translation\TranslationHelper;
+use Icinga\Module\Director\CheckPlugin\PluginState;
+use Icinga\Module\Director\Health;
+
+class HealthCheckPluginOutput extends HtmlDocument
+{
+ use TranslationHelper;
+
+ /** @var Health */
+ protected $health;
+
+ /** @var PluginState */
+ protected $state;
+
+ public function __construct(Health $health)
+ {
+ $this->state = new PluginState('OK');
+ $this->health = $health;
+ $this->process();
+ }
+
+ protected function process()
+ {
+ $checks = $this->health->getAllChecks();
+
+ foreach ($checks as $check) {
+ $this->add([
+ $title = Html::tag('h1', $check->getName()),
+ $ul = Html::tag('ul', ['class' => 'health-check-result'])
+ ]);
+
+ $problems = $check->getProblemSummary();
+ if (! empty($problems)) {
+ $badges = Html::tag('span', ['class' => 'title-badges']);
+ foreach ($problems as $state => $count) {
+ $badges->add(Html::tag('span', [
+ 'class' => ['badge', 'state-' . strtolower($state)],
+ 'title' => sprintf(
+ $this->translate('%s: %d'),
+ $this->translate($state),
+ $count
+ ),
+ ], $count));
+ }
+ $title->add($badges);
+ }
+
+ foreach ($check->getResults() as $result) {
+ $state = $result->getState()->getName();
+ $ul->add(Html::tag('li', [
+ 'class' => 'state state-' . strtolower($state)
+ ], $this->highlightNames($result->getOutput()))->setSeparator(' '));
+ }
+ $this->state->raise($check->getState());
+ }
+ }
+
+ public function getState()
+ {
+ return $this->state;
+ }
+
+ protected function colorizeState($state)
+ {
+ return Html::tag('span', ['class' => 'badge state-' . strtolower($state)], $state);
+ }
+
+ protected function highlightNames($string)
+ {
+ $string = Html::escape($string);
+ return new HtmlString(preg_replace_callback(
+ "/'([^']+)'/",
+ [$this, 'highlightName'],
+ $string
+ ));
+ }
+
+ protected function highlightName($match)
+ {
+ return '"' . Html::tag('strong', $match[1]) . '"';
+ }
+
+ protected function getColorized($match)
+ {
+ return $this->colorizeState($match[1]);
+ }
+}
diff --git a/library/Director/Web/Widget/IcingaConfigDiff.php b/library/Director/Web/Widget/IcingaConfigDiff.php
new file mode 100644
index 0000000..800f1d9
--- /dev/null
+++ b/library/Director/Web/Widget/IcingaConfigDiff.php
@@ -0,0 +1,58 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use gipfl\Diff\HtmlRenderer\SideBySideDiff;
+use gipfl\Diff\PhpDiff;
+use Icinga\Module\Director\IcingaConfig\IcingaConfig;
+use ipl\Html\Html;
+use ipl\Html\HtmlDocument;
+use ipl\Html\ValidHtml;
+
+class IcingaConfigDiff extends HtmlDocument
+{
+ public function __construct(IcingaConfig $left, IcingaConfig $right)
+ {
+ foreach (static::getDiffs($left, $right) as $filename => $diff) {
+ $this->add([
+ Html::tag('h3', $filename),
+ $diff
+ ]);
+ }
+ }
+
+ /**
+ * @param IcingaConfig $oldConfig
+ * @param IcingaConfig $newConfig
+ * @return ValidHtml[]
+ */
+ public static function getDiffs(IcingaConfig $oldConfig, IcingaConfig $newConfig)
+ {
+ $oldFileNames = $oldConfig->getFileNames();
+ $newFileNames = $newConfig->getFileNames();
+
+ $fileNames = array_merge($oldFileNames, $newFileNames);
+
+ $diffs = [];
+ foreach ($fileNames as $filename) {
+ if (in_array($filename, $oldFileNames)) {
+ $left = $oldConfig->getFile($filename)->getContent();
+ } else {
+ $left = '';
+ }
+
+ if (in_array($filename, $newFileNames)) {
+ $right = $newConfig->getFile($filename)->getContent();
+ } else {
+ $right = '';
+ }
+ if ($left === $right) {
+ continue;
+ }
+
+ $diffs[$filename] = new SideBySideDiff(new PhpDiff($left, $right));
+ }
+
+ return $diffs;
+ }
+}
diff --git a/library/Director/Web/Widget/IcingaObjectInspection.php b/library/Director/Web/Widget/IcingaObjectInspection.php
new file mode 100644
index 0000000..61f3567
--- /dev/null
+++ b/library/Director/Web/Widget/IcingaObjectInspection.php
@@ -0,0 +1,254 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use gipfl\IcingaWeb2\Link;
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Widget\NameValueTable;
+use Icinga\Date\DateFormatter;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\PlainObjectRenderer;
+use Icinga\Module\Director\Web\Table\DbHelper;
+use stdClass;
+
+class IcingaObjectInspection extends BaseHtmlElement
+{
+ use DbHelper;
+ use TranslationHelper;
+
+ protected $tag = 'div';
+
+ /** @var Db */
+ protected $db;
+
+ /** @var stdClass */
+ protected $object;
+
+ public function __construct(stdClass $object, Db $db)
+ {
+ $this->object = $object;
+ $this->db = $db;
+ }
+
+ /**
+ * @throws \Icinga\Exception\IcingaException
+ */
+ protected function assemble()
+ {
+ $attrs = $this->object->attrs;
+ if (isset($attrs->source_location)) {
+ $this->renderSourceLocation($attrs->source_location);
+ }
+ if (isset($attrs->last_check_result)) {
+ $this->renderLastCheckResult($attrs->last_check_result);
+ }
+
+ $this->renderObjectAttributes($attrs);
+ // $this->add(Html::tag('pre', null, PlainObjectRenderer::render($this->object)));
+ }
+
+ /**
+ * @param $result
+ * @throws \Icinga\Exception\IcingaException
+ */
+ protected function renderLastCheckResult($result)
+ {
+ $this->add(Html::tag('h2', null, $this->translate('Last Check Result')));
+ $this->renderCheckResultDetails($result);
+ if (property_exists($result, 'command')) {
+ $this->renderExecutedCommand($result->command);
+ }
+ }
+
+ /**
+ * @param array|string $command
+ *
+ * @throws \Icinga\Exception\IcingaException
+ */
+ protected function renderExecutedCommand($command)
+ {
+ if (is_array($command)) {
+ $command = implode(' ', array_map('escapeshellarg', $command));
+ }
+ $this->add([
+ Html::tag('h3', null, 'Executed Command'),
+ $this->formatConsole($command)
+ ]);
+ }
+
+ protected function renderCheckResultDetails($result)
+ {
+ }
+
+ /**
+ * @param $attrs
+ * @throws \Icinga\Exception\IcingaException
+ */
+ protected function renderObjectAttributes($attrs)
+ {
+ $blacklist = [
+ 'last_check_result',
+ 'source_location',
+ 'templates',
+ ];
+
+ $linked = [
+ 'check_command',
+ 'groups',
+ ];
+
+ $info = new NameValueTable();
+ foreach ($attrs as $key => $value) {
+ if (in_array($key, $blacklist)) {
+ continue;
+ }
+ if ($key === 'groups') {
+ $info->addNameValueRow($key, $this->linkGroups($value));
+ } elseif (in_array($key, $linked)) {
+ $info->addNameValueRow($key, $this->renderLinkedObject($key, $value));
+ } else {
+ $info->addNameValueRow($key, PlainObjectRenderer::render($value));
+ }
+ }
+
+ $this->add([
+ Html::tag('h2', null, 'Object Properties'),
+ $info
+ ]);
+ }
+
+ /**
+ * @param $key
+ * @param $objectName
+ * @return Link|Link[]
+ * @throws \Icinga\Exception\IcingaException
+ * @throws \Icinga\Exception\ProgrammingError
+ */
+ protected function renderLinkedObject($key, $objectName)
+ {
+ $keys = [
+ 'check_command' => ['CheckCommand', 'CheckCommands'],
+ 'event_command' => ['EventCommand', 'EventCommands'],
+ 'notification_command' => ['NotificationCommand', 'NotificationCommands'],
+ ];
+ $type = $keys[$key];
+
+ if ($key === 'groups') {
+ return $this->linkGroups($objectName);
+ } else {
+ $singular = $type[0];
+ $plural = $type[1];
+
+ return Link::create($objectName, 'director/inspect/object', [
+ 'type' => $singular,
+ 'plural' => $plural,
+ 'name' => $objectName
+ ]);
+ }
+ }
+
+ /**
+ * @param $groups
+ * @return Link[]
+ * @throws \Icinga\Exception\IcingaException
+ * @throws \Icinga\Exception\ProgrammingError
+ */
+ protected function linkGroups($groups)
+ {
+ if ($groups === null) {
+ return [];
+ }
+
+ $singular = $this->object->type . 'Group';
+ $plural = $singular . "s";
+
+ $links = [];
+
+ foreach ($groups as $name) {
+ $links[] = Link::create($name, 'director/inspect/object', [
+ 'type' => $singular,
+ 'plural' => $plural,
+ 'name' => $name
+ ]);
+ }
+
+ return $links;
+ }
+
+ /**
+ * @param stdClass $source
+ * @throws \Icinga\Exception\IcingaException
+ */
+ protected function renderSourceLocation(stdClass $source)
+ {
+ $findRelative = 'api/packages/director';
+ $this->add(Html::tag('h2')->add('Source Location'));
+ $pos = strpos($source->path, $findRelative);
+
+ if (false === $pos) {
+ $this->add(Html::tag('p', null, Html::sprintf(
+ 'The configuration for this object has not been rendered by'
+ . ' Icinga Director. You can find it on line %s in %s.',
+ Html::tag('strong', null, $source->first_line),
+ Html::tag('strong', null, $source->path)
+ )));
+ } else {
+ $relativePath = substr($source->path, $pos + strlen($findRelative) + 1);
+ $parts = explode('/', $relativePath);
+ $stageName = array_shift($parts);
+ $relativePath = implode('/', $parts);
+ $source->director_relative = $relativePath;
+ $deployment = $this->loadDeploymentForStage($stageName);
+
+ $this->add(Html::tag('p')->add(Html::sprintf(
+ 'The configuration for this object has been rendered by Icinga'
+ . ' Director %s to %s',
+ DateFormatter::timeAgo(strtotime($deployment->start_time, false)),
+ $this->linkToSourceLocation($deployment, $source)
+ )));
+ }
+ }
+
+ protected function loadDeploymentForStage($stageName)
+ {
+ $db = $this->db->getDbAdapter();
+ $query = $db->select()->from(
+ ['dl' => 'director_deployment_log'],
+ ['id', 'start_time', 'config_checksum']
+ )->where('stage_name = ?', $stageName)->order('id DESC')->limit(1);
+
+ return $db->fetchRow($query);
+ }
+
+ /**
+ * @param $deployment
+ * @param $source
+ * @return Link
+ * @throws \Icinga\Exception\IcingaException
+ * @throws \Icinga\Exception\ProgrammingError
+ */
+ protected function linkToSourceLocation($deployment, $source)
+ {
+ $filename = $source->director_relative;
+
+ return Link::create(
+ sprintf('%s:%s', $filename, $source->first_line),
+ 'director/config/file',
+ [
+ 'config_checksum' => $this->getChecksum($deployment->config_checksum),
+ 'deployment_id' => $deployment->id,
+ 'backTo' => 'deployment',
+ 'file_path' => $filename,
+ 'highlight' => $source->first_line,
+ 'highlightSeverity' => 'ok'
+ ]
+ );
+ }
+
+ protected function formatConsole($output)
+ {
+ return Html::tag('pre', ['class' => 'logfile'], $output);
+ }
+}
diff --git a/library/Director/Web/Widget/ImportSourceDetails.php b/library/Director/Web/Widget/ImportSourceDetails.php
new file mode 100644
index 0000000..32eef7f
--- /dev/null
+++ b/library/Director/Web/Widget/ImportSourceDetails.php
@@ -0,0 +1,83 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use gipfl\Web\Widget\Hint;
+use ipl\Html\HtmlDocument;
+use Icinga\Module\Director\Forms\ImportCheckForm;
+use Icinga\Module\Director\Forms\ImportRunForm;
+use Icinga\Module\Director\Objects\ImportSource;
+use ipl\Html\Html;
+use gipfl\Translation\TranslationHelper;
+
+class ImportSourceDetails extends HtmlDocument
+{
+ use TranslationHelper;
+
+ protected $source;
+
+ public function __construct(ImportSource $source)
+ {
+ $this->source = $source;
+ }
+
+ protected function assemble()
+ {
+ $source = $this->source;
+ $description = $source->get('description');
+ if ($description !== null && strlen($description)) {
+ $this->add(Html::tag('p', null, $description));
+ }
+
+ switch ($source->get('import_state')) {
+ case 'unknown':
+ $this->add(Hint::warning($this->translate(
+ "It's currently unknown whether we are in sync with this Import Source."
+ . ' You should either check for changes or trigger a new Import Run.'
+ )));
+ break;
+ case 'in-sync':
+ $this->add(Hint::ok(sprintf(
+ $this->translate(
+ 'This Import Source was last found to be in sync at %s.'
+ ),
+ $source->last_attempt
+ )));
+ // TODO: check whether...
+ // - there have been imports since then, differing from former ones
+ // - there have been activities since then
+ break;
+ case 'pending-changes':
+ $this->add(Hint::warning($this->translate(
+ 'There are pending changes for this Import Source. You should trigger a new'
+ . ' Import Run.'
+ )));
+ break;
+ case 'failing':
+ $this->add(Hint::error(sprintf(
+ $this->translate(
+ 'This Import Source failed when last checked at %s: %s'
+ ),
+ $source->last_attempt,
+ $source->last_error_message
+ )));
+ break;
+ default:
+ $this->add(Hint::error(sprintf(
+ $this->translate('This Import Source has an invalid state: %s'),
+ $source->get('import_state')
+ )));
+ }
+
+ $this->add(
+ ImportCheckForm::load()
+ ->setImportSource($source)
+ ->handleRequest()
+ );
+ $this->add(
+ ImportRunForm::load()
+ ->setImportSource($source)
+ ->handleRequest()
+ );
+ }
+}
diff --git a/library/Director/Web/Widget/InspectPackages.php b/library/Director/Web/Widget/InspectPackages.php
new file mode 100644
index 0000000..f9b8864
--- /dev/null
+++ b/library/Director/Web/Widget/InspectPackages.php
@@ -0,0 +1,174 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use gipfl\IcingaWeb2\Link;
+use gipfl\Translation\TranslationHelper;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Objects\IcingaEndpoint;
+use ipl\Html\Html;
+use ipl\Html\Table;
+
+class InspectPackages
+{
+ use TranslationHelper;
+
+ /** @var Db */
+ protected $db;
+
+ /** @var string */
+ protected $baseUrl;
+
+ public function __construct(Db $db, $baseUrl)
+ {
+ $this->db = $db;
+ $this->baseUrl = $baseUrl;
+ }
+
+ public function getContent(IcingaEndpoint $endpoint = null, $package = null, $stage = null, $file = null)
+ {
+ if ($endpoint === null) {
+ return $this->getRootEndpoints();
+ } elseif ($package === null) {
+ return $this->getPackages($endpoint);
+ } elseif ($stage === null) {
+ return $this->getStages($endpoint, $package);
+ } elseif ($file === null) {
+ return $this->getFiles($endpoint, $package, $stage);
+ } else {
+ return $this->getFile($endpoint, $package, $stage, $file);
+ }
+ }
+
+ public function getTitle(IcingaEndpoint $endpoint = null, $package = null, $stage = null, $file = null)
+ {
+ if ($endpoint === null) {
+ return $this->translate('Endpoint in your Root Zone');
+ } elseif ($package === null) {
+ return \sprintf($this->translate('Packages on Endpoint: %s'), $endpoint->getObjectName());
+ } elseif ($stage === null) {
+ return \sprintf($this->translate('Stages in Package: %s'), $package);
+ } elseif ($file === null) {
+ return \sprintf($this->translate('Files in Stage: %s'), $stage);
+ } else {
+ return \sprintf($this->translate('File Content: %s'), $file);
+ }
+ }
+
+ public function getBreadCrumb(IcingaEndpoint $endpoint = null, $package = null, $stage = null)
+ {
+ $parts = [
+ 'endpoint' => $endpoint === null ? null : $endpoint->getObjectName(),
+ 'package' => $package,
+ 'stage' => $stage,
+ ];
+
+ $params = [];
+ // No root zone link for now:
+ // $result = [Link::create($this->translate('Root Zone'), $this->baseUrl)];
+ $result = [Html::tag('a', ['href' => '#'], $this->translate('Root Zone'))];
+ foreach ($parts as $name => $value) {
+ if ($value === null) {
+ break;
+ }
+ $params[$name] = $value;
+ $result[] = Link::create($value, $this->baseUrl, $params);
+ }
+
+ return Html::tag('ul', ['class' => 'breadcrumb'], Html::wrapEach($result, 'li'));
+ }
+
+ protected function getRootEndpoints()
+ {
+ $table = $this->prepareTable();
+ foreach ($this->db->getEndpointNamesInDeploymentZone() as $name) {
+ $table->add(Table::row([
+ Link::create($name, $this->baseUrl, [
+ 'endpoint' => $name,
+ ])
+ ]));
+ }
+
+ return $table;
+ }
+
+ protected function getPackages(IcingaEndpoint $endpoint)
+ {
+ $table = $this->prepareTable();
+ $api = $endpoint->api();
+ foreach ($api->getPackages() as $package) {
+ $table->add(Table::row([
+ Link::create($package->name, $this->baseUrl, [
+ 'endpoint' => $endpoint->getObjectName(),
+ 'package' => $package->name,
+ ])
+ ]));
+ }
+
+ return $table;
+ }
+
+ protected function getStages(IcingaEndpoint $endpoint, $packageName)
+ {
+ $table = $this->prepareTable();
+ $api = $endpoint->api();
+ foreach ($api->getPackages() as $package) {
+ if ($package->name !== $packageName) {
+ continue;
+ }
+ foreach ($package->stages as $stage) {
+ $label = [$stage];
+ if ($stage === $package->{'active-stage'}) {
+ $label[] = Html::tag('small', [' (', $this->translate('active'), ')']);
+ }
+
+ $table->add(Table::row([
+ Link::create($label, $this->baseUrl, [
+ 'endpoint' => $endpoint->getObjectName(),
+ 'package' => $package->name,
+ 'stage' => $stage
+ ])
+ ]));
+ }
+ }
+
+ return $table;
+ }
+
+ protected function getFiles(IcingaEndpoint $endpoint, $package, $stage)
+ {
+ $table = $this->prepareTable();
+ $table->getAttributes()->set('data-base-target', '_next');
+ foreach ($endpoint->api()->listStageFiles($stage, $package) as $filename) {
+ $table->add($table->row([
+ Link::create($filename, $this->baseUrl, [
+ 'endpoint' => $endpoint->getObjectName(),
+ 'package' => $package,
+ 'stage' => $stage,
+ 'file' => $filename
+ ])
+ ]));
+ }
+
+ return $table;
+ }
+
+ protected function getFile(IcingaEndpoint $endpoint, $package, $stage, $file)
+ {
+ return Html::tag('pre', $endpoint->api()->getStagedFile($stage, $file, $package));
+ }
+
+ protected function prepareTable($headerCols = [])
+ {
+ $table = new Table();
+ $table->addAttributes([
+ 'class' => ['common-table', 'table-row-selectable'],
+ 'data-base-target' => '_self'
+ ]);
+ if (! empty($headerCols)) {
+ $table->add($table::row($headerCols, null, 'th'));
+ }
+
+ return $table;
+ }
+}
diff --git a/library/Director/Web/Widget/JobDetails.php b/library/Director/Web/Widget/JobDetails.php
new file mode 100644
index 0000000..3a530a2
--- /dev/null
+++ b/library/Director/Web/Widget/JobDetails.php
@@ -0,0 +1,69 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use gipfl\Web\Widget\Hint;
+use Icinga\Date\DateFormatter;
+use ipl\Html\HtmlDocument;
+use Icinga\Module\Director\Objects\DirectorJob;
+use ipl\Html\Html;
+use gipfl\Translation\TranslationHelper;
+
+class JobDetails extends HtmlDocument
+{
+ use TranslationHelper;
+
+ /**
+ * JobDetails constructor.
+ * @param DirectorJob $job
+ * @throws \Icinga\Exception\NotFoundError
+ */
+ public function __construct(DirectorJob $job)
+ {
+ $runInterval = $job->get('run_interval');
+ if ($job->hasBeenDisabled()) {
+ $this->add(Hint::error(sprintf(
+ $this->translate(
+ 'This job would run every %ds. It has been disabled and will'
+ . ' therefore not be executed as scheduled'
+ ),
+ $runInterval
+ )));
+ } else {
+ //$class = $job->job(); echo $class::getDescription()
+ $msg = $job->isPending()
+ ? sprintf(
+ $this->translate('This job runs every %ds and is currently pending'),
+ $runInterval
+ )
+ : sprintf(
+ $this->translate('This job runs every %ds.'),
+ $runInterval
+ );
+ $this->add(Html::tag('p', null, $msg));
+ }
+
+ $tsLastAttempt = $job->get('ts_last_attempt');
+ if ($tsLastAttempt) {
+ $ts = \strtotime($tsLastAttempt);
+ $timeAgo = Html::tag('span', [
+ 'class' => 'time-ago',
+ 'title' => DateFormatter::formatDateTime($ts)
+ ], DateFormatter::timeAgo($ts));
+ if ($job->get('last_attempt_succeeded') === 'y') {
+ $this->add(Hint::ok(Html::sprintf(
+ $this->translate('The last attempt succeeded %s'),
+ $timeAgo
+ )));
+ } else {
+ $this->add(Hint::error(Html::sprintf(
+ $this->translate('The last attempt failed %s: %s'),
+ $timeAgo,
+ $job->get('last_error_message')
+ )));
+ }
+ } else {
+ $this->add(Hint::warning($this->translate('This job has not been executed yet')));
+ }
+ }
+}
diff --git a/library/Director/Web/Widget/ListItem.php b/library/Director/Web/Widget/ListItem.php
new file mode 100644
index 0000000..ec326cc
--- /dev/null
+++ b/library/Director/Web/Widget/ListItem.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use ipl\Html\Attributes;
+use ipl\Html\BaseHtmlElement;
+use ipl\Html\Html;
+use ipl\Html\ValidHtml;
+
+class ListItem extends BaseHtmlElement
+{
+ protected $contentSeparator = "\n";
+
+ /**
+ * @param ValidHtml|array|string $content
+ * @param Attributes|array $attributes
+ *
+ * @return $this
+ */
+ public function addItem($content, $attributes = null)
+ {
+ return $this->add(
+ Html::tag('li', $attributes, $content)
+ );
+ }
+}
diff --git a/library/Director/Web/Widget/NotInBranchedHint.php b/library/Director/Web/Widget/NotInBranchedHint.php
new file mode 100644
index 0000000..222934b
--- /dev/null
+++ b/library/Director/Web/Widget/NotInBranchedHint.php
@@ -0,0 +1,23 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use gipfl\Translation\TranslationHelper;
+use gipfl\Web\Widget\Hint;
+use Icinga\Authentication\Auth;
+use Icinga\Module\Director\Db\Branch\Branch;
+use ipl\Html\Html;
+
+class NotInBranchedHint extends Hint
+{
+ use TranslationHelper;
+
+ public function __construct($forbiddenAction, Branch $branch, Auth $auth)
+ {
+ parent::__construct(Html::sprintf(
+ $this->translate('%s is not available while being in a Configuration Branch: %s'),
+ $forbiddenAction,
+ Branch::requireHook()->linkToBranch($branch, $auth, $branch->getName())
+ ), 'info');
+ }
+}
diff --git a/library/Director/Web/Widget/OrderedList.php b/library/Director/Web/Widget/OrderedList.php
new file mode 100644
index 0000000..8f888de
--- /dev/null
+++ b/library/Director/Web/Widget/OrderedList.php
@@ -0,0 +1,8 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+class OrderedList extends AbstractList
+{
+ protected $tag = 'ol';
+}
diff --git a/library/Director/Web/Widget/ShowConfigFile.php b/library/Director/Web/Widget/ShowConfigFile.php
new file mode 100644
index 0000000..77d32cf
--- /dev/null
+++ b/library/Director/Web/Widget/ShowConfigFile.php
@@ -0,0 +1,106 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use ipl\Html\HtmlDocument;
+use Icinga\Module\Director\IcingaConfig\IcingaConfigFile;
+use ipl\Html\Html;
+use ipl\Html\HtmlString;
+use gipfl\IcingaWeb2\Link;
+use gipfl\Translation\TranslationHelper;
+
+class ShowConfigFile extends HtmlDocument
+{
+ use TranslationHelper;
+
+ protected $file;
+
+ protected $highlight;
+
+ protected $highlightSeverity;
+
+ public function __construct(
+ IcingaConfigFile $file,
+ $highlight = null,
+ $highlightSeverity = null
+ ) {
+ $this->file = $file;
+ $this->highlight = $highlight;
+ $this->highlightSeverity = $highlightSeverity;
+ }
+
+ /**
+ * @throws \Icinga\Exception\IcingaException
+ */
+ protected function assemble()
+ {
+ $source = $this->linkObjects(Html::escape($this->file->getContent()));
+ if ($this->highlight) {
+ $source = $this->highlight(
+ $source,
+ $this->highlight,
+ $this->highlightSeverity
+ );
+ }
+
+ $this->add(Html::tag(
+ 'pre',
+ ['class' => 'generated-config'],
+ new HtmlString($source)
+ ));
+ }
+
+ /**
+ * @param $match
+ * @return string
+ * @throws \Icinga\Exception\IcingaException
+ * @throws \Icinga\Exception\ProgrammingError
+ */
+ protected function linkObject($match)
+ {
+ if ($match[2] === 'Service') {
+ return $match[0];
+ }
+ $controller = $match[2];
+
+ if ($match[2] === 'CheckCommand') {
+ $controller = 'command';
+ }
+
+ $name = $this->decode($match[3]);
+ return sprintf(
+ '%s %s &quot;%s&quot; {',
+ $match[1],
+ $match[2],
+ Link::create(
+ $name,
+ 'director/' . $controller,
+ ['name' => $name],
+ ['data-base-target' => '_next']
+ )
+ );
+ }
+
+ protected function decode($str)
+ {
+ return htmlspecialchars_decode($str, ENT_COMPAT | ENT_SUBSTITUTE | ENT_HTML5);
+ }
+
+ protected function linkObjects($config)
+ {
+ $pattern = '/^(object|template)\s([A-Z][A-Za-z]*?)\s&quot;(.+?)&quot;\s{/m';
+
+ return preg_replace_callback(
+ $pattern,
+ [$this, 'linkObject'],
+ $config
+ );
+ }
+
+ protected function highlight($what, $line, $severity)
+ {
+ $lines = explode("\n", $what);
+ $lines[$line - 1] = '<span class="highlight ' . $severity . '">' . $lines[$line - 1] . '</span>';
+ return implode("\n", $lines);
+ }
+}
diff --git a/library/Director/Web/Widget/SyncRunDetails.php b/library/Director/Web/Widget/SyncRunDetails.php
new file mode 100644
index 0000000..408e8f6
--- /dev/null
+++ b/library/Director/Web/Widget/SyncRunDetails.php
@@ -0,0 +1,129 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+use Icinga\Module\Director\Objects\DirectorActivityLog;
+use ipl\Html\HtmlDocument;
+use Icinga\Module\Director\Db;
+use Icinga\Module\Director\Objects\SyncRun;
+use gipfl\IcingaWeb2\Link;
+use gipfl\Translation\TranslationHelper;
+use gipfl\IcingaWeb2\Widget\NameValueTable;
+use function sprintf;
+
+class SyncRunDetails extends NameValueTable
+{
+ use TranslationHelper;
+
+ const URL_ACTIVITIES = 'director/config/activities';
+
+ /** @var SyncRun */
+ protected $run;
+
+ public function __construct(SyncRun $run)
+ {
+ $this->run = $run;
+ $this->getAttributes()->add('data-base-target', '_next'); // eigentlich nur runSummary
+ $this->addNameValuePairs([
+ $this->translate('Start time') => $run->get('start_time'),
+ $this->translate('Duration') => sprintf('%.2fs', $run->get('duration_ms') / 1000),
+ $this->translate('Activity') => $this->runSummary($run)
+ ]);
+ }
+
+ /**
+ * @param SyncRun $run
+ * @return array
+ */
+ protected function runSummary(SyncRun $run)
+ {
+ $html = [];
+ $total = $run->countActivities();
+ if ($total === 0) {
+ $html[] = $this->translate('No changes have been made');
+ } else {
+ if ($total === 1) {
+ $html[] = $this->translate('One object has been modified');
+ } else {
+ $html[] = sprintf(
+ $this->translate('%s objects have been modified'),
+ $total
+ );
+ }
+
+ /** @var Db $db */
+ $db = $run->getConnection();
+ $formerId = $db->fetchActivityLogIdByChecksum($run->get('last_former_activity'));
+ if ($formerId === null) {
+ return $html;
+ }
+ $lastId = $db->fetchActivityLogIdByChecksum($run->get('last_related_activity'));
+
+ if ($formerId !== $lastId) {
+ $idRangeEx = sprintf(
+ 'id>%d&id<=%d',
+ $formerId,
+ $lastId
+ );
+ } else {
+ $idRangeEx = null;
+ }
+
+ $links = new HtmlDocument();
+ $links->setSeparator(', ');
+ $links->add([
+ $this->activitiesLink(
+ 'objects_created',
+ $this->translate('%d created'),
+ DirectorActivityLog::ACTION_CREATE,
+ $idRangeEx
+ ),
+ $this->activitiesLink(
+ 'objects_modified',
+ $this->translate('%d modified'),
+ DirectorActivityLog::ACTION_MODIFY,
+ $idRangeEx
+ ),
+ $this->activitiesLink(
+ 'objects_deleted',
+ $this->translate('%d deleted'),
+ DirectorActivityLog::ACTION_DELETE,
+ $idRangeEx
+ ),
+ ]);
+
+ if ($idRangeEx && count($links) > 1) {
+ $links->add(new Link(
+ $this->translate('Show all actions'),
+ self::URL_ACTIVITIES,
+ ['idRangeEx' => $idRangeEx]
+ ));
+ }
+
+ if (! $links->isEmpty()) {
+ $html[] = ': ';
+ $html[] = $links;
+ }
+ }
+
+ return $html;
+ }
+
+ protected function activitiesLink($key, $label, $action, $rangeFilter)
+ {
+ $count = $this->run->get($key);
+ if ($count > 0) {
+ if ($rangeFilter) {
+ return new Link(
+ sprintf($label, $count),
+ self::URL_ACTIVITIES,
+ ['action' => $action, 'idRangeEx' => $rangeFilter]
+ );
+ }
+
+ return sprintf($label, $count);
+ }
+
+ return null;
+ }
+}
diff --git a/library/Director/Web/Widget/UnorderedList.php b/library/Director/Web/Widget/UnorderedList.php
new file mode 100644
index 0000000..f01dbe3
--- /dev/null
+++ b/library/Director/Web/Widget/UnorderedList.php
@@ -0,0 +1,8 @@
+<?php
+
+namespace Icinga\Module\Director\Web\Widget;
+
+class UnorderedList extends AbstractList
+{
+ protected $tag = 'ul';
+}
diff --git a/library/Director/Web/Window.php b/library/Director/Web/Window.php
new file mode 100644
index 0000000..3415dd3
--- /dev/null
+++ b/library/Director/Web/Window.php
@@ -0,0 +1,13 @@
+<?php
+
+namespace Icinga\Module\Director\Web;
+
+use Icinga\Web\Window as WebWindow;
+
+class Window extends WebWindow
+{
+ public function __construct($id)
+ {
+ parent::__construct(\preg_replace('/_.+$/', '', $id));
+ }
+}