diff --git a/alerting/build.gradle b/alerting/build.gradle index 5a53f45d7..4674c1e98 100644 --- a/alerting/build.gradle +++ b/alerting/build.gradle @@ -156,6 +156,8 @@ dependencies { // Needed for integ tests zipArchive group: 'org.opensearch.plugin', name:'opensearch-notifications-core', version: "${opensearch_build}" zipArchive group: 'org.opensearch.plugin', name:'notifications', version: "${opensearch_build}" + zipArchive group: 'org.opensearch.plugin', name:'opensearch-job-scheduler', version: "${opensearch_build}" + zipArchive group: 'org.opensearch.plugin', name:'opensearch-sql-plugin', version: "${opensearch_build}" // Needed for security tests if (securityEnabled) { @@ -173,7 +175,10 @@ dependencies { implementation "org.jetbrains.kotlin:kotlin-stdlib-common:${kotlin_version}" implementation "org.jetbrains:annotations:13.0" + // SQL/PPL plugin dependencies are included in alerting-core api project(":alerting-core") + implementation 'org.json:json:20240303' + implementation "com.github.seancfoley:ipaddress:5.4.1" implementation project(path: ":alerting-spi", configuration: 'shadow') @@ -253,6 +258,28 @@ testClusters.integTest { } })) + plugin(provider({ + new RegularFile() { + @Override + File getAsFile() { + return configurations.zipArchive.asFileTree.matching { + include '**/opensearch-job-scheduler*' + }.singleFile + } + } + })) + + plugin(provider({ + new RegularFile() { + @Override + File getAsFile() { + return configurations.zipArchive.asFileTree.matching { + include '**/opensearch-sql-plugin*' + }.singleFile + } + } + })) + if (securityEnabled) { plugin(provider({ new RegularFile() { @@ -418,105 +445,105 @@ task prepareBwcTests { } } -// Create two test clusters with 3 nodes of the old version -2.times {i -> - task "${baseName}#oldVersionClusterTask$i"(type: StandaloneRestIntegTestTask) { - dependsOn 'prepareBwcTests' - useCluster testClusters."${baseName}$i" - filter { - includeTestsMatching "org.opensearch.alerting.bwc.*IT" - } - systemProperty 'tests.rest.bwcsuite', 'old_cluster' - systemProperty 'tests.rest.bwcsuite_round', 'old' - systemProperty 'tests.plugin_bwc_version', bwcVersion - nonInputProperties.systemProperty('tests.rest.cluster', "${-> testClusters."${baseName}$i".allHttpSocketURI.join(",")}") - nonInputProperties.systemProperty('tests.clustername', "${-> testClusters."${baseName}$i".getName()}") - } -} - -// Upgrade one node of the old cluster to new OpenSearch version with upgraded plugin version. -// This results in a mixed cluster with 2 nodes on the old version and 1 upgraded node. -// This is also used as a one third upgraded cluster for a rolling upgrade. -task "${baseName}#mixedClusterTask"(type: StandaloneRestIntegTestTask) { - useCluster testClusters."${baseName}0" - dependsOn "${baseName}#oldVersionClusterTask0" - doFirst { - testClusters."${baseName}0".upgradeNodeAndPluginToNextVersion(plugins) - } - filter { - includeTestsMatching "org.opensearch.alerting.bwc.*IT" - } - systemProperty 'tests.rest.bwcsuite', 'mixed_cluster' - systemProperty 'tests.rest.bwcsuite_round', 'first' - systemProperty 'tests.plugin_bwc_version', bwcVersion - nonInputProperties.systemProperty('tests.rest.cluster', "${-> testClusters."${baseName}0".allHttpSocketURI.join(",")}") - nonInputProperties.systemProperty('tests.clustername', "${-> testClusters."${baseName}0".getName()}") -} - -// Upgrade the second node to new OpenSearch version with upgraded plugin version after the first node is upgraded. -// This results in a mixed cluster with 1 node on the old version and 2 upgraded nodes. -// This is used for rolling upgrade. -task "${baseName}#twoThirdsUpgradedClusterTask"(type: StandaloneRestIntegTestTask) { - dependsOn "${baseName}#mixedClusterTask" - useCluster testClusters."${baseName}0" - doFirst { - testClusters."${baseName}0".upgradeNodeAndPluginToNextVersion(plugins) - } - filter { - includeTestsMatching "org.opensearch.alerting.bwc.*IT" - } - systemProperty 'tests.rest.bwcsuite', 'mixed_cluster' - systemProperty 'tests.rest.bwcsuite_round', 'second' - systemProperty 'tests.plugin_bwc_version', bwcVersion - nonInputProperties.systemProperty('tests.rest.cluster', "${-> testClusters."${baseName}0".allHttpSocketURI.join(",")}") - nonInputProperties.systemProperty('tests.clustername', "${-> testClusters."${baseName}0".getName()}") -} - -// Upgrade the third node to new OpenSearch version with upgraded plugin version after the second node is upgraded. -// This results in a fully upgraded cluster. -// This is used for rolling upgrade. -task "${baseName}#rollingUpgradeClusterTask"(type: StandaloneRestIntegTestTask) { - dependsOn "${baseName}#twoThirdsUpgradedClusterTask" - useCluster testClusters."${baseName}0" - doFirst { - testClusters."${baseName}0".upgradeNodeAndPluginToNextVersion(plugins) - } - filter { - includeTestsMatching "org.opensearch.alerting.bwc.*IT" - } - mustRunAfter "${baseName}#mixedClusterTask" - systemProperty 'tests.rest.bwcsuite', 'mixed_cluster' - systemProperty 'tests.rest.bwcsuite_round', 'third' - systemProperty 'tests.plugin_bwc_version', bwcVersion - nonInputProperties.systemProperty('tests.rest.cluster', "${-> testClusters."${baseName}0".allHttpSocketURI.join(",")}") - nonInputProperties.systemProperty('tests.clustername', "${-> testClusters."${baseName}0".getName()}") -} - -// Upgrade all the nodes of the old cluster to new OpenSearch version with upgraded plugin version -// at the same time resulting in a fully upgraded cluster. -task "${baseName}#fullRestartClusterTask"(type: StandaloneRestIntegTestTask) { - dependsOn "${baseName}#oldVersionClusterTask1" - useCluster testClusters."${baseName}1" - doFirst { - testClusters."${baseName}1".upgradeAllNodesAndPluginsToNextVersion(plugins) - } - filter { - includeTestsMatching "org.opensearch.alerting.bwc.*IT" - } - systemProperty 'tests.rest.bwcsuite', 'upgraded_cluster' - systemProperty 'tests.plugin_bwc_version', bwcVersion - nonInputProperties.systemProperty('tests.rest.cluster', "${-> testClusters."${baseName}1".allHttpSocketURI.join(",")}") - nonInputProperties.systemProperty('tests.clustername', "${-> testClusters."${baseName}1".getName()}") -} - -// A bwc test suite which runs all the bwc tasks combined -task bwcTestSuite(type: StandaloneRestIntegTestTask) { - exclude '**/*Test*' - exclude '**/*IT*' - dependsOn tasks.named("${baseName}#mixedClusterTask") - dependsOn tasks.named("${baseName}#rollingUpgradeClusterTask") - dependsOn tasks.named("${baseName}#fullRestartClusterTask") -} +//// Create two test clusters with 3 nodes of the old version +//2.times {i -> +// task "${baseName}#oldVersionClusterTask$i"(type: StandaloneRestIntegTestTask) { +// dependsOn 'prepareBwcTests' +// useCluster testClusters."${baseName}$i" +// filter { +// includeTestsMatching "org.opensearch.alerting.bwc.*IT" +// } +// systemProperty 'tests.rest.bwcsuite', 'old_cluster' +// systemProperty 'tests.rest.bwcsuite_round', 'old' +// systemProperty 'tests.plugin_bwc_version', bwcVersion +// nonInputProperties.systemProperty('tests.rest.cluster', "${-> testClusters."${baseName}$i".allHttpSocketURI.join(",")}") +// nonInputProperties.systemProperty('tests.clustername', "${-> testClusters."${baseName}$i".getName()}") +// } +//} +// +//// Upgrade one node of the old cluster to new OpenSearch version with upgraded plugin version. +//// This results in a mixed cluster with 2 nodes on the old version and 1 upgraded node. +//// This is also used as a one third upgraded cluster for a rolling upgrade. +//task "${baseName}#mixedClusterTask"(type: StandaloneRestIntegTestTask) { +// useCluster testClusters."${baseName}0" +// dependsOn "${baseName}#oldVersionClusterTask0" +// doFirst { +// testClusters."${baseName}0".upgradeNodeAndPluginToNextVersion(plugins) +// } +// filter { +// includeTestsMatching "org.opensearch.alerting.bwc.*IT" +// } +// systemProperty 'tests.rest.bwcsuite', 'mixed_cluster' +// systemProperty 'tests.rest.bwcsuite_round', 'first' +// systemProperty 'tests.plugin_bwc_version', bwcVersion +// nonInputProperties.systemProperty('tests.rest.cluster', "${-> testClusters."${baseName}0".allHttpSocketURI.join(",")}") +// nonInputProperties.systemProperty('tests.clustername', "${-> testClusters."${baseName}0".getName()}") +//} +// +//// Upgrade the second node to new OpenSearch version with upgraded plugin version after the first node is upgraded. +//// This results in a mixed cluster with 1 node on the old version and 2 upgraded nodes. +//// This is used for rolling upgrade. +//task "${baseName}#twoThirdsUpgradedClusterTask"(type: StandaloneRestIntegTestTask) { +// dependsOn "${baseName}#mixedClusterTask" +// useCluster testClusters."${baseName}0" +// doFirst { +// testClusters."${baseName}0".upgradeNodeAndPluginToNextVersion(plugins) +// } +// filter { +// includeTestsMatching "org.opensearch.alerting.bwc.*IT" +// } +// systemProperty 'tests.rest.bwcsuite', 'mixed_cluster' +// systemProperty 'tests.rest.bwcsuite_round', 'second' +// systemProperty 'tests.plugin_bwc_version', bwcVersion +// nonInputProperties.systemProperty('tests.rest.cluster', "${-> testClusters."${baseName}0".allHttpSocketURI.join(",")}") +// nonInputProperties.systemProperty('tests.clustername', "${-> testClusters."${baseName}0".getName()}") +//} +// +//// Upgrade the third node to new OpenSearch version with upgraded plugin version after the second node is upgraded. +//// This results in a fully upgraded cluster. +//// This is used for rolling upgrade. +//task "${baseName}#rollingUpgradeClusterTask"(type: StandaloneRestIntegTestTask) { +// dependsOn "${baseName}#twoThirdsUpgradedClusterTask" +// useCluster testClusters."${baseName}0" +// doFirst { +// testClusters."${baseName}0".upgradeNodeAndPluginToNextVersion(plugins) +// } +// filter { +// includeTestsMatching "org.opensearch.alerting.bwc.*IT" +// } +// mustRunAfter "${baseName}#mixedClusterTask" +// systemProperty 'tests.rest.bwcsuite', 'mixed_cluster' +// systemProperty 'tests.rest.bwcsuite_round', 'third' +// systemProperty 'tests.plugin_bwc_version', bwcVersion +// nonInputProperties.systemProperty('tests.rest.cluster', "${-> testClusters."${baseName}0".allHttpSocketURI.join(",")}") +// nonInputProperties.systemProperty('tests.clustername', "${-> testClusters."${baseName}0".getName()}") +//} +// +//// Upgrade all the nodes of the old cluster to new OpenSearch version with upgraded plugin version +//// at the same time resulting in a fully upgraded cluster. +//task "${baseName}#fullRestartClusterTask"(type: StandaloneRestIntegTestTask) { +// dependsOn "${baseName}#oldVersionClusterTask1" +// useCluster testClusters."${baseName}1" +// doFirst { +// testClusters."${baseName}1".upgradeAllNodesAndPluginsToNextVersion(plugins) +// } +// filter { +// includeTestsMatching "org.opensearch.alerting.bwc.*IT" +// } +// systemProperty 'tests.rest.bwcsuite', 'upgraded_cluster' +// systemProperty 'tests.plugin_bwc_version', bwcVersion +// nonInputProperties.systemProperty('tests.rest.cluster', "${-> testClusters."${baseName}1".allHttpSocketURI.join(",")}") +// nonInputProperties.systemProperty('tests.clustername', "${-> testClusters."${baseName}1".getName()}") +//} +// +//// A bwc test suite which runs all the bwc tasks combined +//task bwcTestSuite(type: StandaloneRestIntegTestTask) { +// exclude '**/*Test*' +// exclude '**/*IT*' +// dependsOn tasks.named("${baseName}#mixedClusterTask") +// dependsOn tasks.named("${baseName}#rollingUpgradeClusterTask") +// dependsOn tasks.named("${baseName}#fullRestartClusterTask") +//} run { doFirst { diff --git a/alerting/src/main/kotlin/org/opensearch/alerting/AlertService.kt b/alerting/src/main/kotlin/org/opensearch/alerting/AlertService.kt index 38706ac71..9d3f5536a 100644 --- a/alerting/src/main/kotlin/org/opensearch/alerting/AlertService.kt +++ b/alerting/src/main/kotlin/org/opensearch/alerting/AlertService.kt @@ -35,11 +35,13 @@ import org.opensearch.commons.alerting.model.ClusterMetricsTriggerRunResult import org.opensearch.commons.alerting.model.DataSources import org.opensearch.commons.alerting.model.Monitor import org.opensearch.commons.alerting.model.NoOpTrigger +import org.opensearch.commons.alerting.model.PPLInput import org.opensearch.commons.alerting.model.QueryLevelTriggerRunResult import org.opensearch.commons.alerting.model.Trigger import org.opensearch.commons.alerting.model.Workflow import org.opensearch.commons.alerting.model.WorkflowRunContext import org.opensearch.commons.alerting.model.action.AlertCategory +import org.opensearch.commons.alerting.util.isPPLMonitor import org.opensearch.core.action.ActionListener import org.opensearch.core.common.bytes.BytesReference import org.opensearch.core.rest.RestStatus @@ -201,6 +203,10 @@ class AlertService( } } + // populate PPL Monitor specific fields + val query = if (ctx.monitor.isPPLMonitor()) (ctx.monitor.inputs[0] as PPLInput).query else null + val queryResults = if (ctx.monitor.isPPLMonitor()) ctx.pplQueryResults else emptyList() + // Merge the alert's error message to the current alert's history val updatedHistory = currentAlert?.errorHistory.update(alertError) return if (alertError == null && !result.triggered) { @@ -211,7 +217,9 @@ class AlertService( errorHistory = updatedHistory, actionExecutionResults = updatedActionExecutionResults, schemaVersion = IndexUtils.alertIndexSchemaVersion, - clusters = triggeredClusters + clusters = triggeredClusters, + query = query, + queryResults = queryResults ) } else if (alertError == null && currentAlert?.isAcknowledged() == true) { null @@ -224,7 +232,9 @@ class AlertService( errorHistory = updatedHistory, actionExecutionResults = updatedActionExecutionResults, schemaVersion = IndexUtils.alertIndexSchemaVersion, - clusters = triggeredClusters + clusters = triggeredClusters, + query = query, + queryResults = queryResults ) } else { val alertState = if (workflorwRunContext?.auditDelegateMonitorAlerts == true) { @@ -237,7 +247,7 @@ class AlertService( errorHistory = updatedHistory, actionExecutionResults = updatedActionExecutionResults, schemaVersion = IndexUtils.alertIndexSchemaVersion, executionId = executionId, workflowId = workflorwRunContext?.workflowId ?: "", - clusters = triggeredClusters + clusters = triggeredClusters, query = query, queryResults = queryResults ) } } diff --git a/alerting/src/main/kotlin/org/opensearch/alerting/AlertingPlugin.kt b/alerting/src/main/kotlin/org/opensearch/alerting/AlertingPlugin.kt index c7415e061..1df921076 100644 --- a/alerting/src/main/kotlin/org/opensearch/alerting/AlertingPlugin.kt +++ b/alerting/src/main/kotlin/org/opensearch/alerting/AlertingPlugin.kt @@ -108,6 +108,8 @@ import org.opensearch.commons.alerting.model.ClusterMetricsInput import org.opensearch.commons.alerting.model.DocLevelMonitorInput import org.opensearch.commons.alerting.model.DocumentLevelTrigger import org.opensearch.commons.alerting.model.Monitor +import org.opensearch.commons.alerting.model.PPLInput +import org.opensearch.commons.alerting.model.PPLTrigger import org.opensearch.commons.alerting.model.QueryLevelTrigger import org.opensearch.commons.alerting.model.ScheduledJob import org.opensearch.commons.alerting.model.ScheduledJob.Companion.SCHEDULED_JOBS_INDEX @@ -207,6 +209,7 @@ internal class AlertingPlugin : PainlessExtension, ActionPlugin, ScriptPlugin, R nodesInCluster: Supplier ): List { return listOf( + // Alerting V1 RestGetMonitorAction(), RestDeleteMonitorAction(), RestIndexMonitorAction(), @@ -236,6 +239,7 @@ internal class AlertingPlugin : PainlessExtension, ActionPlugin, ScriptPlugin, R override fun getActions(): List> { return listOf( + // Alerting V1 ActionPlugin.ActionHandler(ScheduledJobsStatsAction.INSTANCE, ScheduledJobsStatsTransportAction::class.java), ActionPlugin.ActionHandler(AlertingActions.INDEX_MONITOR_ACTION_TYPE, TransportIndexMonitorAction::class.java), ActionPlugin.ActionHandler(AlertingActions.GET_MONITOR_ACTION_TYPE, TransportGetMonitorAction::class.java), @@ -262,7 +266,7 @@ internal class AlertingPlugin : PainlessExtension, ActionPlugin, ScriptPlugin, R ActionPlugin.ActionHandler(AlertingActions.DELETE_COMMENT_ACTION_TYPE, TransportDeleteAlertingCommentAction::class.java), ActionPlugin.ActionHandler(ExecuteWorkflowAction.INSTANCE, TransportExecuteWorkflowAction::class.java), ActionPlugin.ActionHandler(GetRemoteIndexesAction.INSTANCE, TransportGetRemoteIndexesAction::class.java), - ActionPlugin.ActionHandler(DocLevelMonitorFanOutAction.INSTANCE, TransportDocLevelMonitorFanOutAction::class.java) + ActionPlugin.ActionHandler(DocLevelMonitorFanOutAction.INSTANCE, TransportDocLevelMonitorFanOutAction::class.java), ) } @@ -271,12 +275,14 @@ internal class AlertingPlugin : PainlessExtension, ActionPlugin, ScriptPlugin, R Monitor.XCONTENT_REGISTRY, SearchInput.XCONTENT_REGISTRY, DocLevelMonitorInput.XCONTENT_REGISTRY, + PPLInput.XCONTENT_REGISTRY, QueryLevelTrigger.XCONTENT_REGISTRY, BucketLevelTrigger.XCONTENT_REGISTRY, ClusterMetricsInput.XCONTENT_REGISTRY, DocumentLevelTrigger.XCONTENT_REGISTRY, ChainedAlertTrigger.XCONTENT_REGISTRY, RemoteMonitorTrigger.XCONTENT_REGISTRY, + PPLTrigger.XCONTENT_REGISTRY, Workflow.XCONTENT_REGISTRY ) } @@ -468,7 +474,13 @@ internal class AlertingPlugin : PainlessExtension, ActionPlugin, ScriptPlugin, R AlertingSettings.REMOTE_METADATA_ENDPOINT, AlertingSettings.REMOTE_METADATA_REGION, AlertingSettings.REMOTE_METADATA_SERVICE_NAME, - AlertingSettings.MULTI_TENANT_TRIGGER_EVAL_ENABLED + AlertingSettings.MULTI_TENANT_TRIGGER_EVAL_ENABLED, + AlertingSettings.PPL_MONITOR_EXECUTION_MAX_DURATION, + AlertingSettings.PPL_MAX_QUERY_LENGTH, + AlertingSettings.PPL_QUERY_RESULTS_MAX_DATAROWS, + AlertingSettings.PPL_QUERY_RESULTS_MAX_SIZE, + AlertingSettings.NOTIFICATION_SUBJECT_SOURCE_MAX_LENGTH, + AlertingSettings.NOTIFICATION_MESSAGE_SOURCE_MAX_LENGTH, ) } diff --git a/alerting/src/main/kotlin/org/opensearch/alerting/BucketLevelMonitorRunner.kt b/alerting/src/main/kotlin/org/opensearch/alerting/BucketLevelMonitorRunner.kt index 416401520..24b523e27 100644 --- a/alerting/src/main/kotlin/org/opensearch/alerting/BucketLevelMonitorRunner.kt +++ b/alerting/src/main/kotlin/org/opensearch/alerting/BucketLevelMonitorRunner.kt @@ -72,6 +72,7 @@ object BucketLevelMonitorRunner : MonitorRunner() { periodStart: Instant, periodEnd: Instant, dryrun: Boolean, + manual: Boolean, workflowRunContext: WorkflowRunContext?, executionId: String, transportService: TransportService diff --git a/alerting/src/main/kotlin/org/opensearch/alerting/DocumentLevelMonitorRunner.kt b/alerting/src/main/kotlin/org/opensearch/alerting/DocumentLevelMonitorRunner.kt index 8a961b3b9..67c567e46 100644 --- a/alerting/src/main/kotlin/org/opensearch/alerting/DocumentLevelMonitorRunner.kt +++ b/alerting/src/main/kotlin/org/opensearch/alerting/DocumentLevelMonitorRunner.kt @@ -58,6 +58,7 @@ class DocumentLevelMonitorRunner : MonitorRunner() { periodStart: Instant, periodEnd: Instant, dryrun: Boolean, + manual: Boolean, workflowRunContext: WorkflowRunContext?, executionId: String, transportService: TransportService diff --git a/alerting/src/main/kotlin/org/opensearch/alerting/InputService.kt b/alerting/src/main/kotlin/org/opensearch/alerting/InputService.kt index 3ee6d644d..c09f4e1a4 100644 --- a/alerting/src/main/kotlin/org/opensearch/alerting/InputService.kt +++ b/alerting/src/main/kotlin/org/opensearch/alerting/InputService.kt @@ -7,9 +7,14 @@ package org.opensearch.alerting import kotlinx.coroutines.newSingleThreadContext import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout import org.apache.logging.log4j.LogManager +import org.json.JSONObject import org.opensearch.action.search.SearchRequest import org.opensearch.action.search.SearchResponse +import org.opensearch.alerting.PPLUtils.appendDataRowsLimit +import org.opensearch.alerting.PPLUtils.capAndReformatPPLQueryResults +import org.opensearch.alerting.PPLUtils.executePplQuery import org.opensearch.alerting.opensearchapi.convertToMap import org.opensearch.alerting.opensearchapi.suspendUntil import org.opensearch.alerting.settings.AlertingSettings @@ -33,6 +38,7 @@ import org.opensearch.common.xcontent.XContentType import org.opensearch.commons.alerting.model.ClusterMetricsInput import org.opensearch.commons.alerting.model.InputRunResults import org.opensearch.commons.alerting.model.Monitor +import org.opensearch.commons.alerting.model.PPLInput import org.opensearch.commons.alerting.model.SearchInput import org.opensearch.commons.alerting.model.TriggerAfterKey import org.opensearch.commons.alerting.model.WorkflowRunContext @@ -50,9 +56,12 @@ import org.opensearch.script.ScriptService import org.opensearch.script.ScriptType import org.opensearch.script.TemplateScript import org.opensearch.search.builder.SearchSourceBuilder +import org.opensearch.transport.TransportService import org.opensearch.transport.client.Client +import org.opensearch.transport.client.node.NodeClient import java.time.Duration import java.time.Instant +import kotlin.time.measureTimedValue /** Service that handles the collection of input results for Monitor executions */ class InputService( @@ -216,6 +225,87 @@ class InputService( } } + suspend fun collectInputResultsForPPLMonitor( + monitor: Monitor, + monitorCtx: MonitorRunnerExecutionContext, + transportService: TransportService + ): InputRunResults { + return try { + // PPL Alerting: + // these query results are for number_of_results PPL triggers, + // only the number of results returned by base query matters + // for those triggers, not the contents themselves + val basePplQueryResults = runPPLBaseQuery( + monitor, + (monitor.inputs[0] as PPLInput).query, + monitorCtx, + transportService + ) + val numPplResults = basePplQueryResults.getLong("total") + + // PPL Alerting: + // PPL Trigger evaluations won't read this input result in. + // for num results triggers, this is because the contents of the query results + // are unimportant, only the number of results matters. + // for custom trigger, this is because it'll be running its own query + // (base query + custom condition) and evaluating on those query results. + // thus, the size capped and reformatted base query results are included + // here to populate the final customer facing response of monitor execution + val cappedPPLBaseQueryResults = capAndReformatPPLQueryResults( + basePplQueryResults, + monitorCtx.clusterService!!.clusterSettings.get(AlertingSettings.PPL_QUERY_RESULTS_MAX_SIZE) + ) + + InputRunResults(emptyList(), null, null, cappedPPLBaseQueryResults, numPplResults) + } catch (e: Exception) { + logger.error( + "failed to run PPL Monitor base query " + + "from PPL Monitor ${monitor.name} (id: ${monitor.id}", + e + ) + InputRunResults(emptyList(), e, null, listOf(), null) + } + } + + // for PPL Monitor execution, the base PPL query is run once + // for number_of_results PPL triggers + private suspend fun runPPLBaseQuery( + pplMonitor: Monitor, + baseQuery: String, + monitorCtx: MonitorRunnerExecutionContext, + transportService: TransportService + ): JSONObject { + + // TODO: change name to trigger max duration + val monitorExecutionDuration = monitorCtx + .clusterService!! + .clusterSettings + .get(AlertingSettings.PPL_MONITOR_EXECUTION_MAX_DURATION) + + var queryResponseJson: JSONObject? = null + + withTimeout(monitorExecutionDuration.millis) { + // limit the number of PPL query result data rows returned + val dataRowsLimit = monitorCtx.clusterService!!.clusterSettings.get(AlertingSettings.PPL_QUERY_RESULTS_MAX_DATAROWS) + val limitedQueryToExecute = appendDataRowsLimit(baseQuery, dataRowsLimit) + + logger.debug("executing the base PPL query of monitor: ${pplMonitor.id}") + val (queryResponseJsonReceived, timeTaken) = measureTimedValue { + executePplQuery( + limitedQueryToExecute, + false, + monitorCtx.client!! as NodeClient + ) + } + logger.debug("base query results: $queryResponseJsonReceived") + logger.debug("time taken to execute base query against sql/ppl plugin: $timeTaken") + + queryResponseJson = queryResponseJsonReceived + } + + return queryResponseJson!! + } + fun getSearchRequest( monitor: Monitor, searchInput: SearchInput, diff --git a/alerting/src/main/kotlin/org/opensearch/alerting/MonitorRunner.kt b/alerting/src/main/kotlin/org/opensearch/alerting/MonitorRunner.kt index 49c11500b..e0d97345a 100644 --- a/alerting/src/main/kotlin/org/opensearch/alerting/MonitorRunner.kt +++ b/alerting/src/main/kotlin/org/opensearch/alerting/MonitorRunner.kt @@ -5,35 +5,20 @@ package org.opensearch.alerting -import org.opensearch.OpenSearchSecurityException -import org.opensearch.alerting.action.GetDestinationsAction -import org.opensearch.alerting.action.GetDestinationsRequest -import org.opensearch.alerting.action.GetDestinationsResponse -import org.opensearch.alerting.model.destination.Destination import org.opensearch.alerting.opensearchapi.InjectorContextElement -import org.opensearch.alerting.opensearchapi.suspendUntil import org.opensearch.alerting.opensearchapi.withClosableContext import org.opensearch.alerting.script.QueryLevelTriggerExecutionContext import org.opensearch.alerting.script.TriggerExecutionContext -import org.opensearch.alerting.util.destinationmigration.NotificationActionConfigs -import org.opensearch.alerting.util.destinationmigration.NotificationApiUtils.Companion.getNotificationConfigInfo -import org.opensearch.alerting.util.destinationmigration.getTitle -import org.opensearch.alerting.util.destinationmigration.publishLegacyNotification -import org.opensearch.alerting.util.destinationmigration.sendNotification -import org.opensearch.alerting.util.isAllowed -import org.opensearch.alerting.util.isTestAction +import org.opensearch.alerting.util.getConfigAndSendNotification import org.opensearch.alerting.util.use import org.opensearch.commons.ConfigConstants import org.opensearch.commons.alerting.model.ActionRunResult import org.opensearch.commons.alerting.model.Monitor import org.opensearch.commons.alerting.model.MonitorRunResult -import org.opensearch.commons.alerting.model.Table import org.opensearch.commons.alerting.model.WorkflowRunContext import org.opensearch.commons.alerting.model.action.Action -import org.opensearch.commons.notifications.model.NotificationConfigInfo import org.opensearch.core.common.Strings import org.opensearch.transport.TransportService -import org.opensearch.transport.client.node.NodeClient import java.time.Instant abstract class MonitorRunner { @@ -44,6 +29,7 @@ abstract class MonitorRunner { periodStart: Instant, periodEnd: Instant, dryRun: Boolean, + manual: Boolean, workflowRunContext: WorkflowRunContext? = null, executionId: String, transportService: TransportService @@ -97,103 +83,4 @@ abstract class MonitorRunner { ActionRunResult(action.id, action.name, mapOf(), false, MonitorRunnerService.currentTime(), e) } } - - protected suspend fun getConfigAndSendNotification( - action: Action, - monitorCtx: MonitorRunnerExecutionContext, - subject: String?, - message: String - ): String { - val config = getConfigForNotificationAction(action, monitorCtx) - if (config.destination == null && config.channel == null) { - throw IllegalStateException("Unable to find a Notification Channel or Destination config with id [${action.destinationId}]") - } - - // Adding a check on TEST_ACTION Destination type here to avoid supporting it as a LegacyBaseMessage type - // just for Alerting integration tests - if (config.destination?.isTestAction() == true) { - return "test action" - } - - if (config.destination?.isAllowed(monitorCtx.allowList) == false) { - throw IllegalStateException( - "Monitor contains a Destination type that is not allowed: ${config.destination.type}" - ) - } - - var actionResponseContent = "" - actionResponseContent = config.channel - ?.sendNotification( - monitorCtx.client!!, - config.channel.getTitle(subject), - message - ) ?: actionResponseContent - - actionResponseContent = config.destination - ?.buildLegacyBaseMessage(subject, message, monitorCtx.destinationContextFactory!!.getDestinationContext(config.destination)) - ?.publishLegacyNotification(monitorCtx.client!!) - ?: actionResponseContent - - return actionResponseContent - } - - /** - * The "destination" ID referenced in a Monitor Action could either be a Notification config or a Destination config - * depending on whether the background migration process has already migrated it from a Destination to a Notification config. - * - * To cover both of these cases, the Notification config will take precedence and if it is not found, the Destination will be retrieved. - */ - private suspend fun getConfigForNotificationAction( - action: Action, - monitorCtx: MonitorRunnerExecutionContext - ): NotificationActionConfigs { - var destination: Destination? = null - var notificationPermissionException: Exception? = null - - var channel: NotificationConfigInfo? = null - try { - channel = getNotificationConfigInfo(monitorCtx.client as NodeClient, action.destinationId) - } catch (e: OpenSearchSecurityException) { - notificationPermissionException = e - } - - // If the channel was not found, try to retrieve the Destination - if (channel == null) { - destination = try { - val table = Table( - "asc", - "destination.name.keyword", - null, - 1, - 0, - null - ) - val getDestinationsRequest = GetDestinationsRequest( - action.destinationId, - 0L, - null, - table, - "ALL" - ) - - val getDestinationsResponse: GetDestinationsResponse = monitorCtx.client!!.suspendUntil { - monitorCtx.client!!.execute(GetDestinationsAction.INSTANCE, getDestinationsRequest, it) - } - getDestinationsResponse.destinations.firstOrNull() - } catch (e: IllegalStateException) { - // Catching the exception thrown when the Destination was not found so the NotificationActionConfigs object can be returned - null - } catch (e: OpenSearchSecurityException) { - if (notificationPermissionException != null) - throw notificationPermissionException - else - throw e - } - - if (destination == null && notificationPermissionException != null) - throw notificationPermissionException - } - - return NotificationActionConfigs(destination, channel) - } } diff --git a/alerting/src/main/kotlin/org/opensearch/alerting/MonitorRunnerService.kt b/alerting/src/main/kotlin/org/opensearch/alerting/MonitorRunnerService.kt index 8e4c44760..71406d790 100644 --- a/alerting/src/main/kotlin/org/opensearch/alerting/MonitorRunnerService.kt +++ b/alerting/src/main/kotlin/org/opensearch/alerting/MonitorRunnerService.kt @@ -72,6 +72,7 @@ import org.opensearch.commons.alerting.util.AlertingException import org.opensearch.commons.alerting.util.IndexPatternUtils import org.opensearch.commons.alerting.util.isBucketLevelMonitor import org.opensearch.commons.alerting.util.isMonitorOfStandardType +import org.opensearch.commons.alerting.util.isPPLMonitor import org.opensearch.core.action.ActionListener import org.opensearch.core.rest.RestStatus import org.opensearch.core.xcontent.NamedXContentRegistry @@ -362,6 +363,7 @@ object MonitorRunnerService : JobRunner, CoroutineScope, AbstractLifecycleCompon monitorCtx.client!!.execute( ExecuteWorkflowAction.INSTANCE, ExecuteWorkflowRequest( + false, false, TimeValue(periodEnd.toEpochMilli()), job.id, @@ -392,6 +394,7 @@ object MonitorRunnerService : JobRunner, CoroutineScope, AbstractLifecycleCompon monitorCtx.clusterService!!.state().nodes().localNode.id ) val executeMonitorRequest = ExecuteMonitorRequest( + false, false, TimeValue(periodEnd.toEpochMilli()), job.id, @@ -424,9 +427,10 @@ object MonitorRunnerService : JobRunner, CoroutineScope, AbstractLifecycleCompon periodStart: Instant, periodEnd: Instant, dryrun: Boolean, + manual: Boolean, transportService: TransportService ): WorkflowRunResult { - return CompositeWorkflowRunner.runWorkflow(workflow, monitorCtx, periodStart, periodEnd, dryrun, transportService) + return CompositeWorkflowRunner.runWorkflow(workflow, monitorCtx, periodStart, periodEnd, dryrun, manual, transportService) } suspend fun runJob( @@ -434,28 +438,16 @@ object MonitorRunnerService : JobRunner, CoroutineScope, AbstractLifecycleCompon periodStart: Instant, periodEnd: Instant, dryrun: Boolean, + manual: Boolean, transportService: TransportService ): MonitorRunResult<*> { // Updating the scheduled job index at the start of monitor execution runs for when there is an upgrade the the schema mapping // has not been updated. - if (!IndexUtils.scheduledJobIndexUpdated && monitorCtx.clusterService != null && monitorCtx.client != null) { - IndexUtils.updateIndexMapping( - ScheduledJob.SCHEDULED_JOBS_INDEX, - ScheduledJobIndices.scheduledJobMappings(), monitorCtx.clusterService!!.state(), monitorCtx.client!!.admin().indices(), - object : ActionListener { - override fun onResponse(response: AcknowledgedResponse) { - } - - override fun onFailure(t: Exception) { - logger.error("Failed to update config index schema", t) - } - } - ) - } + updateAlertingConfigIndexSchema() if (job is Workflow) { logger.info("Executing scheduled workflow - id: ${job.id}, periodStart: $periodStart, periodEnd: $periodEnd, dryrun: $dryrun") - CompositeWorkflowRunner.runWorkflow(workflow = job, monitorCtx, periodStart, periodEnd, dryrun, transportService) + CompositeWorkflowRunner.runWorkflow(workflow = job, monitorCtx, periodStart, periodEnd, dryrun, manual, transportService) } val monitor = job as Monitor val executionId = "${monitor.id}_${LocalDateTime.now(ZoneOffset.UTC)}_${UUID.randomUUID()}" @@ -477,13 +469,27 @@ object MonitorRunnerService : JobRunner, CoroutineScope, AbstractLifecycleCompon "Executing scheduled monitor - id: ${monitor.id}, type: ${monitor.monitorType}, periodStart: $periodStart, " + "periodEnd: $periodEnd, dryrun: $dryrun, executionId: $executionId" ) - val runResult = if (monitor.isBucketLevelMonitor()) { + val runResult = if (monitor.isPPLMonitor()) { + // PPL Monitor runs with QueryLevelMonitorRunner + // as PPL Monitors are ultimately query-based + QueryLevelMonitorRunner.runMonitor( + monitor, + monitorCtx, + periodStart, + periodEnd, + dryrun, + manual, + executionId = executionId, + transportService = transportService, + ) + } else if (monitor.isBucketLevelMonitor()) { BucketLevelMonitorRunner.runMonitor( monitor, monitorCtx, periodStart, periodEnd, dryrun, + manual, executionId = executionId, transportService = transportService ) @@ -494,6 +500,7 @@ object MonitorRunnerService : JobRunner, CoroutineScope, AbstractLifecycleCompon periodStart, periodEnd, dryrun, + manual, executionId = executionId, transportService = transportService ) @@ -504,6 +511,7 @@ object MonitorRunnerService : JobRunner, CoroutineScope, AbstractLifecycleCompon periodStart, periodEnd, dryrun, + manual, executionId = executionId, transportService = transportService ) @@ -519,6 +527,7 @@ object MonitorRunnerService : JobRunner, CoroutineScope, AbstractLifecycleCompon periodStart, periodEnd, dryrun, + manual, executionId = executionId, transportService = transportService ) @@ -587,4 +596,21 @@ object MonitorRunnerService : JobRunner, CoroutineScope, AbstractLifecycleCompon .newInstance(template.params + mapOf("ctx" to ctx.asTemplateArg())) .execute() } + + private fun updateAlertingConfigIndexSchema() { + if (!IndexUtils.scheduledJobIndexUpdated && monitorCtx.clusterService != null && monitorCtx.client != null) { + IndexUtils.updateIndexMapping( + ScheduledJob.SCHEDULED_JOBS_INDEX, + ScheduledJobIndices.scheduledJobMappings(), monitorCtx.clusterService!!.state(), monitorCtx.client!!.admin().indices(), + object : ActionListener { + override fun onResponse(response: AcknowledgedResponse) { + } + + override fun onFailure(t: Exception) { + logger.error("Failed to update config index schema", t) + } + } + ) + } + } } diff --git a/alerting/src/main/kotlin/org/opensearch/alerting/PPLUtils.kt b/alerting/src/main/kotlin/org/opensearch/alerting/PPLUtils.kt new file mode 100644 index 000000000..2d7558c21 --- /dev/null +++ b/alerting/src/main/kotlin/org/opensearch/alerting/PPLUtils.kt @@ -0,0 +1,263 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.alerting + +import org.json.JSONArray +import org.json.JSONObject +import org.opensearch.alerting.core.ppl.PPLPluginInterface +import org.opensearch.alerting.opensearchapi.suspendUntil +import org.opensearch.sql.plugin.transport.TransportPPLQueryRequest +import org.opensearch.transport.client.node.NodeClient + +object PPLUtils { + +// // TODO: these are in-house PPL query parsers, find a PPL plugin dependency that does this for us +// /* Regular Expressions */ +// // captures the name of the result variable in a PPL monitor's custom condition +// // e.g. custom condition: `eval apple = avg_latency > 100` +// // captures: "apple" +// private val evalResultVarRegex = """^(?!.*\|)\s*(?i:eval)\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*=""".toRegex() + + private val customConditionValidationRegex = """^\s*where\s+.+""".toRegex() + + const val PPL_RESULTS_SIZE_EXCEEDED_MESSAGE = "The PPL Query results were too large and thus excluded." + + /** + * Appends a user-defined custom condition to a PPL query. + * + * This method is used exclusively for custom condition triggers. It concatenates + * the custom condition to the base PPL query using the pipe operator (|), allowing the condition + * to evaluate each query result data row. + * + * @param query The base PPL query string (e.g., "source=logs | where status=error") + * @param customCondition The custom trigger condition to append (e.g., "eval result = avg > 3") + * @return The combined PPL query with the custom condition appended + * + * @example + * ``` + * val baseQuery = "source=logs | stats max(price) as max_price by region" + * val condition = "eval result = max_price > 300" + * val result = appendCustomCondition(baseQuery, condition) + * // Returns: "source=logs | stats max(price) as max_price by region | eval result = max_price > 300" + * ``` + * + * @note This method does not validate the syntax of either the query or custom condition. + * It is assumed that upstream workflows have already validated the base query, + * and that downstream workflows will validate the constructed query + */ + fun appendCustomCondition(query: String, customCondition: String): String { + return "$query | $customCondition" + } + + /** + * Appends a limit on the number of documents/data rows to retrieve from a PPL query. + * + * This method uses the PPL `head` command to restrict the number of rows returned by + * the query. This is used to prevent memory issues and improving performance when + * only a subset of results is needed for alert evaluation. + * + * @param query The base PPL query string + * @param maxDataRows The maximum number of data rows to retrieve + * @return The PPL query with a head limit appended (e.g., "source=logs | head 1000") + * + * @example + * ``` + * val query = "source=logs | where status=error" + * val limitedQuery = appendDataRowsLimit(query, 100) + * // Returns: "source=logs | where status=error | head 100" + * ``` + */ + fun appendDataRowsLimit(query: String, maxDataRows: Long): String { + return "$query | head $maxDataRows" + } + + fun customConditionIsValid(customCondition: String): Boolean { + return customCondition.matches(customConditionValidationRegex) + } + + /** + * Executes a PPL query and returns the response as a parsable JSONObject. + * + * This method calls the PPL Plugin's Execute or Explain API via the transport layer to execute the provided query + * and parses the response into a structured JSON format suitable for trigger evaluation + * + * @param query The PPL query string to execute + * @param explain true if the query should just be explained, false if the query should be executed + * @param localNode The node within which the request will be serviced + * @param transportService The transport service used to run the request + * @return A JSONObject containing the query execution results + * + * @throws Exception if the query execution fails or the response cannot be parsed as JSON + * + * @note The response format follows the PPL plugin's Execute API response structure with + * "schema", "datarows", "total", and "size" fields. + */ + suspend fun executePplQuery( + query: String, + explain: Boolean, + client: NodeClient + ): JSONObject { + val path = if (explain) { + "/_plugins/_ppl/_explain" + } else { + "/_plugins/_ppl" + } + + // call PPL plugin to execute query + val transportPplQueryRequest = TransportPPLQueryRequest( + query, + JSONObject(mapOf("query" to query)), + path + ) + + val transportPplQueryResponse = PPLPluginInterface.suspendUntil { + this.executeQuery( + client, + transportPplQueryRequest, + it + ) + } + + val queryResponseJson = JSONObject(transportPplQueryResponse.result) + + return queryResponseJson + } + + fun capAndReformatPPLQueryResults(rawQueryResults: JSONObject, maxSize: Long): List> { + val cappedQueryResults = capPPLQueryResultsSize(rawQueryResults, maxSize).toMap() + val reformattedQueryResults = constructPPLQueryResultsMap(cappedQueryResults) + return reformattedQueryResults + } + + /** + * Caps the size of PPL query results to prevent memory issues and oversized alert payloads. + * + * Checks if the serialized query results exceed a specified size limit. If the results + * are within the limit, they are returned unchanged. If they exceed the limit, the whole response + * is replaced with an informational message while preserving the original structure of the response. + * This ensures alerts can still be created even when query results are too large. + * + * @param pplQueryResults The PPL query response JSONObject + * @param maxSize The maximum allowed size in bytes (estimated by serialized string length) + * @return The original results if under the limit, or a modified version with datarows replaced by a message + * + * @example + * ``` + * val queryResults = executePplQuery(query, client) + * val cappedResults = capPPLQueryResultsSize(queryResults, maxSize = 5000L) + * + * // If results were too large, datarows will contain: + * // [["The PPL Query results were too large and thus excluded"]] + * // But schema, total, and size fields are preserved + * ``` + * + * @note Size is estimated using `toString().length`, which approximates byte size but may + * not be exact for multi-byte characters + * @note The PPL query results structure includes: + * - `schema`: Array of objects storing data types for each column + * - `datarows`: Array of arrays containing the actual query result rows + * - `total`: Total number of result rows + * - `size`: Same as `total` (redundant field in PPL response) + */ + fun capPPLQueryResultsSize(pplQueryResults: JSONObject, maxSize: Long): JSONObject { + // estimate byte size with serialized string length + // if query results size are already under the limit, do nothing + // and return the query results as is + val pplQueryResultsSize = pplQueryResults.toString().length + if (pplQueryResultsSize <= maxSize) { + return pplQueryResults + } + + // if the query results exceed the limit, we need to replace the query results + // with a message that says the results were too large, but still retain the other + // ppl query response fields like schema, total, and size + val limitExceedMessageQueryResults = JSONObject() + + val schema = JSONArray().put(JSONObject(mapOf("name" to "message", "type" to "string"))) + val datarows = JSONArray().put(JSONArray(listOf(PPL_RESULTS_SIZE_EXCEEDED_MESSAGE))) + + limitExceedMessageQueryResults.put("schema", schema) + limitExceedMessageQueryResults.put("datarows", datarows) + limitExceedMessageQueryResults.put("total", 1) + limitExceedMessageQueryResults.put("size", 1) + + return limitExceedMessageQueryResults + } + + /** + * Transforms PPL query results from array-based format (that SQL Plugin Execute API response uses) + * to map-based format for easier template access. + * + * PPL query responses contain a `schema` array that defines field names and types, and a `datarows` array + * that contains the actual data values in positional format. This function combines them into a list of maps + * where each list element represents a row with field names as keys and corresponding values from the datarows. + * + * ### Input Format + * The input should be a PPL query result with this structure: + * ```json + * { + * "schema": [ + * {"name": "abc", "type": "string"}, + * {"name": "number", "type": "integer"} + * ], + * "datarows": [ + * ["xyz", 3], + * ["def", 5] + * ] + * } + * ``` + * + * ### Output Format + * The function returns a list where each element is a map representing a data row: + * ```json + * [ + * {"abc": "xyz", "number": 3}, + * {"abc": "def", "number": 5} + * ] + * ``` + * + * ### Edge Cases + * - If `schema` is missing or empty, returns an empty list + * - If `datarows` is missing or empty, returns an empty list + * - If a schema entry is malformed (not a map or missing "name" field), it is skipped + * - If a datarow has fewer values than schema fields, missing values are set to `null` + * - If a datarow has more values than schema fields, extra values are ignored + * - If a datarow is not a list, that row is skipped + * + * @param rawQueryResults The PPL query results map from SQL Plugin Execute API response + * containing "schema" and "datarows" fields. + * @return A list of maps where each map represents a data row with field names as keys and + * corresponding values from datarows. Returns an empty list if schema or datarows + * are missing, empty, or malformed. + * + * @see org.opensearch.alerting.script.QueryLevelTriggerExecutionContext.asTemplateArg + * @see org.opensearch.alerting.PPLUtils.executePplQuery + */ + fun constructPPLQueryResultsMap(rawQueryResults: Map): List> { + // Extract schema array + val schema = rawQueryResults["schema"] as? List<*> ?: return emptyList() + + // Extract field names from schema + val fieldNames = schema.mapNotNull { schemaEntry -> + (schemaEntry as? Map<*, *>)?.get("name") as? String + } + + if (fieldNames.isEmpty()) return emptyList() + + // Extract datarows array + val datarows = rawQueryResults["datarows"] as? List<*> ?: return emptyList() + + // Transform each row into a map + return datarows.mapNotNull { row -> + val rowList = row as? List<*> ?: return@mapNotNull null + + // Create a map from field names to values + fieldNames.mapIndexed { index, fieldName -> + fieldName to rowList.getOrNull(index) + }.toMap() + } + } +} diff --git a/alerting/src/main/kotlin/org/opensearch/alerting/QueryLevelMonitorRunner.kt b/alerting/src/main/kotlin/org/opensearch/alerting/QueryLevelMonitorRunner.kt index 197b0103b..3938714a1 100644 --- a/alerting/src/main/kotlin/org/opensearch/alerting/QueryLevelMonitorRunner.kt +++ b/alerting/src/main/kotlin/org/opensearch/alerting/QueryLevelMonitorRunner.kt @@ -17,13 +17,18 @@ import org.opensearch.alerting.util.isADMonitor import org.opensearch.commons.alerting.model.Alert import org.opensearch.commons.alerting.model.Monitor import org.opensearch.commons.alerting.model.MonitorRunResult +import org.opensearch.commons.alerting.model.PPLInput +import org.opensearch.commons.alerting.model.PPLTrigger +import org.opensearch.commons.alerting.model.PPLTrigger.ConditionType import org.opensearch.commons.alerting.model.QueryLevelTrigger import org.opensearch.commons.alerting.model.QueryLevelTriggerRunResult import org.opensearch.commons.alerting.model.SearchInput import org.opensearch.commons.alerting.model.WorkflowRunContext +import org.opensearch.commons.alerting.util.isPPLMonitor import org.opensearch.transport.TransportService import java.time.Instant import java.util.Locale +import kotlin.collections.set object QueryLevelMonitorRunner : MonitorRunner() { private val logger = LogManager.getLogger(javaClass) @@ -34,6 +39,7 @@ object QueryLevelMonitorRunner : MonitorRunner() { periodStart: Instant, periodEnd: Instant, dryrun: Boolean, + manual: Boolean, workflowRunContext: WorkflowRunContext?, executionId: String, transportService: TransportService @@ -56,24 +62,45 @@ object QueryLevelMonitorRunner : MonitorRunner() { logger.error("Error loading alerts for monitor: $id", e) return monitorResult.copy(error = e) } - if (!isADMonitor(monitor)) { + + if (isADMonitor(monitor)) { + monitorResult = monitorResult.copy( + inputResults = monitorCtx.inputService!!.collectInputResultsForADMonitor(monitor, periodStart, periodEnd) + ) + } else if (monitor.isPPLMonitor()) { withClosableContext( InjectorContextElement( monitor.id, monitorCtx.settings!!, monitorCtx.threadPool!!.threadContext, - roles, + monitor.user?.roles, monitor.user ) ) { monitorResult = monitorResult.copy( - inputResults = monitorCtx.inputService!!.collectInputResults(monitor, periodStart, periodEnd, null, workflowRunContext) + inputResults = monitorCtx.inputService!!.collectInputResultsForPPLMonitor(monitor, monitorCtx, transportService) ) } } else { - monitorResult = monitorResult.copy( - inputResults = monitorCtx.inputService!!.collectInputResultsForADMonitor(monitor, periodStart, periodEnd) - ) + withClosableContext( + InjectorContextElement( + monitor.id, + monitorCtx.settings!!, + monitorCtx.threadPool!!.threadContext, + roles, + monitor.user + ) + ) { + monitorResult = monitorResult.copy( + inputResults = monitorCtx.inputService!!.collectInputResults( + monitor, + periodStart, + periodEnd, + null, + workflowRunContext + ) + ) + } } val updatedAlerts = mutableListOf() @@ -105,6 +132,7 @@ object QueryLevelMonitorRunner : MonitorRunner() { alertsToExecuteActionsForIds, maxComments ) + for (trigger in monitor.triggers) { val currentAlert = currentAlerts[trigger] val currentAlertContext = currentAlert?.let { @@ -113,7 +141,7 @@ object QueryLevelMonitorRunner : MonitorRunner() { val triggerCtx = QueryLevelTriggerExecutionContext( monitor, - trigger as QueryLevelTrigger, + trigger, monitorResult, currentAlertContext, monitorCtx.clusterService!!.clusterSettings @@ -125,16 +153,36 @@ object QueryLevelMonitorRunner : MonitorRunner() { } else { when (Monitor.MonitorType.valueOf(monitor.monitorType.uppercase(Locale.ROOT))) { Monitor.MonitorType.QUERY_LEVEL_MONITOR -> - monitorCtx.triggerService!!.runQueryLevelTrigger(monitor, trigger, triggerCtx) + monitorCtx.triggerService!!.runQueryLevelTrigger(monitor, trigger as QueryLevelTrigger, triggerCtx) Monitor.MonitorType.CLUSTER_METRICS_MONITOR -> { val remoteMonitoringEnabled = monitorCtx.clusterService!!.clusterSettings.get(AlertingSettings.CROSS_CLUSTER_MONITORING_ENABLED) logger.debug("Remote monitoring enabled: {}", remoteMonitoringEnabled) if (remoteMonitoringEnabled) monitorCtx.triggerService!!.runClusterMetricsTrigger( - monitor, trigger, triggerCtx, monitorCtx.clusterService!! + monitor, trigger as QueryLevelTrigger, triggerCtx, monitorCtx.clusterService!! ) - else monitorCtx.triggerService!!.runQueryLevelTrigger(monitor, trigger, triggerCtx) + else monitorCtx.triggerService!!.runQueryLevelTrigger(monitor, trigger as QueryLevelTrigger, triggerCtx) + } + Monitor.MonitorType.PPL_MONITOR -> { + val pplTrigger = trigger as PPLTrigger + + if (pplTrigger.conditionType == ConditionType.NUMBER_OF_RESULTS) { + // number of results trigger case + monitorCtx.triggerService!!.runPplNumResultsTrigger( + pplTrigger, + monitorResult.inputResults.pplBaseQueryNumResults + ) + } else { + // custom condition trigger case + monitorCtx.triggerService!!.runPplCustomTrigger( + monitor, + pplTrigger, + (monitor.inputs[0] as PPLInput).query, + monitorCtx, + transportService + ) + } } else -> throw IllegalArgumentException("Unsupported monitor type: ${monitor.monitorType}.") @@ -143,15 +191,37 @@ object QueryLevelMonitorRunner : MonitorRunner() { triggerResults[trigger.id] = triggerResult + // PPL Alerting: + // what query results get stored in the Alert and Notification depends on the trigger type. + // if number of results: evaluated on base query, so base query results are included. + // if custom: the custom trigger ran its own query (base query + custom condition), so that query's results are included + // trigger is not PPLTrigger: this is a query-level monitor run, simply include an empty list + val pplQueryResultsToInclude = if (trigger is PPLTrigger && trigger.conditionType == ConditionType.NUMBER_OF_RESULTS) { + monitorResult.inputResults.pplBaseQueryResults + } else if (trigger is PPLTrigger && trigger.conditionType == ConditionType.CUSTOM) { + triggerResult.pplCustomQueryResults + } else { + listOf() + } + + // PPL Alerting: + // triggerCtx hasn't been populated yet with the correct PPL query results + // to include in the notification, so populate that here. + // if this is a query-level monitor run, triggerCtx.pplQueryResults simply + // stays an empty list + val postRunTriggerCtx = triggerCtx.copy( + pplQueryResults = pplQueryResultsToInclude + ) + if (monitorCtx.triggerService!!.isQueryLevelTriggerActionable(triggerCtx, triggerResult, workflowRunContext)) { - val actionCtx = triggerCtx.copy(error = monitorResult.error ?: triggerResult.error) + val actionCtx = postRunTriggerCtx.copy(error = monitorResult.error ?: triggerResult.error) for (action in trigger.actions) { triggerResult.actionResults[action.id] = this.runAction(action, actionCtx, monitorCtx, monitor, dryrun) } } val updatedAlert = monitorCtx.alertService!!.composeQueryLevelAlert( - triggerCtx, + postRunTriggerCtx, triggerResult, monitorResult.alertError() ?: triggerResult.alertError(), executionId, diff --git a/alerting/src/main/kotlin/org/opensearch/alerting/TriggerService.kt b/alerting/src/main/kotlin/org/opensearch/alerting/TriggerService.kt index d4b3d9ded..321708a27 100644 --- a/alerting/src/main/kotlin/org/opensearch/alerting/TriggerService.kt +++ b/alerting/src/main/kotlin/org/opensearch/alerting/TriggerService.kt @@ -5,12 +5,20 @@ package org.opensearch.alerting +import kotlinx.coroutines.withTimeout import org.apache.logging.log4j.LogManager +import org.opensearch.alerting.PPLUtils.appendCustomCondition +import org.opensearch.alerting.PPLUtils.appendDataRowsLimit +import org.opensearch.alerting.PPLUtils.capAndReformatPPLQueryResults +import org.opensearch.alerting.PPLUtils.executePplQuery import org.opensearch.alerting.chainedAlertCondition.parsers.ChainedAlertExpressionParser +import org.opensearch.alerting.opensearchapi.InjectorContextElement +import org.opensearch.alerting.opensearchapi.withClosableContext import org.opensearch.alerting.script.BucketLevelTriggerExecutionContext import org.opensearch.alerting.script.ChainedAlertTriggerExecutionContext import org.opensearch.alerting.script.QueryLevelTriggerExecutionContext import org.opensearch.alerting.script.TriggerScript +import org.opensearch.alerting.settings.AlertingSettings import org.opensearch.alerting.triggercondition.parsers.TriggerExpressionParser import org.opensearch.alerting.util.CrossClusterMonitorUtils import org.opensearch.alerting.util.getBucketKeysHash @@ -29,6 +37,8 @@ import org.opensearch.commons.alerting.model.DocLevelQuery import org.opensearch.commons.alerting.model.DocumentLevelTrigger import org.opensearch.commons.alerting.model.DocumentLevelTriggerRunResult import org.opensearch.commons.alerting.model.Monitor +import org.opensearch.commons.alerting.model.PPLTrigger +import org.opensearch.commons.alerting.model.PPLTrigger.NumResultsCondition import org.opensearch.commons.alerting.model.QueryLevelTrigger import org.opensearch.commons.alerting.model.QueryLevelTriggerRunResult import org.opensearch.commons.alerting.model.Workflow @@ -38,6 +48,9 @@ import org.opensearch.script.ScriptService import org.opensearch.search.aggregations.Aggregation import org.opensearch.search.aggregations.Aggregations import org.opensearch.search.aggregations.support.AggregationPath +import org.opensearch.transport.TransportService +import org.opensearch.transport.client.node.NodeClient +import kotlin.time.measureTimedValue /** Service that handles executing Triggers */ class TriggerService(val scriptService: ScriptService) { @@ -235,4 +248,157 @@ class TriggerService(val scriptService: ScriptService) { return keyValuesList } + + fun runPplNumResultsTrigger( + pplTrigger: PPLTrigger, + numResults: Long? + ): QueryLevelTriggerRunResult { + + if (numResults == null) { + return QueryLevelTriggerRunResult( + pplTrigger.name, + true, + IllegalStateException("Did not receive a number of results from PPL query execution: ${pplTrigger.id}") + ) + } + + if (pplTrigger.numResultsCondition == null) { + return QueryLevelTriggerRunResult( + pplTrigger.name, + true, + IllegalStateException("No number of results condition found for trigger: ${pplTrigger.id}") + ) + } + + if (pplTrigger.numResultsValue == null) { + return QueryLevelTriggerRunResult( + pplTrigger.name, + true, + IllegalStateException("No number of results value found for trigger: ${pplTrigger.id}") + ) + } + + val numResultsCondition = pplTrigger.numResultsCondition!! + val numResultsValue = pplTrigger.numResultsValue!! + + val triggered = when (numResultsCondition) { + NumResultsCondition.GREATER_THAN -> numResults > numResultsValue + NumResultsCondition.GREATER_THAN_EQUAL -> numResults >= numResultsValue + NumResultsCondition.LESS_THAN -> numResults < numResultsValue + NumResultsCondition.LESS_THAN_EQUAL -> numResults <= numResultsValue + NumResultsCondition.EQUAL -> numResults == numResultsValue + NumResultsCondition.NOT_EQUAL -> numResults != numResultsValue + } + + logger.debug("Number of Results PPLTrigger ${pplTrigger.name} with ID ${pplTrigger.id} triggered: $triggered") + + // unlike evaluating custom conditions, where we must include the query results because + // a custom condition executes its own PPL query, number of results trigger evaluation + // doesn't need to include query results because they're evaluated purely on the size + // of the results. the results themselves are already held in QueryLevelMonitorRunner.kt + // (the caller of this function), so passing the results here only to return them again + // inside the QueryLevelTriggerRunResult would be redundant + return QueryLevelTriggerRunResult(pplTrigger.name, triggered, null) + } + + suspend fun runPplCustomTrigger( + pplMonitor: Monitor, + pplTrigger: PPLTrigger, + query: String, + monitorCtx: MonitorRunnerExecutionContext, + transportService: TransportService + ): QueryLevelTriggerRunResult { + + if (pplTrigger.customCondition == null) { + return QueryLevelTriggerRunResult( + pplTrigger.name, + true, + IllegalStateException("No custom condition found for trigger: ${pplTrigger.id}") + ) + } + + // TODO: change name to trigger max duration + val monitorExecutionDuration = monitorCtx + .clusterService!! + .clusterSettings + .get(AlertingSettings.PPL_MONITOR_EXECUTION_MAX_DURATION) + val queryResultsSizeLimit = monitorCtx + .clusterService!! + .clusterSettings + .get(AlertingSettings.PPL_QUERY_RESULTS_MAX_SIZE) + + var triggered: Boolean? = null + var customConditionQueryResults: List>? = null + + try { + withTimeout(monitorExecutionDuration.millis) { + logger.debug("checking if custom condition is used and appending to base query") + + val customCondition = pplTrigger.customCondition!! + + // append the custom condition to query + val queryToExecute = appendCustomCondition(query, customCondition) + + // limit the number of PPL query result data rows returned + val dataRowsLimit = monitorCtx.clusterService!!.clusterSettings.get(AlertingSettings.PPL_QUERY_RESULTS_MAX_DATAROWS) + val limitedQueryToExecute = appendDataRowsLimit(queryToExecute, dataRowsLimit) + + // TODO: after getting ppl query results, see if the number of results + // retrieved equals the max allowed number of query results. this implies + // query results might have been excluded, in which case a warning message + // in the alert and notification must be added that results were excluded + // and an alert that should have been generated might not have been + + logger.debug("executing the PPL query of monitor: ${pplMonitor.id} with custom condition: $customCondition") + // execute the PPL query + val (queryResponseJson, timeTaken) = measureTimedValue { + withClosableContext( + InjectorContextElement( + pplMonitor.id, + monitorCtx.settings!!, + monitorCtx.threadPool!!.threadContext, + pplMonitor.user?.roles, + pplMonitor.user + ) + ) { + executePplQuery( + limitedQueryToExecute, + false, + monitorCtx.client!! as NodeClient + ) + } + } + logger.debug("query results for trigger ${pplTrigger.id}: $queryResponseJson") + logger.debug("time taken to execute query against sql/ppl plugin: $timeTaken") + + // val numPplResults = basePplQueryResults.getLong("total") + + // the custom condition query returns all buckets that met the custom condition, + // so if there are any results at all, the custom condition was met for at least one bukcet, + // this trigger has triggered. + triggered = queryResponseJson.getLong("total") > 0 + + // cap and reformat the results to be included in trigger run result + customConditionQueryResults = capAndReformatPPLQueryResults(queryResponseJson, queryResultsSizeLimit) + + logger.debug("Custom PPLTrigger ${pplTrigger.name} with ID ${pplTrigger.id} triggered: $triggered") + } + + return QueryLevelTriggerRunResult( + pplTrigger.name, + triggered!!, + null, + mutableMapOf(), + customConditionQueryResults!! + ) + } catch (e: Exception) { + logger.error( + "failed to run PPL Custom Trigger ${pplTrigger.name} (id: ${pplTrigger.id} " + + "from PPL Monitor ${pplMonitor.name} (id: ${pplMonitor.id}", + e + ) + + return QueryLevelTriggerRunResult(pplTrigger.name, true, e) + } + } } diff --git a/alerting/src/main/kotlin/org/opensearch/alerting/action/ExecuteMonitorRequest.kt b/alerting/src/main/kotlin/org/opensearch/alerting/action/ExecuteMonitorRequest.kt index c7b699dfc..1d2c69218 100644 --- a/alerting/src/main/kotlin/org/opensearch/alerting/action/ExecuteMonitorRequest.kt +++ b/alerting/src/main/kotlin/org/opensearch/alerting/action/ExecuteMonitorRequest.kt @@ -15,6 +15,7 @@ import java.io.IOException class ExecuteMonitorRequest : ActionRequest { val dryrun: Boolean + val manual: Boolean val requestEnd: TimeValue val monitorId: String? val monitor: Monitor? @@ -22,12 +23,14 @@ class ExecuteMonitorRequest : ActionRequest { constructor( dryrun: Boolean, + manual: Boolean, requestEnd: TimeValue, monitorId: String?, monitor: Monitor?, requestStart: TimeValue? = null ) : super() { this.dryrun = dryrun + this.manual = manual this.requestEnd = requestEnd this.monitorId = monitorId this.monitor = monitor @@ -37,6 +40,7 @@ class ExecuteMonitorRequest : ActionRequest { @Throws(IOException::class) constructor(sin: StreamInput) : this( sin.readBoolean(), // dryrun + sin.readBoolean(), // manual sin.readTimeValue(), // requestEnd sin.readOptionalString(), // monitorId if (sin.readBoolean()) { @@ -52,6 +56,7 @@ class ExecuteMonitorRequest : ActionRequest { @Throws(IOException::class) override fun writeTo(out: StreamOutput) { out.writeBoolean(dryrun) + out.writeBoolean(manual) out.writeTimeValue(requestEnd) out.writeOptionalString(monitorId) if (monitor != null) { diff --git a/alerting/src/main/kotlin/org/opensearch/alerting/action/ExecuteWorkflowRequest.kt b/alerting/src/main/kotlin/org/opensearch/alerting/action/ExecuteWorkflowRequest.kt index 104448cce..fdf962048 100644 --- a/alerting/src/main/kotlin/org/opensearch/alerting/action/ExecuteWorkflowRequest.kt +++ b/alerting/src/main/kotlin/org/opensearch/alerting/action/ExecuteWorkflowRequest.kt @@ -19,6 +19,7 @@ import java.io.IOException */ class ExecuteWorkflowRequest : ActionRequest { val dryrun: Boolean + val manual: Boolean val requestEnd: TimeValue val workflowId: String? val workflow: Workflow? @@ -26,12 +27,14 @@ class ExecuteWorkflowRequest : ActionRequest { constructor( dryrun: Boolean, + manual: Boolean, requestEnd: TimeValue, workflowId: String?, workflow: Workflow?, requestStart: TimeValue? = null, ) : super() { this.dryrun = dryrun + this.manual = manual this.requestEnd = requestEnd this.requestStart = requestStart this.workflowId = workflowId @@ -40,6 +43,7 @@ class ExecuteWorkflowRequest : ActionRequest { @Throws(IOException::class) constructor(sin: StreamInput) : this( + sin.readBoolean(), sin.readBoolean(), sin.readTimeValue(), sin.readOptionalString(), @@ -62,6 +66,7 @@ class ExecuteWorkflowRequest : ActionRequest { @Throws(IOException::class) override fun writeTo(out: StreamOutput) { out.writeBoolean(dryrun) + out.writeBoolean(manual) out.writeTimeValue(requestEnd) out.writeOptionalTimeValue(requestStart) out.writeOptionalString(workflowId) diff --git a/alerting/src/main/kotlin/org/opensearch/alerting/remote/monitors/RemoteDocumentLevelMonitorRunner.kt b/alerting/src/main/kotlin/org/opensearch/alerting/remote/monitors/RemoteDocumentLevelMonitorRunner.kt index c12356cab..495b914da 100644 --- a/alerting/src/main/kotlin/org/opensearch/alerting/remote/monitors/RemoteDocumentLevelMonitorRunner.kt +++ b/alerting/src/main/kotlin/org/opensearch/alerting/remote/monitors/RemoteDocumentLevelMonitorRunner.kt @@ -41,6 +41,7 @@ class RemoteDocumentLevelMonitorRunner : MonitorRunner() { periodStart: Instant, periodEnd: Instant, dryRun: Boolean, + manual: Boolean, workflowRunContext: WorkflowRunContext?, executionId: String, transportService: TransportService diff --git a/alerting/src/main/kotlin/org/opensearch/alerting/resthandler/RestExecuteMonitorAction.kt b/alerting/src/main/kotlin/org/opensearch/alerting/resthandler/RestExecuteMonitorAction.kt index 4dd4f588b..792f86824 100644 --- a/alerting/src/main/kotlin/org/opensearch/alerting/resthandler/RestExecuteMonitorAction.kt +++ b/alerting/src/main/kotlin/org/opensearch/alerting/resthandler/RestExecuteMonitorAction.kt @@ -60,7 +60,7 @@ class RestExecuteMonitorAction : BaseRestHandler() { if (request.hasParam("monitorID")) { val monitorId = request.param("monitorID") - val execMonitorRequest = ExecuteMonitorRequest(dryrun, requestEnd, monitorId, null) + val execMonitorRequest = ExecuteMonitorRequest(dryrun, true, requestEnd, monitorId, null) client.execute(ExecuteMonitorAction.INSTANCE, execMonitorRequest, RestToXContentListener(channel)) } else { val xcp = request.contentParser() @@ -73,7 +73,7 @@ class RestExecuteMonitorAction : BaseRestHandler() { throw AlertingException.wrap(e) } - val execMonitorRequest = ExecuteMonitorRequest(dryrun, requestEnd, null, monitor) + val execMonitorRequest = ExecuteMonitorRequest(dryrun, true, requestEnd, null, monitor) client.execute(ExecuteMonitorAction.INSTANCE, execMonitorRequest, RestToXContentListener(channel)) } } diff --git a/alerting/src/main/kotlin/org/opensearch/alerting/resthandler/RestExecuteWorkflowAction.kt b/alerting/src/main/kotlin/org/opensearch/alerting/resthandler/RestExecuteWorkflowAction.kt index 096f03010..60a3ccc86 100644 --- a/alerting/src/main/kotlin/org/opensearch/alerting/resthandler/RestExecuteWorkflowAction.kt +++ b/alerting/src/main/kotlin/org/opensearch/alerting/resthandler/RestExecuteWorkflowAction.kt @@ -41,13 +41,13 @@ class RestExecuteWorkflowAction : BaseRestHandler() { if (request.hasParam("workflowID")) { val workflowId = request.param("workflowID") - val execWorkflowRequest = ExecuteWorkflowRequest(dryrun, requestEnd, workflowId, null) + val execWorkflowRequest = ExecuteWorkflowRequest(dryrun, true, requestEnd, workflowId, null) client.execute(ExecuteWorkflowAction.INSTANCE, execWorkflowRequest, RestToXContentListener(channel)) } else { val xcp = request.contentParser() XContentParserUtils.ensureExpectedToken(XContentParser.Token.START_OBJECT, xcp.nextToken(), xcp) val workflow = Workflow.parse(xcp, Workflow.NO_ID, Workflow.NO_VERSION) - val execWorkflowRequest = ExecuteWorkflowRequest(dryrun, requestEnd, null, workflow) + val execWorkflowRequest = ExecuteWorkflowRequest(dryrun, true, requestEnd, null, workflow) client.execute(ExecuteWorkflowAction.INSTANCE, execWorkflowRequest, RestToXContentListener(channel)) } } diff --git a/alerting/src/main/kotlin/org/opensearch/alerting/resthandler/RestGetAlertsAction.kt b/alerting/src/main/kotlin/org/opensearch/alerting/resthandler/RestGetAlertsAction.kt index d147aa299..96f15831f 100644 --- a/alerting/src/main/kotlin/org/opensearch/alerting/resthandler/RestGetAlertsAction.kt +++ b/alerting/src/main/kotlin/org/opensearch/alerting/resthandler/RestGetAlertsAction.kt @@ -11,7 +11,6 @@ import org.opensearch.commons.alerting.action.AlertingActions import org.opensearch.commons.alerting.action.GetAlertsRequest import org.opensearch.commons.alerting.model.Table import org.opensearch.rest.BaseRestHandler -import org.opensearch.rest.BaseRestHandler.RestChannelConsumer import org.opensearch.rest.RestHandler.ReplacedRoute import org.opensearch.rest.RestHandler.Route import org.opensearch.rest.RestRequest @@ -54,7 +53,7 @@ class RestGetAlertsAction : BaseRestHandler() { val size = request.paramAsInt("size", 20) val startIndex = request.paramAsInt("startIndex", 0) val searchString = request.param("searchString", "") - val severityLevel = request.param("severityLevel", "ALL") + val severityLevel = request.param("severityLevel", "ALL") // TODO: stateless and stateful operate on diff sevs val alertState = request.param("alertState", "ALL") val monitorId: String? = request.param("monitorId") val workflowId: String? = request.param("workflowIds") @@ -79,4 +78,9 @@ class RestGetAlertsAction : BaseRestHandler() { client.execute(AlertingActions.GET_ALERTS_ACTION_TYPE, getAlertsRequest, RestToXContentListener(channel)) } } + + companion object { + const val STATEFUL = "stateful" + const val STATELESS = "stateless" + } } diff --git a/alerting/src/main/kotlin/org/opensearch/alerting/resthandler/RestIndexMonitorAction.kt b/alerting/src/main/kotlin/org/opensearch/alerting/resthandler/RestIndexMonitorAction.kt index 5f753edd1..7a9c76e10 100644 --- a/alerting/src/main/kotlin/org/opensearch/alerting/resthandler/RestIndexMonitorAction.kt +++ b/alerting/src/main/kotlin/org/opensearch/alerting/resthandler/RestIndexMonitorAction.kt @@ -18,6 +18,7 @@ import org.opensearch.commons.alerting.model.BucketLevelTrigger import org.opensearch.commons.alerting.model.DocLevelMonitorInput import org.opensearch.commons.alerting.model.DocumentLevelTrigger import org.opensearch.commons.alerting.model.Monitor +import org.opensearch.commons.alerting.model.PPLTrigger import org.opensearch.commons.alerting.model.QueryLevelTrigger import org.opensearch.commons.alerting.model.ScheduledJob import org.opensearch.commons.alerting.util.AlertingException @@ -30,7 +31,6 @@ import org.opensearch.core.xcontent.XContentParser.Token import org.opensearch.core.xcontent.XContentParserUtils.ensureExpectedToken import org.opensearch.index.seqno.SequenceNumbers import org.opensearch.rest.BaseRestHandler -import org.opensearch.rest.BaseRestHandler.RestChannelConsumer import org.opensearch.rest.BytesRestResponse import org.opensearch.rest.RestChannel import org.opensearch.rest.RestHandler.ReplacedRoute @@ -43,7 +43,7 @@ import org.opensearch.rest.action.RestResponseListener import org.opensearch.transport.client.node.NodeClient import java.io.IOException import java.time.Instant -import java.util.* +import java.util.Locale private val log = LogManager.getLogger(RestIndexMonitorAction::class.java) @@ -92,6 +92,7 @@ class RestIndexMonitorAction : BaseRestHandler() { val monitor: Monitor val rbacRoles: List? + try { monitor = Monitor.parse(xcp, id).copy(lastUpdateTime = Instant.now()) @@ -135,6 +136,14 @@ class RestIndexMonitorAction : BaseRestHandler() { } } } + + Monitor.MonitorType.PPL_MONITOR -> { + triggers.forEach { + if (it !is PPLTrigger) { + throw IllegalArgumentException("Illegal trigger type, ${it.javaClass.name}, for PPL monitor") + } + } + } } } } catch (e: Exception) { diff --git a/alerting/src/main/kotlin/org/opensearch/alerting/script/QueryLevelTriggerExecutionContext.kt b/alerting/src/main/kotlin/org/opensearch/alerting/script/QueryLevelTriggerExecutionContext.kt index 2fc5b402b..d2963a8c2 100644 --- a/alerting/src/main/kotlin/org/opensearch/alerting/script/QueryLevelTriggerExecutionContext.kt +++ b/alerting/src/main/kotlin/org/opensearch/alerting/script/QueryLevelTriggerExecutionContext.kt @@ -9,24 +9,34 @@ import org.opensearch.alerting.model.AlertContext import org.opensearch.common.settings.ClusterSettings import org.opensearch.commons.alerting.model.Monitor import org.opensearch.commons.alerting.model.MonitorRunResult +import org.opensearch.commons.alerting.model.PPLTrigger import org.opensearch.commons.alerting.model.QueryLevelTrigger import org.opensearch.commons.alerting.model.QueryLevelTriggerRunResult +import org.opensearch.commons.alerting.model.Trigger import java.time.Instant data class QueryLevelTriggerExecutionContext( override val monitor: Monitor, - val trigger: QueryLevelTrigger, + val trigger: Trigger, override val results: List>, + val pplQueryResults: List>, // each list element is a result row override val periodStart: Instant, override val periodEnd: Instant, val alert: AlertContext? = null, override val error: Exception? = null, - override val clusterSettings: ClusterSettings + override val clusterSettings: ClusterSettings, ) : TriggerExecutionContext(monitor, results, periodStart, periodEnd, error, clusterSettings) { + init { + require(trigger is QueryLevelTrigger || trigger is PPLTrigger) { + "QueryLevelTriggerExecutionContext must only store Triggers for per-query style monitoring, " + + "like QueryLevelTrigger or PPLTrigger" + } + } + constructor( monitor: Monitor, - trigger: QueryLevelTrigger, + trigger: Trigger, monitorRunResult: MonitorRunResult, alertContext: AlertContext? = null, clusterSettings: ClusterSettings @@ -34,6 +44,8 @@ data class QueryLevelTriggerExecutionContext( monitor, trigger, monitorRunResult.inputResults.results, + // PPL Alerting: this empty list is overridden post PPL Trigger execution + listOf(), monitorRunResult.periodStart, monitorRunResult.periodEnd, alertContext, @@ -49,6 +61,7 @@ data class QueryLevelTriggerExecutionContext( val tempArg = super.asTemplateArg().toMutableMap() tempArg["trigger"] = trigger.asTemplateArg() tempArg["alert"] = alert?.asTemplateArg() // map "alert" templateArg field to AlertContext wrapper instead of Alert object + tempArg["ppl_query_results"] = pplQueryResults return tempArg } } diff --git a/alerting/src/main/kotlin/org/opensearch/alerting/service/DeleteMonitorService.kt b/alerting/src/main/kotlin/org/opensearch/alerting/service/DeleteMonitorService.kt index fbc655543..00ba144f8 100644 --- a/alerting/src/main/kotlin/org/opensearch/alerting/service/DeleteMonitorService.kt +++ b/alerting/src/main/kotlin/org/opensearch/alerting/service/DeleteMonitorService.kt @@ -167,7 +167,11 @@ object DeleteMonitorService : } private suspend fun deleteLock(monitor: Monitor) { - client.suspendUntil { lockService.deleteLock(LockModel.generateLockId(monitor.id), it) } + deleteLock(monitor.id) + } + + private suspend fun deleteLock(monitorId: String) { + client.suspendUntil { lockService.deleteLock(LockModel.generateLockId(monitorId), it) } } /** diff --git a/alerting/src/main/kotlin/org/opensearch/alerting/settings/AlertingSettings.kt b/alerting/src/main/kotlin/org/opensearch/alerting/settings/AlertingSettings.kt index ba4dfc159..e0bef8bfa 100644 --- a/alerting/src/main/kotlin/org/opensearch/alerting/settings/AlertingSettings.kt +++ b/alerting/src/main/kotlin/org/opensearch/alerting/settings/AlertingSettings.kt @@ -309,6 +309,50 @@ class AlertingSettings { Setting.Property.NodeScope, Setting.Property.Dynamic ) + val PPL_MONITOR_EXECUTION_MAX_DURATION = Setting.positiveTimeSetting( + "plugins.alerting.ppl_monitor_max_execution_duration", + TimeValue(1, TimeUnit.MINUTES), + Setting.Property.NodeScope, Setting.Property.Dynamic + ) + + val PPL_MAX_QUERY_LENGTH = Setting.longSetting( + "plugins.alerting.ppl_monitor_max_query_length", + 2000L, + 0L, + Setting.Property.NodeScope, Setting.Property.Dynamic + ) + + // max data rows to retrieve when executing PPL query against + // SQL/PPL plugin during monitor execution + val PPL_QUERY_RESULTS_MAX_DATAROWS = Setting.longSetting( + "plugins.alerting.ppl_query_results_max_datarows", + 10000L, + 1L, + Setting.Property.NodeScope, Setting.Property.Dynamic + ) + + // max size of query results to store in alerts and notifications + val PPL_QUERY_RESULTS_MAX_SIZE = Setting.longSetting( + "plugins.alerting.ppl_query_results_max_size", + 3000L, + 0L, + Setting.Property.NodeScope, Setting.Property.Dynamic + ) + + val NOTIFICATION_SUBJECT_SOURCE_MAX_LENGTH = Setting.intSetting( + "plugins.alerting.notification_subject_source_max_length", + 1000, + 100, + Setting.Property.NodeScope, Setting.Property.Dynamic + ) + + val NOTIFICATION_MESSAGE_SOURCE_MAX_LENGTH = Setting.intSetting( + "plugins.alerting.notification_message_source_max_length", + 3000, + 1000, + Setting.Property.NodeScope, Setting.Property.Dynamic + ) + val NOTIFICATION_CONTEXT_RESULTS_ALLOWED_ROLES: Setting> = Setting.listSetting( "plugins.alerting.notification_context_results_allowed_roles", listOf(), diff --git a/alerting/src/main/kotlin/org/opensearch/alerting/transport/TransportDeleteMonitorAction.kt b/alerting/src/main/kotlin/org/opensearch/alerting/transport/TransportDeleteMonitorAction.kt index 97b513230..b23c8ba05 100644 --- a/alerting/src/main/kotlin/org/opensearch/alerting/transport/TransportDeleteMonitorAction.kt +++ b/alerting/src/main/kotlin/org/opensearch/alerting/transport/TransportDeleteMonitorAction.kt @@ -88,7 +88,7 @@ class TransportDeleteMonitorAction @Inject constructor( ) { suspend fun resolveUserAndStart(refreshPolicy: RefreshPolicy) { try { - val monitor = getMonitor() + val monitor = getMonitor() ?: return // null means there was an issue retrieving the Monitor val canDelete = user == null || !doFilterForUser(user) || checkUserPermissionsWithResource(user, monitor.user, actionListener, "monitor", monitorId) diff --git a/alerting/src/main/kotlin/org/opensearch/alerting/transport/TransportDeleteWorkflowAction.kt b/alerting/src/main/kotlin/org/opensearch/alerting/transport/TransportDeleteWorkflowAction.kt index bf0d44eab..e15262a25 100644 --- a/alerting/src/main/kotlin/org/opensearch/alerting/transport/TransportDeleteWorkflowAction.kt +++ b/alerting/src/main/kotlin/org/opensearch/alerting/transport/TransportDeleteWorkflowAction.kt @@ -327,7 +327,7 @@ class TransportDeleteWorkflowAction @Inject constructor( ) } - private fun parseWorkflow(getResponse: GetResponse): Workflow { + private fun parseWorkflow(getResponse: GetResponse): Workflow? { val xcp = XContentHelper.createParser( xContentRegistry, LoggingDeprecationHandler.INSTANCE, getResponse.sourceAsBytesRef, XContentType.JSON diff --git a/alerting/src/main/kotlin/org/opensearch/alerting/transport/TransportExecuteMonitorAction.kt b/alerting/src/main/kotlin/org/opensearch/alerting/transport/TransportExecuteMonitorAction.kt index 88e86509a..da775a818 100644 --- a/alerting/src/main/kotlin/org/opensearch/alerting/transport/TransportExecuteMonitorAction.kt +++ b/alerting/src/main/kotlin/org/opensearch/alerting/transport/TransportExecuteMonitorAction.kt @@ -61,9 +61,16 @@ class TransportExecuteMonitorAction @Inject constructor( private val sdkClient: SdkClient ) : HandledTransportAction ( ExecuteMonitorAction.NAME, transportService, actionFilters, ::ExecuteMonitorRequest -) { +), + SecureTransportAction { @Volatile private var indexTimeout = AlertingSettings.INDEX_TIMEOUT.get(settings) + @Volatile override var filterByEnabled = AlertingSettings.FILTER_BY_BACKEND_ROLES.get(settings) + + init { + listenFilterBySettingChange(clusterService) + } + override fun doExecute(task: Task, execMonitorRequest: ExecuteMonitorRequest, actionListener: ActionListener) { val userStr = client.threadPool().threadContext.getTransient(ConfigConstants.OPENSEARCH_SECURITY_USER_INFO_THREAD_CONTEXT) @@ -89,7 +96,14 @@ class TransportExecuteMonitorAction @Inject constructor( "Executing monitor from API - id: ${monitor.id}, type: ${monitor.monitorType}, " + "periodStart: $periodStart, periodEnd: $periodEnd, dryrun: ${execMonitorRequest.dryrun}" ) - val monitorRunResult = runner.runJob(monitor, periodStart, periodEnd, execMonitorRequest.dryrun, transportService) + val monitorRunResult = runner.runJob( + monitor, + periodStart, + periodEnd, + execMonitorRequest.dryrun, + execMonitorRequest.manual, + transportService + ) withContext(Dispatchers.IO) { actionListener.onResponse(ExecuteMonitorResponse(monitorRunResult)) } diff --git a/alerting/src/main/kotlin/org/opensearch/alerting/transport/TransportExecuteWorkflowAction.kt b/alerting/src/main/kotlin/org/opensearch/alerting/transport/TransportExecuteWorkflowAction.kt index 8b3272e74..7352cfc53 100644 --- a/alerting/src/main/kotlin/org/opensearch/alerting/transport/TransportExecuteWorkflowAction.kt +++ b/alerting/src/main/kotlin/org/opensearch/alerting/transport/TransportExecuteWorkflowAction.kt @@ -78,6 +78,7 @@ class TransportExecuteWorkflowAction @Inject constructor( periodStart, periodEnd, execWorkflowRequest.dryrun, + execWorkflowRequest.manual, transportService = transportService ) withContext(Dispatchers.IO, { diff --git a/alerting/src/main/kotlin/org/opensearch/alerting/transport/TransportGetAlertsAction.kt b/alerting/src/main/kotlin/org/opensearch/alerting/transport/TransportGetAlertsAction.kt index a936ecedb..ea9338d4c 100644 --- a/alerting/src/main/kotlin/org/opensearch/alerting/transport/TransportGetAlertsAction.kt +++ b/alerting/src/main/kotlin/org/opensearch/alerting/transport/TransportGetAlertsAction.kt @@ -27,6 +27,8 @@ import org.opensearch.commons.alerting.action.AlertingActions import org.opensearch.commons.alerting.action.GetAlertsRequest import org.opensearch.commons.alerting.action.GetAlertsResponse import org.opensearch.commons.alerting.model.Alert +import org.opensearch.commons.alerting.model.Alert.Companion.MONITOR_NAME_FIELD +import org.opensearch.commons.alerting.model.Alert.Companion.TRIGGER_NAME_FIELD import org.opensearch.commons.alerting.model.Monitor import org.opensearch.commons.alerting.model.ScheduledJob import org.opensearch.commons.alerting.util.AlertingException @@ -136,8 +138,8 @@ class TransportGetAlertsAction @Inject constructor( QueryBuilders .queryStringQuery(tableProp.searchString) .defaultOperator(Operator.AND) - .field("monitor_name") - .field("trigger_name") + .field(MONITOR_NAME_FIELD) + .field(TRIGGER_NAME_FIELD) ) } val searchSourceBuilder = SearchSourceBuilder() diff --git a/alerting/src/main/kotlin/org/opensearch/alerting/transport/TransportIndexMonitorAction.kt b/alerting/src/main/kotlin/org/opensearch/alerting/transport/TransportIndexMonitorAction.kt index cab10849e..8c87371dd 100644 --- a/alerting/src/main/kotlin/org/opensearch/alerting/transport/TransportIndexMonitorAction.kt +++ b/alerting/src/main/kotlin/org/opensearch/alerting/transport/TransportIndexMonitorAction.kt @@ -8,6 +8,8 @@ package org.opensearch.alerting.transport import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.newSingleThreadContext +import kotlinx.coroutines.withContext import org.apache.logging.log4j.LogManager import org.opensearch.ExceptionsHelper import org.opensearch.OpenSearchException @@ -28,6 +30,10 @@ import org.opensearch.action.support.WriteRequest.RefreshPolicy import org.opensearch.action.support.clustermanager.AcknowledgedResponse import org.opensearch.alerting.AlertingPlugin import org.opensearch.alerting.MonitorMetadataService +import org.opensearch.alerting.PPLUtils.appendCustomCondition +import org.opensearch.alerting.PPLUtils.appendDataRowsLimit +import org.opensearch.alerting.PPLUtils.customConditionIsValid +import org.opensearch.alerting.PPLUtils.executePplQuery import org.opensearch.alerting.core.ScheduledJobIndices import org.opensearch.alerting.opensearchapi.suspendUntil import org.opensearch.alerting.service.DeleteMonitorService @@ -36,6 +42,10 @@ import org.opensearch.alerting.settings.AlertingSettings.Companion.ALERTING_MAX_ import org.opensearch.alerting.settings.AlertingSettings.Companion.INDEX_TIMEOUT import org.opensearch.alerting.settings.AlertingSettings.Companion.MAX_ACTION_THROTTLE_VALUE import org.opensearch.alerting.settings.AlertingSettings.Companion.MAX_TRIGGERS_PER_MONITOR +import org.opensearch.alerting.settings.AlertingSettings.Companion.NOTIFICATION_MESSAGE_SOURCE_MAX_LENGTH +import org.opensearch.alerting.settings.AlertingSettings.Companion.NOTIFICATION_SUBJECT_SOURCE_MAX_LENGTH +import org.opensearch.alerting.settings.AlertingSettings.Companion.PPL_MAX_QUERY_LENGTH +import org.opensearch.alerting.settings.AlertingSettings.Companion.PPL_QUERY_RESULTS_MAX_DATAROWS import org.opensearch.alerting.settings.AlertingSettings.Companion.REQUEST_TIMEOUT import org.opensearch.alerting.settings.DestinationSettings.Companion.ALLOW_LIST import org.opensearch.alerting.util.DocLevelMonitorQueries @@ -58,12 +68,16 @@ import org.opensearch.commons.alerting.action.IndexMonitorResponse import org.opensearch.commons.alerting.model.DocLevelMonitorInput import org.opensearch.commons.alerting.model.DocLevelMonitorInput.Companion.DOC_LEVEL_INPUT_FIELD import org.opensearch.commons.alerting.model.Monitor +import org.opensearch.commons.alerting.model.Monitor.MonitorType import org.opensearch.commons.alerting.model.MonitorMetadata +import org.opensearch.commons.alerting.model.PPLInput +import org.opensearch.commons.alerting.model.PPLTrigger import org.opensearch.commons.alerting.model.ScheduledJob import org.opensearch.commons.alerting.model.ScheduledJob.Companion.SCHEDULED_JOBS_INDEX import org.opensearch.commons.alerting.model.SearchInput import org.opensearch.commons.alerting.model.remote.monitors.RemoteDocLevelMonitorInput import org.opensearch.commons.alerting.model.remote.monitors.RemoteDocLevelMonitorInput.Companion.REMOTE_DOC_LEVEL_MONITOR_INPUT_FIELD +import org.opensearch.commons.alerting.model.userErrorMessage import org.opensearch.commons.alerting.util.AlertingException import org.opensearch.commons.alerting.util.isMonitorOfStandardType import org.opensearch.commons.authuser.User @@ -86,6 +100,7 @@ import org.opensearch.search.builder.SearchSourceBuilder import org.opensearch.tasks.Task import org.opensearch.transport.TransportService import org.opensearch.transport.client.Client +import org.opensearch.transport.client.node.NodeClient import java.io.IOException import java.time.Duration import java.util.Locale @@ -94,7 +109,7 @@ private val log = LogManager.getLogger(TransportIndexMonitorAction::class.java) private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO) class TransportIndexMonitorAction @Inject constructor( - transportService: TransportService, + val transportService: TransportService, val client: Client, actionFilters: ActionFilters, val scheduledJobIndices: ScheduledJobIndices, @@ -115,6 +130,13 @@ class TransportIndexMonitorAction @Inject constructor( @Volatile private var indexTimeout = INDEX_TIMEOUT.get(settings) @Volatile private var maxActionThrottle = MAX_ACTION_THROTTLE_VALUE.get(settings) @Volatile private var allowList = ALLOW_LIST.get(settings) + + // PPL Alerting related settings + @Volatile private var maxQueryLength = PPL_MAX_QUERY_LENGTH.get(settings) + @Volatile private var maxQueryResults = PPL_QUERY_RESULTS_MAX_DATAROWS.get(settings) + @Volatile private var notificationSubjectMaxLength = NOTIFICATION_SUBJECT_SOURCE_MAX_LENGTH.get(settings) + @Volatile private var notificationMessageMaxLength = NOTIFICATION_MESSAGE_SOURCE_MAX_LENGTH.get(settings) + @Volatile override var filterByEnabled = AlertingSettings.FILTER_BY_BACKEND_ROLES.get(settings) init { @@ -124,6 +146,16 @@ class TransportIndexMonitorAction @Inject constructor( clusterService.clusterSettings.addSettingsUpdateConsumer(INDEX_TIMEOUT) { indexTimeout = it } clusterService.clusterSettings.addSettingsUpdateConsumer(MAX_ACTION_THROTTLE_VALUE) { maxActionThrottle = it } clusterService.clusterSettings.addSettingsUpdateConsumer(ALLOW_LIST) { allowList = it } + + clusterService.clusterSettings.addSettingsUpdateConsumer(PPL_MAX_QUERY_LENGTH) { maxQueryLength = it } + clusterService.clusterSettings.addSettingsUpdateConsumer(PPL_QUERY_RESULTS_MAX_DATAROWS) { maxQueryResults = it } + clusterService.clusterSettings.addSettingsUpdateConsumer(NOTIFICATION_SUBJECT_SOURCE_MAX_LENGTH) { + notificationSubjectMaxLength = it + } + clusterService.clusterSettings.addSettingsUpdateConsumer(NOTIFICATION_MESSAGE_SOURCE_MAX_LENGTH) { + notificationMessageMaxLength = it + } + listenFilterBySettingChange(clusterService) } @@ -173,11 +205,13 @@ class TransportIndexMonitorAction @Inject constructor( } } - if (!isADMonitor(transformedRequest.monitor)) { - checkIndicesAndExecute(client, actionListener, transformedRequest, user) - } else { + if (isADMonitor(transformedRequest.monitor)) { // check if user has access to any anomaly detector for AD monitor checkAnomalyDetectorAndExecute(client, actionListener, transformedRequest, user) + } else if (transformedRequest.monitor.monitorType == MonitorType.PPL_MONITOR.value) { + checkPPLQueryAndExecute(actionListener, transformedRequest, user) + } else { + checkIndicesAndExecute(client, actionListener, transformedRequest, user) } } @@ -244,6 +278,189 @@ class TransportIndexMonitorAction @Inject constructor( ) } + private fun checkPPLQueryAndExecute( + actionListener: ActionListener, + indexMonitorRequest: IndexMonitorRequest, + user: User? + ) { + // declare upfront the validation listener that will move on to the next phase + // of monitor indexing after PPL validations are complete + val validationListener = object : ActionListener { // validationListener + override fun onResponse(response: Unit) { + // user permissions to indices have already been checked if we made it to the onResponse(), + // proceed without the context of the user, otherwise, + // we would get permissions errors trying to search the alerting-config + // index as the user. pass the user object itself so backend + // roles can be matched and checked downstream + // roles can be matched and checked downstream + client.threadPool().threadContext.stashContext().use { + val pplMonitor = indexMonitorRequest.monitor + if (user == null) { + indexMonitorRequest.monitor = pplMonitor + .copy(user = User("", listOf(), listOf(), mapOf())) + } else { + indexMonitorRequest.monitor = pplMonitor + .copy(user = User(user.name, user.backendRoles, user.roles, user.customAttributes)) + } + IndexMonitorHandler(client, actionListener, indexMonitorRequest, user).resolveUserAndStart() + } + } + + override fun onFailure(e: Exception) { + actionListener.onFailure(e) + } + } + + // initiate the PPL monitor and PPL query validations + client.threadPool().threadContext.stashContext().use { + scope.launch { + val singleThreadContext = newSingleThreadContext("IndexPPLMonitorActionThread") + withContext(singleThreadContext) { + it.restore() + + val pplMonitor = indexMonitorRequest.monitor + + // validate the PPL query syntax and that user has permissions to + // the indices being queried + val pplQueryValid = validatePPLQuery(pplMonitor, validationListener) + if (!pplQueryValid) { + return@withContext + } + + // run basic validations against the PPL Monitor + val pplMonitorValid = validatePPLMonitor(pplMonitor, validationListener) + if (!pplMonitorValid) { + return@withContext + } + + validationListener.onResponse(Unit) + } + } + } + } + + private suspend fun validatePPLQuery( + pplMonitor: Monitor, + validationListener: ActionListener + ): Boolean { + // first attempt to run the monitor query and all possible + // extensions of it (from custom conditions) + try { + val query = (pplMonitor.inputs[0] as PPLInput).query + + val limitedQueryToExecute = appendDataRowsLimit(query, maxQueryResults) + + // now PPL explain the base query as is. + // if there are any PPL syntax, index not found, insufficient index permissions, or other errors, + // this will throw an exception from the SQL/PPL plugin + executePplQuery(limitedQueryToExecute, true, client as NodeClient) + + // scan all the triggers with custom conditions, and ensure each query constructed + // from the base query + custom condition is valid + for (trigger in pplMonitor.triggers) { + val pplTrigger = trigger as PPLTrigger + + if (pplTrigger.conditionType != PPLTrigger.ConditionType.CUSTOM) { + continue + } + + val customCondition = pplTrigger.customCondition!! + + // validate the custom condition is a where statement and + // not some other valid PPL statement + if (!customConditionIsValid(customCondition)) { + validationListener.onFailure( + AlertingException.wrap( + IllegalArgumentException( + "Custom condition for trigger ${trigger.name} is invalid, " + + "custom condition must be a valid PPL where statement." + ) + ) + ) + return false + } + + val queryWithCustomCondition = appendCustomCondition(query, customCondition) + val limitedQueryWithCustomCondition = appendDataRowsLimit(queryWithCustomCondition, maxQueryResults) + + // if the custom condition is invalid, this will throw an exception + // from the SQL/PPL plugin + executePplQuery(limitedQueryWithCustomCondition, true, client as NodeClient) + } + } catch (e: Exception) { + validationListener.onFailure( + AlertingException.wrap( + IllegalArgumentException("Validation error for PPL Query in PPL Monitor: ${e.userErrorMessage()}") + ) + ) + return false + } + + return true + } + + private fun validatePPLMonitor(pplMonitor: Monitor, validationListener: ActionListener): Boolean { + pplMonitor.triggers.forEach { trigger -> + val pplTrigger = trigger as PPLTrigger + + if (pplTrigger.conditionType == PPLTrigger.ConditionType.NUMBER_OF_RESULTS && + pplTrigger.numResultsValue!! > maxQueryResults + ) { + validationListener.onFailure( + AlertingException.wrap( + IllegalArgumentException( + "Trigger ${trigger.id} checks for number of results threshold of ${trigger.numResultsValue}, " + + "but PPL Alerting is configured only to retrieve $maxQueryResults query results maximum. " + + "Please lower the number of results value to one below this maximum value, or adjust the cluster " + + "setting: $PPL_QUERY_RESULTS_MAX_DATAROWS.key}" + ) + ) + ) + return false + } + + pplTrigger.actions.forEach { action -> + if (action.subjectTemplate?.idOrCode?.length!! > notificationSubjectMaxLength) { + validationListener.onFailure( + AlertingException.wrap( + IllegalArgumentException( + "Notification subject source cannot exceed length: $notificationSubjectMaxLength" + ) + ) + ) + return false + } + + if (action.messageTemplate.idOrCode.length > notificationMessageMaxLength) { + validationListener.onFailure( + AlertingException.wrap( + IllegalArgumentException( + "Notification message source cannot exceed length: $notificationMessageMaxLength" + ) + ) + ) + return false + } + } + } + + val query = (pplMonitor.inputs[0] as PPLInput).query + + // ensure the query length doesn't exceed the limit + if (query.length > maxQueryLength) { + validationListener.onFailure( + AlertingException.wrap( + IllegalArgumentException( + "PPL Query length must be at most $maxQueryLength but was ${query.length}" + ) + ) + ) + return false + } + + return true + } + /** * It's no reasonable to create AD monitor if the user has no access to any detector. Otherwise * the monitor will not get any anomaly result. So we will check user has access to at least 1 @@ -637,6 +854,7 @@ class TransportIndexMonitorAction @Inject constructor( getResponse.sourceAsBytesRef, XContentType.JSON ) val monitor = ScheduledJob.parse(xcp, getResponse.id, getResponse.version) as Monitor + onGetResponse(monitor) } catch (t: Exception) { actionListener.onFailure(AlertingException.wrap(t)) diff --git a/alerting/src/main/kotlin/org/opensearch/alerting/transport/TransportIndexWorkflowAction.kt b/alerting/src/main/kotlin/org/opensearch/alerting/transport/TransportIndexWorkflowAction.kt index 17dc67d92..bc91f6de7 100644 --- a/alerting/src/main/kotlin/org/opensearch/alerting/transport/TransportIndexWorkflowAction.kt +++ b/alerting/src/main/kotlin/org/opensearch/alerting/transport/TransportIndexWorkflowAction.kt @@ -470,7 +470,7 @@ class TransportIndexWorkflowAction @Inject constructor( user, currentWorkflow.user, actionListener, - "workfklow", + "workflow", request.workflowId ) ) { diff --git a/alerting/src/main/kotlin/org/opensearch/alerting/transport/TransportSearchMonitorAction.kt b/alerting/src/main/kotlin/org/opensearch/alerting/transport/TransportSearchMonitorAction.kt index 771133628..77da00f7c 100644 --- a/alerting/src/main/kotlin/org/opensearch/alerting/transport/TransportSearchMonitorAction.kt +++ b/alerting/src/main/kotlin/org/opensearch/alerting/transport/TransportSearchMonitorAction.kt @@ -11,13 +11,13 @@ import org.apache.lucene.search.TotalHits.Relation import org.opensearch.action.ActionRequest import org.opensearch.action.search.SearchRequest import org.opensearch.action.search.SearchResponse -import org.opensearch.action.search.SearchResponse.Clusters import org.opensearch.action.search.ShardSearchFailure import org.opensearch.action.support.ActionFilters import org.opensearch.action.support.HandledTransportAction import org.opensearch.alerting.AlertingPlugin import org.opensearch.alerting.opensearchapi.addFilter import org.opensearch.alerting.settings.AlertingSettings +import org.opensearch.alerting.util.isIndexNotFoundException import org.opensearch.alerting.util.use import org.opensearch.cluster.service.ClusterService import org.opensearch.common.inject.Inject @@ -115,6 +115,7 @@ class TransportSearchMonitorAction @Inject constructor( } } + // Used in Get and Search monitor functionalities to return a "no results" response fun getEmptySearchResponse(): SearchResponse { val internalSearchResponse = InternalSearchResponse( SearchHits(emptyArray(), TotalHits(0L, Relation.EQUAL_TO), 0.0f), diff --git a/alerting/src/main/kotlin/org/opensearch/alerting/util/AlertingUtils.kt b/alerting/src/main/kotlin/org/opensearch/alerting/util/AlertingUtils.kt index 1bb92e838..83dd8839e 100644 --- a/alerting/src/main/kotlin/org/opensearch/alerting/util/AlertingUtils.kt +++ b/alerting/src/main/kotlin/org/opensearch/alerting/util/AlertingUtils.kt @@ -6,13 +6,23 @@ package org.opensearch.alerting.util import org.apache.logging.log4j.LogManager +import org.opensearch.OpenSearchSecurityException import org.opensearch.alerting.AlertService +import org.opensearch.alerting.MonitorRunnerExecutionContext import org.opensearch.alerting.MonitorRunnerService +import org.opensearch.alerting.action.GetDestinationsAction +import org.opensearch.alerting.action.GetDestinationsRequest +import org.opensearch.alerting.action.GetDestinationsResponse import org.opensearch.alerting.model.AlertContext import org.opensearch.alerting.model.destination.Destination +import org.opensearch.alerting.opensearchapi.suspendUntil import org.opensearch.alerting.script.BucketLevelTriggerExecutionContext import org.opensearch.alerting.script.DocumentLevelTriggerExecutionContext -import org.opensearch.alerting.settings.DestinationSettings +import org.opensearch.alerting.util.destinationmigration.NotificationActionConfigs +import org.opensearch.alerting.util.destinationmigration.NotificationApiUtils.Companion.getNotificationConfigInfo +import org.opensearch.alerting.util.destinationmigration.getTitle +import org.opensearch.alerting.util.destinationmigration.publishLegacyNotification +import org.opensearch.alerting.util.destinationmigration.sendNotification import org.opensearch.cluster.service.ClusterService import org.opensearch.common.settings.Settings import org.opensearch.common.util.concurrent.ThreadContext @@ -21,13 +31,18 @@ import org.opensearch.commons.alerting.model.BucketLevelTrigger import org.opensearch.commons.alerting.model.BucketLevelTriggerRunResult import org.opensearch.commons.alerting.model.DocumentLevelTrigger import org.opensearch.commons.alerting.model.Monitor +import org.opensearch.commons.alerting.model.Table import org.opensearch.commons.alerting.model.Trigger import org.opensearch.commons.alerting.model.action.Action import org.opensearch.commons.alerting.model.action.ActionExecutionPolicy import org.opensearch.commons.alerting.model.action.ActionExecutionScope import org.opensearch.commons.alerting.util.isBucketLevelMonitor import org.opensearch.commons.alerting.util.isMonitorOfStandardType +import org.opensearch.commons.notifications.model.NotificationConfigInfo +import org.opensearch.index.IndexNotFoundException import org.opensearch.script.Script +import org.opensearch.transport.RemoteTransportException +import org.opensearch.transport.client.node.NodeClient import java.util.Locale import kotlin.math.max @@ -287,3 +302,118 @@ fun printsSampleDocData(trigger: Trigger): Boolean { validTags.any { tag -> action.messageTemplate.idOrCode.contains(tag) } } } + +// Checks if the exception is caused by an IndexNotFoundException (directly or nested). +// Used in Get and Search monitor functionalities to determine whether a "no results" +// response should be returned +fun isIndexNotFoundException(e: Exception): Boolean { + if (e is IndexNotFoundException) { + return true + } + if (e is RemoteTransportException) { + val cause = e.cause + if (cause is IndexNotFoundException) { + return true + } + } + return false +} + +suspend fun getConfigAndSendNotification( + action: Action, + monitorCtx: MonitorRunnerExecutionContext, + subject: String?, + message: String +): String { + val config = getConfigForNotificationAction(action, monitorCtx) + if (config.destination == null && config.channel == null) { + throw IllegalStateException("Unable to find a Notification Channel or Destination config with id [${action.destinationId}]") + } + + // Adding a check on TEST_ACTION Destination type here to avoid supporting it as a LegacyBaseMessage type + // just for Alerting integration tests + if (config.destination?.isTestAction() == true) { + return "test action" + } + + if (config.destination?.isAllowed(monitorCtx.allowList) == false) { + throw IllegalStateException( + "Monitor contains a Destination type that is not allowed: ${config.destination.type}" + ) + } + + var actionResponseContent = "" + actionResponseContent = config.channel + ?.sendNotification( + monitorCtx.client!!, + config.channel.getTitle(subject), + message + ) ?: actionResponseContent + + actionResponseContent = config.destination + ?.buildLegacyBaseMessage(subject, message, monitorCtx.destinationContextFactory!!.getDestinationContext(config.destination)) + ?.publishLegacyNotification(monitorCtx.client!!) + ?: actionResponseContent + + return actionResponseContent +} + +/** + * The "destination" ID referenced in a Monitor Action could either be a Notification config or a Destination config + * depending on whether the background migration process has already migrated it from a Destination to a Notification config. + * + * To cover both of these cases, the Notification config will take precedence and if it is not found, the Destination will be retrieved. + */ +private suspend fun getConfigForNotificationAction( + action: Action, + monitorCtx: MonitorRunnerExecutionContext +): NotificationActionConfigs { + var destination: Destination? = null + var notificationPermissionException: Exception? = null + + var channel: NotificationConfigInfo? = null + try { + channel = getNotificationConfigInfo(monitorCtx.client as NodeClient, action.destinationId) + } catch (e: OpenSearchSecurityException) { + notificationPermissionException = e + } + + // If the channel was not found, try to retrieve the Destination + if (channel == null) { + destination = try { + val table = Table( + "asc", + "destination.name.keyword", + null, + 1, + 0, + null + ) + val getDestinationsRequest = GetDestinationsRequest( + action.destinationId, + 0L, + null, + table, + "ALL" + ) + + val getDestinationsResponse: GetDestinationsResponse = monitorCtx.client!!.suspendUntil { + monitorCtx.client!!.execute(GetDestinationsAction.INSTANCE, getDestinationsRequest, it) + } + getDestinationsResponse.destinations.firstOrNull() + } catch (e: IllegalStateException) { + // Catching the exception thrown when the Destination was not found so the NotificationActionConfigs object can be returned + null + } catch (e: OpenSearchSecurityException) { + if (notificationPermissionException != null) + throw notificationPermissionException + else + throw e + } + + if (destination == null && notificationPermissionException != null) + throw notificationPermissionException + } + + return NotificationActionConfigs(destination, channel) +} diff --git a/alerting/src/main/kotlin/org/opensearch/alerting/util/IndexUtils.kt b/alerting/src/main/kotlin/org/opensearch/alerting/util/IndexUtils.kt index 093b0bd39..994293f1d 100644 --- a/alerting/src/main/kotlin/org/opensearch/alerting/util/IndexUtils.kt +++ b/alerting/src/main/kotlin/org/opensearch/alerting/util/IndexUtils.kt @@ -6,6 +6,7 @@ package org.opensearch.alerting.util import org.opensearch.action.admin.indices.mapping.put.PutMappingRequest +import org.opensearch.action.index.IndexResponse import org.opensearch.action.support.IndicesOptions import org.opensearch.action.support.clustermanager.AcknowledgedResponse import org.opensearch.alerting.alerts.AlertIndices @@ -47,6 +48,7 @@ class IndexUtils { private set var commentsIndexUpdated: Boolean = false private set + var lastUpdatedAlertHistoryIndex: String? = null var lastUpdatedFindingHistoryIndex: String? = null var lastUpdatedCommentsHistoryIndex: String? = null @@ -205,5 +207,18 @@ class IndexUtils { fun getCreationDateForIndex(index: String, clusterState: ClusterState): Long { return clusterState.metadata.index(index).creationDate } + + @JvmStatic + fun checkShardsFailure(response: IndexResponse): String? { + val failureReasons = StringBuilder() + if (response.shardInfo.failed > 0) { + response.shardInfo.failures.forEach { + entry -> + failureReasons.append(entry.reason()) + } + return failureReasons.toString() + } + return null + } } } diff --git a/alerting/src/main/kotlin/org/opensearch/alerting/workflow/CompositeWorkflowRunner.kt b/alerting/src/main/kotlin/org/opensearch/alerting/workflow/CompositeWorkflowRunner.kt index 1e613bd0f..5f47fe360 100644 --- a/alerting/src/main/kotlin/org/opensearch/alerting/workflow/CompositeWorkflowRunner.kt +++ b/alerting/src/main/kotlin/org/opensearch/alerting/workflow/CompositeWorkflowRunner.kt @@ -56,6 +56,7 @@ object CompositeWorkflowRunner : WorkflowRunner() { periodStart: Instant, periodEnd: Instant, dryRun: Boolean, + manual: Boolean, transportService: TransportService ): WorkflowRunResult { val workflowExecutionStartTime = Instant.now() @@ -145,6 +146,7 @@ object CompositeWorkflowRunner : WorkflowRunner() { periodStart, periodEnd, dryRun, + manual, workflowRunContext, executionId, transportService @@ -249,6 +251,7 @@ object CompositeWorkflowRunner : WorkflowRunner() { periodStart: Instant, periodEnd: Instant, dryRun: Boolean, + manual: Boolean, workflowRunContext: WorkflowRunContext, executionId: String, transportService: TransportService @@ -261,6 +264,7 @@ object CompositeWorkflowRunner : WorkflowRunner() { periodStart, periodEnd, dryRun, + manual, workflowRunContext, executionId, transportService @@ -272,6 +276,7 @@ object CompositeWorkflowRunner : WorkflowRunner() { periodStart, periodEnd, dryRun, + manual, workflowRunContext, executionId, transportService @@ -283,6 +288,7 @@ object CompositeWorkflowRunner : WorkflowRunner() { periodStart, periodEnd, dryRun, + manual, workflowRunContext, executionId, transportService diff --git a/alerting/src/main/kotlin/org/opensearch/alerting/workflow/WorkflowRunner.kt b/alerting/src/main/kotlin/org/opensearch/alerting/workflow/WorkflowRunner.kt index ea24da3a6..574b6a670 100644 --- a/alerting/src/main/kotlin/org/opensearch/alerting/workflow/WorkflowRunner.kt +++ b/alerting/src/main/kotlin/org/opensearch/alerting/workflow/WorkflowRunner.kt @@ -5,36 +5,21 @@ package org.opensearch.alerting.workflow -import org.opensearch.OpenSearchSecurityException import org.opensearch.alerting.MonitorRunnerExecutionContext import org.opensearch.alerting.MonitorRunnerService -import org.opensearch.alerting.action.GetDestinationsAction -import org.opensearch.alerting.action.GetDestinationsRequest -import org.opensearch.alerting.action.GetDestinationsResponse -import org.opensearch.alerting.model.destination.Destination import org.opensearch.alerting.opensearchapi.InjectorContextElement -import org.opensearch.alerting.opensearchapi.suspendUntil import org.opensearch.alerting.opensearchapi.withClosableContext import org.opensearch.alerting.script.ChainedAlertTriggerExecutionContext -import org.opensearch.alerting.util.destinationmigration.NotificationActionConfigs -import org.opensearch.alerting.util.destinationmigration.NotificationApiUtils -import org.opensearch.alerting.util.destinationmigration.getTitle -import org.opensearch.alerting.util.destinationmigration.publishLegacyNotification -import org.opensearch.alerting.util.destinationmigration.sendNotification -import org.opensearch.alerting.util.isAllowed -import org.opensearch.alerting.util.isTestAction +import org.opensearch.alerting.util.getConfigAndSendNotification import org.opensearch.alerting.util.use import org.opensearch.commons.alerting.model.ActionRunResult -import org.opensearch.commons.alerting.model.Table import org.opensearch.commons.alerting.model.Workflow import org.opensearch.commons.alerting.model.WorkflowRunResult import org.opensearch.commons.alerting.model.action.Action -import org.opensearch.commons.notifications.model.NotificationConfigInfo import org.opensearch.core.common.Strings import org.opensearch.script.Script import org.opensearch.script.TemplateScript import org.opensearch.transport.TransportService -import org.opensearch.transport.client.node.NodeClient import java.time.Instant abstract class WorkflowRunner { @@ -44,6 +29,7 @@ abstract class WorkflowRunner { periodStart: Instant, periodEnd: Instant, dryRun: Boolean, + manual: Boolean, transportService: TransportService ): WorkflowRunResult @@ -93,107 +79,6 @@ abstract class WorkflowRunner { } } - protected suspend fun getConfigAndSendNotification( - action: Action, - monitorCtx: MonitorRunnerExecutionContext, - subject: String?, - message: String - ): String { - val config = getConfigForNotificationAction(action, monitorCtx) - if (config.destination == null && config.channel == null) { - throw IllegalStateException("Unable to find a Notification Channel or Destination config with id [${action.destinationId}]") - } - - // Adding a check on TEST_ACTION Destination type here to avoid supporting it as a LegacyBaseMessage type - // just for Alerting integration tests - if (config.destination?.isTestAction() == true) { - return "test action" - } - - if (config.destination?.isAllowed(monitorCtx.allowList) == false) { - throw IllegalStateException( - "Monitor contains a Destination type that is not allowed: ${config.destination.type}" - ) - } - - var actionResponseContent = "" - actionResponseContent = config.channel - ?.sendNotification( - monitorCtx.client!!, - config.channel.getTitle(subject), - message - ) ?: actionResponseContent - - actionResponseContent = config.destination - ?.buildLegacyBaseMessage(subject, message, monitorCtx.destinationContextFactory!!.getDestinationContext(config.destination)) - ?.publishLegacyNotification(monitorCtx.client!!) - ?: actionResponseContent - - return actionResponseContent - } - - /** - * The "destination" ID referenced in a Monitor Action could either be a Notification config or a Destination config - * depending on whether the background migration process has already migrated it from a Destination to a Notification config. - * - * To cover both of these cases, the Notification config will take precedence and if it is not found, the Destination will be retrieved. - */ - private suspend fun getConfigForNotificationAction( - action: Action, - monitorCtx: MonitorRunnerExecutionContext - ): NotificationActionConfigs { - var destination: Destination? = null - var notificationPermissionException: Exception? = null - - var channel: NotificationConfigInfo? = null - try { - channel = NotificationApiUtils.getNotificationConfigInfo(monitorCtx.client as NodeClient, action.destinationId) - } catch (e: OpenSearchSecurityException) { - notificationPermissionException = e - } - - // If the channel was not found, try to retrieve the Destination - if (channel == null) { - destination = try { - val table = Table( - "asc", - "destination.name.keyword", - null, - 1, - 0, - null - ) - val getDestinationsRequest = GetDestinationsRequest( - action.destinationId, - 0L, - null, - table, - "ALL" - ) - - val getDestinationsResponse: GetDestinationsResponse = monitorCtx.client!!.suspendUntil { - monitorCtx.client!!.execute(GetDestinationsAction.INSTANCE, getDestinationsRequest, it) - } - getDestinationsResponse.destinations.firstOrNull() - } catch (e: IllegalStateException) { - // Catching the exception thrown when the Destination was not found so the NotificationActionConfigs object can be returned - null - } catch (e: OpenSearchSecurityException) { - if (notificationPermissionException != null) { - throw notificationPermissionException - } else { - throw e - } - } - - if (destination == null && notificationPermissionException != null) { - throw notificationPermissionException - } - } - - return NotificationActionConfigs(destination, channel) - } - internal fun compileTemplate(template: Script, ctx: ChainedAlertTriggerExecutionContext): String { return MonitorRunnerService.monitorCtx.scriptService!!.compile(template, TemplateScript.CONTEXT) .newInstance(template.params + mapOf("ctx" to ctx.asTemplateArg())) diff --git a/alerting/src/main/resources/org/opensearch/alerting/alerts/alert_mapping.json b/alerting/src/main/resources/org/opensearch/alerting/alerts/alert_mapping.json index 76e5104cc..12a3d86d2 100644 --- a/alerting/src/main/resources/org/opensearch/alerting/alerts/alert_mapping.json +++ b/alerting/src/main/resources/org/opensearch/alerting/alerts/alert_mapping.json @@ -4,7 +4,7 @@ "required": true }, "_meta" : { - "schema_version": 5 + "schema_version": 6 }, "properties": { "schema_version": { @@ -177,6 +177,13 @@ "type" : "keyword" } } + }, + "query": { + "type": "text" + }, + "query_results": { + "type": "nested", + "dynamic": true } } } \ No newline at end of file diff --git a/alerting/src/main/resources/org/opensearch/alerting/org.opensearch.alerting.txt b/alerting/src/main/resources/org/opensearch/alerting/org.opensearch.alerting.txt index c8ac9e253..47d8eda2b 100644 --- a/alerting/src/main/resources/org/opensearch/alerting/org.opensearch.alerting.txt +++ b/alerting/src/main/resources/org/opensearch/alerting/org.opensearch.alerting.txt @@ -23,7 +23,7 @@ class org.opensearch.alerting.script.TriggerExecutionContext { class org.opensearch.alerting.script.QueryLevelTriggerExecutionContext { Monitor getMonitor() - QueryLevelTrigger getTrigger() + Trigger getTrigger() List getResults() java.time.Instant getPeriodStart() java.time.Instant getPeriodEnd() @@ -38,7 +38,7 @@ class org.opensearch.commons.alerting.model.Monitor { boolean getEnabled() } -class org.opensearch.commons.alerting.model.QueryLevelTrigger { +class org.opensearch.commons.alerting.model.Trigger { String getId() String getName() String getSeverity() diff --git a/alerting/src/test/kotlin/org/opensearch/alerting/AccessRoles.kt b/alerting/src/test/kotlin/org/opensearch/alerting/AccessRoles.kt index d14473884..133504168 100644 --- a/alerting/src/test/kotlin/org/opensearch/alerting/AccessRoles.kt +++ b/alerting/src/test/kotlin/org/opensearch/alerting/AccessRoles.kt @@ -9,6 +9,7 @@ import org.opensearch.alerting.action.ExecuteWorkflowAction import org.opensearch.commons.alerting.action.AlertingActions val ALL_ACCESS_ROLE = "all_access" +val PPL_FULL_ACCESS_ROLE = "ppl_full_access" val READALL_AND_MONITOR_ROLE = "readall_and_monitor" val ALERTING_FULL_ACCESS_ROLE = "alerting_full_access" val ALERTING_ACK_ALERTS_ROLE = "alerting_ack_alerts" @@ -31,6 +32,7 @@ val ALERTING_GET_ALERTS_ACCESS = "alerting_get_alerts_access" val ALERTING_INDEX_WORKFLOW_ACCESS = "alerting_index_workflow_access" val ROLE_TO_PERMISSION_MAPPING = mapOf( + ALL_ACCESS_ROLE to "*", ALERTING_NO_ACCESS_ROLE to "", ALERTING_GET_EMAIL_ACCOUNT_ACCESS to "cluster:admin/opendistro/alerting/destination/email_account/get", ALERTING_SEARCH_EMAIL_ACCOUNT_ACCESS to "cluster:admin/opendistro/alerting/destination/email_account/search", diff --git a/alerting/src/test/kotlin/org/opensearch/alerting/AlertingRestTestCase.kt b/alerting/src/test/kotlin/org/opensearch/alerting/AlertingRestTestCase.kt index 77ff20006..6cf36f982 100644 --- a/alerting/src/test/kotlin/org/opensearch/alerting/AlertingRestTestCase.kt +++ b/alerting/src/test/kotlin/org/opensearch/alerting/AlertingRestTestCase.kt @@ -54,6 +54,8 @@ import org.opensearch.commons.alerting.model.DocumentLevelTrigger import org.opensearch.commons.alerting.model.Finding import org.opensearch.commons.alerting.model.FindingWithDocs import org.opensearch.commons.alerting.model.Monitor +import org.opensearch.commons.alerting.model.PPLInput +import org.opensearch.commons.alerting.model.PPLTrigger import org.opensearch.commons.alerting.model.QueryLevelTrigger import org.opensearch.commons.alerting.model.ScheduledJob import org.opensearch.commons.alerting.model.SearchInput @@ -66,23 +68,25 @@ import org.opensearch.core.xcontent.ToXContent import org.opensearch.core.xcontent.XContentBuilder import org.opensearch.core.xcontent.XContentParser import org.opensearch.core.xcontent.XContentParserUtils +import org.opensearch.index.query.QueryBuilders import org.opensearch.search.SearchModule import org.opensearch.search.builder.SearchSourceBuilder +import org.opensearch.test.OpenSearchTestCase import java.net.URLEncoder import java.nio.file.Files import java.time.Instant import java.time.ZonedDateTime import java.time.format.DateTimeFormatter import java.time.temporal.ChronoUnit +import java.time.temporal.ChronoUnit.MILLIS import java.util.Locale import java.util.UUID +import java.util.concurrent.TimeUnit import java.util.stream.Collectors import javax.management.MBeanServerInvocationHandler import javax.management.ObjectName import javax.management.remote.JMXConnectorFactory import javax.management.remote.JMXServiceURL -import kotlin.collections.ArrayList -import kotlin.collections.HashMap /** * Superclass for tests that interact with an external test cluster using OpenSearch's RestClient @@ -105,9 +109,11 @@ abstract class AlertingRestTestCase : ODFERestTestCase() { Monitor.XCONTENT_REGISTRY, SearchInput.XCONTENT_REGISTRY, DocLevelMonitorInput.XCONTENT_REGISTRY, + PPLInput.XCONTENT_REGISTRY, QueryLevelTrigger.XCONTENT_REGISTRY, BucketLevelTrigger.XCONTENT_REGISTRY, DocumentLevelTrigger.XCONTENT_REGISTRY, + PPLTrigger.XCONTENT_REGISTRY, Workflow.XCONTENT_REGISTRY, ChainedAlertTrigger.XCONTENT_REGISTRY ) + SearchModule(Settings.EMPTY, emptyList()).namedXContents @@ -150,6 +156,22 @@ abstract class AlertingRestTestCase : ODFERestTestCase() { return getMonitor(monitorId = monitorJson["_id"] as String) } + // used only for PPL Monitor tests + // a createMonitorWithClient() wrapper that creates an index before proceeding + protected fun createPPLIndexThenMonitorWithClient( + client: RestClient, + monitor: Monitor, + rbacRoles: List? = null, + refresh: Boolean = true + ): Monitor { + // every random ppl monitor's query searches index TEST_INDEX_NAME + // by default, so create that first before creating the monitor + if (!indexExists(TEST_INDEX_NAME)) { + createIndex(TEST_INDEX_NAME, Settings.EMPTY, TEST_INDEX_MAPPINGS) + } + return createMonitorWithClient(client, monitor, rbacRoles, refresh) + } + protected fun createMonitor(monitor: Monitor, refresh: Boolean = true): Monitor { return createMonitorWithClient(client(), monitor, emptyList(), refresh) } @@ -535,6 +557,19 @@ abstract class AlertingRestTestCase : ODFERestTestCase() { return getMonitor(monitorId = monitorId) } + protected fun createRandomPPLMonitor(pplMonitorConfig: Monitor = randomPPLMonitor()): Monitor { + // every random ppl monitor's query searches index TEST_INDEX_NAME + // by default, so create that first before creating the monitor + val indexExistsResponse = adminClient().makeRequest("HEAD", TEST_INDEX_NAME) + if (indexExistsResponse.restStatus() == RestStatus.NOT_FOUND) { + createIndex(TEST_INDEX_NAME, Settings.EMPTY, TEST_INDEX_MAPPINGS) + } + logger.info("ppl monitor: $pplMonitorConfig") + + val pplMonitorId = createMonitor(pplMonitorConfig).id + return getMonitor(monitorId = pplMonitorId) + } + protected fun createRandomDocumentMonitor(refresh: Boolean = false, withMetadata: Boolean = false): Monitor { val monitor = randomDocumentLevelMonitor(withMetadata = withMetadata) val monitorId = createMonitor(monitor, refresh).id @@ -644,9 +679,9 @@ abstract class AlertingRestTestCase : ODFERestTestCase() { if (refresh) refreshIndex(indices) val request = """ - { "version" : true, - "query": { "match_all": {} } - } + { "version" : true, + "query": { "match_all": {} } + } """.trimIndent() val httpResponse = adminClient().makeRequest("GET", "/$indices/_search", StringEntity(request, APPLICATION_JSON)) assertEquals("Search failed", RestStatus.OK, httpResponse.restStatus()) @@ -691,9 +726,9 @@ abstract class AlertingRestTestCase : ODFERestTestCase() { if (refresh) refreshIndex(indices) val request = """ - { "version" : true, - "query": { "match_all": {} } - } + { "version" : true, + "query": { "match_all": {} } + } """.trimIndent() val httpResponse = adminClient().makeRequest("GET", "/$indices/_search", StringEntity(request, APPLICATION_JSON)) assertEquals("Search failed", RestStatus.OK, httpResponse.restStatus()) @@ -716,9 +751,9 @@ abstract class AlertingRestTestCase : ODFERestTestCase() { // If this is a test monitor (it doesn't have an ID) and no alerts will be saved for it. val searchParams = if (monitor.id != Monitor.NO_ID) mapOf("routing" to monitor.id) else mapOf() val request = """ - { "version" : true, - "query" : { "term" : { "${Alert.MONITOR_ID_FIELD}" : "${monitor.id}" } } - } + { "version" : true, + "query" : { "term" : { "${Alert.MONITOR_ID_FIELD}" : "${monitor.id}" } } + } """.trimIndent() val httpResponse = adminClient().makeRequest("GET", "/$indices/_search", searchParams, StringEntity(request, APPLICATION_JSON)) assertEquals("Search failed", RestStatus.OK, httpResponse.restStatus()) @@ -880,9 +915,9 @@ abstract class AlertingRestTestCase : ODFERestTestCase() { protected fun searchMonitors(): SearchResponse { var baseEndpoint = "${AlertingPlugin.MONITOR_BASE_URI}/_search?" val request = """ - { "version" : true, - "query": { "match_all": {} } - } + { "version" : true, + "query": { "match_all": {} } + } """.trimIndent() val httpResponse = adminClient().makeRequest("POST", baseEndpoint, StringEntity(request, APPLICATION_JSON)) assertEquals("Search failed", RestStatus.OK, httpResponse.restStatus()) @@ -1215,9 +1250,9 @@ abstract class AlertingRestTestCase : ODFERestTestCase() { val testTime = DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(twoMinsAgo) val testDoc = """ { - "test_strict_date_time": "$testTime", - "test_field": "$value", - "number": "$i" + "test_strict_date_time": "$testTime", + "test_field": "$value", + "number": "$i" } """.trimIndent() // Indexing documents with deterministic doc id to allow for easy selected deletion during testing @@ -1231,9 +1266,9 @@ abstract class AlertingRestTestCase : ODFERestTestCase() { val testTime = DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(time) val testDoc = """ { - "test_strict_date_time": "$testTime", - "test_field": "$value", - "number": "$i" + "test_strict_date_time": "$testTime", + "test_field": "$value", + "number": "$i" } """.trimIndent() // Indexing documents with deterministic doc id to allow for easy selected deletion during testing @@ -1250,9 +1285,9 @@ abstract class AlertingRestTestCase : ODFERestTestCase() { val testTime = DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(time) val testDoc = """ { - "test_strict_date_time": "$testTime", - "test_field": "$value", - "number": "$i" + "test_strict_date_time": "$testTime", + "test_field": "$value", + "number": "$i" } """.trimIndent() // Indexing documents with deterministic doc id to allow for easy selected deletion during testing @@ -1433,8 +1468,9 @@ abstract class AlertingRestTestCase : ODFERestTestCase() { return map[key] } - fun getAlertingStats(metrics: String = ""): Map { - val monitorStatsResponse = client().makeRequest("GET", "/_plugins/_alerting/stats$metrics") + fun getAlertingStats(metrics: String = "", alertingVersion: String? = null): Map { + val endpoint = "/_plugins/_alerting/stats$metrics${alertingVersion?.let { "?version=$it" }.orEmpty()}" + val monitorStatsResponse = client().makeRequest("GET", endpoint) val responseMap = createParser(XContentType.JSON.xContent(), monitorStatsResponse.entity.content).map() return responseMap } @@ -1512,11 +1548,13 @@ abstract class AlertingRestTestCase : ODFERestTestCase() { val customAttributesString = customAttributes.entries.joinToString(prefix = "{", separator = ", ", postfix = "}") { "\"${it.key}\": \"${it.value}\"" } - var entity = " {\n" + - "\"password\": \"$password\",\n" + - "\"backend_roles\": [$broles],\n" + - "\"attributes\": $customAttributesString\n" + - "} " + var entity = """ + { + "password": "$password", + "backend_roles": [$broles], + "attributes": $customAttributesString + } + """.trimIndent() request.setJsonEntity(entity) client().performRequest(request) } @@ -1524,61 +1562,52 @@ abstract class AlertingRestTestCase : ODFERestTestCase() { fun patchUserBackendRoles(name: String, backendRoles: Array) { val request = Request("PATCH", "/_plugins/_security/api/internalusers/$name") val broles = backendRoles.joinToString { "\"$it\"" } - var entity = " [{\n" + - "\"op\": \"replace\",\n" + - "\"path\": \"/backend_roles\",\n" + - "\"value\": [$broles]\n" + - "}]" + var entity = """ + [{ + "op": "replace", + "path": "/backend_roles", + "value": [$broles] + }] + """.trimIndent() request.setJsonEntity(entity) client().performRequest(request) } fun createIndexRole(name: String, index: String) { val request = Request("PUT", "/_plugins/_security/api/roles/$name") - var entity = "{\n" + - "\"cluster_permissions\": [\n" + - "],\n" + - "\"index_permissions\": [\n" + - "{\n" + - "\"index_patterns\": [\n" + - "\"$index\"\n" + - "],\n" + - "\"dls\": \"\",\n" + - "\"fls\": [],\n" + - "\"masked_fields\": [],\n" + - "\"allowed_actions\": [\n" + - "\"crud\"\n" + - "]\n" + - "}\n" + - "],\n" + - "\"tenant_permissions\": []\n" + - "}" + var entity = """ + { + "cluster_permissions": [], + "index_permissions": [{ + "index_patterns": ["$index"], + "dls": "", + "fls": [], + "masked_fields": [], + "allowed_actions": ["crud"] + }], + "tenant_permissions": [] + } + """.trimIndent() request.setJsonEntity(entity) client().performRequest(request) } fun createCustomIndexRole(name: String, index: String, clusterPermissions: String?) { val request = Request("PUT", "/_plugins/_security/api/roles/$name") - val clusterPermissionsArray = if (clusterPermissions.isNullOrBlank()) "" else "\"$clusterPermissions\"" - var entity = "{\n" + - "\"cluster_permissions\": [\n" + - "$clusterPermissionsArray\n" + - "],\n" + - "\"index_permissions\": [\n" + - "{\n" + - "\"index_patterns\": [\n" + - "\"$index\"\n" + - "],\n" + - "\"dls\": \"\",\n" + - "\"fls\": [],\n" + - "\"masked_fields\": [],\n" + - "\"allowed_actions\": [\n" + - "\"crud\"\n" + - "]\n" + - "}\n" + - "],\n" + - "\"tenant_permissions\": []\n" + - "}" + val clusterPerms = if (clusterPermissions.isNullOrEmpty()) "[]" else "[\"$clusterPermissions\"]" + var entity = """ + { + "cluster_permissions": $clusterPerms, + "index_permissions": [{ + "index_patterns": ["$index"], + "dls": "", + "fls": [], + "masked_fields": [], + "allowed_actions": ["crud"] + }], + "tenant_permissions": [] + } + """.trimIndent() request.setJsonEntity(entity) client().performRequest(request) } @@ -1587,55 +1616,43 @@ abstract class AlertingRestTestCase : ODFERestTestCase() { val request = Request("PUT", "/_plugins/_security/api/roles/$name") val clusterPermissionsStr = - clusterPermissions.stream() - .filter { p -> !p.isNullOrBlank() } - .map { p: String? -> "\"" + p + "\"" } - .collect(Collectors.joining(",")) - - var entity = "{\n" + - "\"cluster_permissions\": [\n" + - "$clusterPermissionsStr\n" + - "],\n" + - "\"index_permissions\": [\n" + - "{\n" + - "\"index_patterns\": [\n" + - "\"$index\"\n" + - "],\n" + - "\"dls\": \"\",\n" + - "\"fls\": [],\n" + - "\"masked_fields\": [],\n" + - "\"allowed_actions\": [\n" + - "\"crud\"\n" + - "]\n" + - "}\n" + - "],\n" + - "\"tenant_permissions\": []\n" + - "}" + clusterPermissions.stream().map { p: String? -> "\"" + p + "\"" }.collect( + Collectors.joining(",") + ) + + var entity = """ + { + "cluster_permissions": [$clusterPermissionsStr], + "index_permissions": [{ + "index_patterns": ["$index"], + "dls": "", + "fls": [], + "masked_fields": [], + "allowed_actions": ["crud"] + }], + "tenant_permissions": [] + } + """.trimIndent() request.setJsonEntity(entity) client().performRequest(request) } fun createIndexRoleWithDocLevelSecurity(name: String, index: String, dlsQuery: String, clusterPermissions: String? = "") { val request = Request("PUT", "/_plugins/_security/api/roles/$name") - var entity = "{\n" + - "\"cluster_permissions\": [\n" + - "\"$clusterPermissions\"\n" + - "],\n" + - "\"index_permissions\": [\n" + - "{\n" + - "\"index_patterns\": [\n" + - "\"$index\"\n" + - "],\n" + - "\"dls\": \"$dlsQuery\",\n" + - "\"fls\": [],\n" + - "\"masked_fields\": [],\n" + - "\"allowed_actions\": [\n" + - "\"crud\"\n" + - "]\n" + - "}\n" + - "],\n" + - "\"tenant_permissions\": []\n" + - "}" + val clusterPerms = if (clusterPermissions.isNullOrEmpty()) "[]" else "[\"$clusterPermissions\"]" + var entity = """ + { + "cluster_permissions": $clusterPerms, + "index_permissions": [{ + "index_patterns": ["$index"], + "dls": "$dlsQuery", + "fls": [], + "masked_fields": [], + "allowed_actions": ["crud"] + }], + "tenant_permissions": [] + } + """.trimIndent() request.setJsonEntity(entity) client().performRequest(request) } @@ -1647,25 +1664,19 @@ abstract class AlertingRestTestCase : ODFERestTestCase() { ) val request = Request("PUT", "/_plugins/_security/api/roles/$name") - var entity = "{\n" + - "\"cluster_permissions\": [\n" + - "$clusterPermissionsStr\n" + - "],\n" + - "\"index_permissions\": [\n" + - "{\n" + - "\"index_patterns\": [\n" + - "\"$index\"\n" + - "],\n" + - "\"dls\": \"$dlsQuery\",\n" + - "\"fls\": [],\n" + - "\"masked_fields\": [],\n" + - "\"allowed_actions\": [\n" + - "\"crud\"\n" + - "]\n" + - "}\n" + - "],\n" + - "\"tenant_permissions\": []\n" + - "}" + var entity = """ + { + "cluster_permissions": [$clusterPermissionsStr], + "index_permissions": [{ + "index_patterns": ["$index"], + "dls": "$dlsQuery", + "fls": [], + "masked_fields": [], + "allowed_actions": ["crud"] + }], + "tenant_permissions": [] + } + """.trimIndent() request.setJsonEntity(entity) client().performRequest(request) } @@ -1673,11 +1684,13 @@ abstract class AlertingRestTestCase : ODFERestTestCase() { fun createUserRolesMapping(role: String, users: Array) { val request = Request("PUT", "/_plugins/_security/api/rolesmapping/$role") val usersStr = users.joinToString { it -> "\"$it\"" } - var entity = "{ \n" + - " \"backend_roles\" : [ ],\n" + - " \"hosts\" : [ ],\n" + - " \"users\" : [$usersStr]\n" + - "}" + var entity = """ + { + "backend_roles": [], + "hosts": [], + "users": [$usersStr] + } + """.trimIndent() request.setJsonEntity(entity) client().performRequest(request) } @@ -1688,11 +1701,13 @@ abstract class AlertingRestTestCase : ODFERestTestCase() { val op = if (addUser) "add" else "remove" - val entity = "[{\n" + - " \"op\" : \"$op\",\n" + - " \"path\" : \"/users\",\n" + - " \"value\" : [$usersStr]\n" + - "}]" + val entity = """ + [{ + "op": "$op", + "path": "/users", + "value": [$usersStr] + }] + """.trimIndent() request.setJsonEntity(entity) client().performRequest(request) @@ -2007,4 +2022,60 @@ abstract class AlertingRestTestCase : ODFERestTestCase() { return deletedCommentId } + + protected fun isMonitorScheduled(monitorId: String, alertingStatsResponse: Map): Boolean { + val nodesInfo = alertingStatsResponse["nodes"] as Map + for (nodeId in nodesInfo.keys) { + val nodeInfo = nodesInfo[nodeId] as Map + val jobsInfo = nodeInfo["jobs_info"] as Map + if (jobsInfo.keys.contains(monitorId)) { + return true + } + } + + return false + } + + // this function is used for PPL Alerting testing. + // precondition: TEST_INDEX_NAME must be created before calling this + // indexes a doc from some time ago into index TEST_INDEX_NAME. + // this function only works on the TEST_INDEX_NAME index created + // specifically for this IT suite. It has fields + // "timestamp" (date), "abc" (string), "number" (integer) + protected fun indexDocFromSomeTimeAgo( + timeValue: Long, + timeUnit: ChronoUnit, + abc: String, + number: Int, + id: String = UUID.randomUUID().toString() + ) { + val someTimeAgo = ZonedDateTime.now().minus(timeValue, timeUnit).truncatedTo(MILLIS) + val testTime = DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(someTimeAgo) // the timestamp string is given a random timezone offset + val testDoc = """{ "timestamp" : "$testTime", "abc": "$abc", "number" : "$number" }""" + indexDoc(TEST_INDEX_NAME, id, testDoc) + } + + protected fun ensureNumMonitors(expectedNum: Int) { + // if a validation error is thrown but a monitor is still accidentally created, + // what happens is that this check runs before the workflows to create + // alerting-config index and index the monitor complete, meaning this check gets + // no search results, then afterwards, the monitor is created, leading this function + // to falsely believe no monitor was create. wait some amount of time to let the + // workflows incorrectly create whatever monitors it will + OpenSearchTestCase.waitUntil({ + return@waitUntil false + }, 10, TimeUnit.SECONDS) + + val search = SearchSourceBuilder().query(QueryBuilders.matchAllQuery()).toString() + val searchResponse = client().makeRequest( + "POST", "${AlertingPlugin.MONITOR_BASE_URI}/_search", + StringEntity(search, APPLICATION_JSON) + ) + + assertEquals("Search monitor failed", RestStatus.OK, searchResponse.restStatus()) + val xcp = createParser(XContentType.JSON.xContent(), searchResponse.entity.content) + val hits = xcp.map()["hits"]!! as Map> + val numberDocsFound = hits["total"]?.get("value") + assertEquals("Unexpected number of PPL Monitors found in Search Monitors", expectedNum, numberDocsFound) + } } diff --git a/alerting/src/test/kotlin/org/opensearch/alerting/DocumentMonitorRunnerIT.kt b/alerting/src/test/kotlin/org/opensearch/alerting/DocumentMonitorRunnerIT.kt index 1f0f60bd8..9aa16793e 100644 --- a/alerting/src/test/kotlin/org/opensearch/alerting/DocumentMonitorRunnerIT.kt +++ b/alerting/src/test/kotlin/org/opensearch/alerting/DocumentMonitorRunnerIT.kt @@ -728,7 +728,7 @@ class DocumentMonitorRunnerIT : AlertingRestTestCase() { val alerts = searchAlerts(monitor) assertEquals("Alert not saved", 1, alerts.size) - assertEquals("Alert status is incorrect", Alert.State.ERROR, alerts[0].state) + assertEquals("Alert status is incorrect", Alert.State.ERROR, (alerts[0] as Alert).state) } fun `test execute monitor generates alerts and findings with per alert execution for actions`() { diff --git a/alerting/src/test/kotlin/org/opensearch/alerting/MonitorDataSourcesIT.kt b/alerting/src/test/kotlin/org/opensearch/alerting/MonitorDataSourcesIT.kt index 1211bca3f..a72f7efde 100644 --- a/alerting/src/test/kotlin/org/opensearch/alerting/MonitorDataSourcesIT.kt +++ b/alerting/src/test/kotlin/org/opensearch/alerting/MonitorDataSourcesIT.kt @@ -590,7 +590,7 @@ class MonitorDataSourcesIT : AlertingSingleNodeTestCase() { .get() Assert.assertTrue(getAlertsResponse != null) Assert.assertTrue(getAlertsResponse.alerts.size == 1) - Assert.assertTrue(getAlertsResponse.alerts[0].state.toString().equals(Alert.State.ERROR.toString())) + Assert.assertTrue((getAlertsResponse.alerts[0] as Alert).state.toString().equals(Alert.State.ERROR.toString())) val findings = searchFindings(id, customFindingsIndex) assertEquals("Findings saved for test monitor", 0, findings.size) } @@ -896,10 +896,10 @@ class MonitorDataSourcesIT : AlertingSingleNodeTestCase() { Assert.assertTrue(getAlertsResponse != null) Assert.assertTrue(getAlertsResponse.alerts.size == 1) Assert.assertTrue( - getAlertsResponse.alerts[0].errorHistory[0].message == + (getAlertsResponse.alerts[0] as Alert).errorHistory[0].message == "AlertingException[closed]; nested: Exception[org.opensearch.indices.IndexClosedException: closed]; " ) - Assert.assertEquals(1, getAlertsResponse.alerts[0].errorHistory.size) + Assert.assertEquals(1, (getAlertsResponse.alerts[0] as Alert).errorHistory.size) Assert.assertTrue(getAlertsResponse.alerts[0].errorMessage!!.contains("Failed to run percolate search")) } diff --git a/alerting/src/test/kotlin/org/opensearch/alerting/MonitorRunnerServiceIT.kt b/alerting/src/test/kotlin/org/opensearch/alerting/MonitorRunnerServiceIT.kt index 8e8d71fe6..d933d1029 100644 --- a/alerting/src/test/kotlin/org/opensearch/alerting/MonitorRunnerServiceIT.kt +++ b/alerting/src/test/kotlin/org/opensearch/alerting/MonitorRunnerServiceIT.kt @@ -6,17 +6,20 @@ package org.opensearch.alerting import org.junit.Assert +import org.opensearch.alerting.PPLUtils.PPL_RESULTS_SIZE_EXCEEDED_MESSAGE import org.opensearch.alerting.alerts.AlertIndices import org.opensearch.alerting.model.destination.CustomWebhook import org.opensearch.alerting.model.destination.Destination import org.opensearch.alerting.model.destination.email.Email import org.opensearch.alerting.model.destination.email.Recipient +import org.opensearch.alerting.settings.AlertingSettings import org.opensearch.alerting.util.DestinationType import org.opensearch.alerting.util.getBucketKeysHash import org.opensearch.client.Request import org.opensearch.client.ResponseException import org.opensearch.client.WarningFailureException import org.opensearch.common.settings.Settings +import org.opensearch.common.unit.TimeValue import org.opensearch.commons.alerting.aggregation.bucketselectorext.BucketSelectorExtAggregationBuilder import org.opensearch.commons.alerting.alerts.AlertError import org.opensearch.commons.alerting.model.ActionExecutionResult @@ -31,6 +34,7 @@ import org.opensearch.commons.alerting.model.DocLevelMonitorInput import org.opensearch.commons.alerting.model.DocLevelQuery import org.opensearch.commons.alerting.model.IntervalSchedule import org.opensearch.commons.alerting.model.Monitor +import org.opensearch.commons.alerting.model.PPLTrigger import org.opensearch.commons.alerting.model.SearchInput import org.opensearch.commons.alerting.model.action.ActionExecutionPolicy import org.opensearch.commons.alerting.model.action.AlertCategory @@ -2228,6 +2232,581 @@ class MonitorRunnerServiceIT : AlertingRestTestCase() { } } + fun `test execute num results PPL monitor execution timeout generates error alert`() { + // Create test index with PPL-compatible mappings + createIndex(TEST_INDEX_NAME, Settings.EMPTY, TEST_INDEX_MAPPINGS) + indexDocFromSomeTimeAgo(2, MINUTES, "abc", 5) + + // Create PPL monitor with NUMBER_OF_RESULTS trigger condition + val monitor = createRandomPPLMonitor( + randomPPLMonitor( + enabled = true, + schedule = IntervalSchedule(interval = 1, unit = MINUTES), + triggers = listOf( + randomPPLTrigger( + conditionType = PPLTrigger.ConditionType.NUMBER_OF_RESULTS, + numResultsCondition = PPLTrigger.NumResultsCondition.GREATER_THAN, + numResultsValue = 0L, + customCondition = null + ) + ), + query = "source = $TEST_INDEX_NAME | head 10" + ) + ) + + // Set monitor execution timeout to 1 nanosecond to force a timeout + adminClient().updateSettings(AlertingSettings.PPL_MONITOR_EXECUTION_MAX_DURATION.key, TimeValue.timeValueNanos(1)) + + val response = executeMonitor(monitor.id) + + val output = entityAsMap(response) + assertEquals(monitor.name, output["monitor_name"]) + + // Verify execute response contains error + @Suppress("UNCHECKED_CAST") + for (triggerResult in output.objectMap("trigger_results").values) { + assertTrue("Missing trigger error message", (triggerResult?.get("error") as? String)?.isNotEmpty() == true) + } + + // Verify ERROR alert was created + val alerts = searchAlerts(monitor) + assertEquals("Alert not saved", 1, alerts.size) + verifyAlert(alerts.single(), monitor, ERROR) + } + + fun `test execute custom condition PPL monitor execution timeout generates error alert`() { + // Create test index with PPL-compatible mappings + createIndex(TEST_INDEX_NAME, Settings.EMPTY, TEST_INDEX_MAPPINGS) + indexDocFromSomeTimeAgo(2, MINUTES, "abc", 5) + + // Create PPL monitor with NUMBER_OF_RESULTS trigger condition + val monitor = createRandomPPLMonitor( + randomPPLMonitor( + enabled = true, + schedule = IntervalSchedule(interval = 1, unit = MINUTES), + triggers = listOf( + randomPPLTrigger( + conditionType = PPLTrigger.ConditionType.CUSTOM, + customCondition = "where max_num > 5", + numResultsCondition = null, + numResultsValue = null + ) + ), + query = "source = $TEST_INDEX_NAME | stats max(number) as max_num by abc" + ) + ) + + // Set monitor execution timeout to 1 nanosecond to force a timeout + adminClient().updateSettings(AlertingSettings.PPL_MONITOR_EXECUTION_MAX_DURATION.key, TimeValue.timeValueNanos(1)) + + val response = executeMonitor(monitor.id) + + val output = entityAsMap(response) + assertEquals(monitor.name, output["monitor_name"]) + + // Verify execute response contains error + @Suppress("UNCHECKED_CAST") + for (triggerResult in output.objectMap("trigger_results").values) { + assertTrue("Missing trigger error message", (triggerResult?.get("error") as? String)?.isNotEmpty() == true) + } + + // Verify ERROR alert was created + val alerts = searchAlerts(monitor) + assertEquals("Alert not saved", 1, alerts.size) + verifyAlert(alerts.single(), monitor, ERROR) + } + + fun `test execute PPL monitor with size exceeded query results`() { + // Create test index with PPL-compatible mappings + createIndex(TEST_INDEX_NAME, Settings.EMPTY, TEST_INDEX_MAPPINGS) + indexDocFromSomeTimeAgo(2, MINUTES, "abc", 5) + + // Create PPL monitor with NUMBER_OF_RESULTS trigger condition + val monitor = createRandomPPLMonitor( + randomPPLMonitor( + enabled = true, + schedule = IntervalSchedule(interval = 1, unit = MINUTES), + triggers = listOf( + randomPPLTrigger( + conditionType = PPLTrigger.ConditionType.NUMBER_OF_RESULTS, + numResultsCondition = PPLTrigger.NumResultsCondition.GREATER_THAN, + numResultsValue = 0L, + customCondition = null + ) + ), + query = "source = $TEST_INDEX_NAME | head 10" + ) + ) + + // Set max PPL query results size to guarantee it will be exceeded + adminClient().updateSettings(AlertingSettings.PPL_QUERY_RESULTS_MAX_SIZE.key, 5L) + + executeMonitor(monitor.id) + + val alerts = searchAlerts(monitor) + assertEquals(1, alerts.size) + + val alert = alerts[0] + assertEquals(ACTIVE, alert.state) + assertEquals(monitor.id, alert.monitorId) + assertEquals(monitor.triggers[0].id, alert.triggerId) + + // Verify ppl_query_results contains the size exceeded message + assertNotNull(alert.queryResults) + assertEquals(1, alert.queryResults.size) + + val firstResultRow = alert.queryResults[0] + assertTrue(firstResultRow.containsKey("message")) + assertEquals(PPL_RESULTS_SIZE_EXCEEDED_MESSAGE, firstResultRow["message"]) + } + + fun `test execute PPL monitor with number of results condition basic case`() { + // Setup test index and data + createIndex(TEST_INDEX_NAME, Settings.EMPTY, TEST_INDEX_MAPPINGS) + val docId = "test-doc-1" + indexDocFromSomeTimeAgo(2, MINUTES, "abc", 5, docId) + + // Create PPL monitor with NUMBER_OF_RESULTS condition + val monitor = createMonitor( + randomPPLMonitor( + enabled = true, + schedule = IntervalSchedule(interval = 1, unit = MINUTES), + triggers = listOf( + randomPPLTrigger( + conditionType = PPLTrigger.ConditionType.NUMBER_OF_RESULTS, + numResultsCondition = PPLTrigger.NumResultsCondition.GREATER_THAN, + numResultsValue = 0L, + customCondition = null + ) + ), + query = "source = $TEST_INDEX_NAME | head 10" + ) + ) + + val versionBefore = monitor.version + + // Execute monitor - should trigger because document exists + val response = executeMonitor(monitor.id) + + // Verify monitor execution response + val output = entityAsMap(response) + assertEquals(monitor.name, output["monitor_name"]) + @Suppress("UNCHECKED_CAST") + val triggerResults = output.objectMap("trigger_results") + assertTrue("Trigger should have run", triggerResults.isNotEmpty()) + + // Verify alert was generated with ACTIVE state + val alerts = searchAlerts(monitor) + assertEquals("Alert should have been generated", 1, alerts.size) + + val activeAlert = alerts.single() + verifyAlert(activeAlert, monitor, ACTIVE) + + // Verify alert contains PPL query and results + assertNotNull("PPL query should be stored in alert", activeAlert.query) + assertEquals("source = $TEST_INDEX_NAME | head 10", activeAlert.query) + assertTrue("PPL query results should not be empty", activeAlert.queryResults.isNotEmpty()) + + // Verify the query results are in the new transformed format (list of maps) + assertEquals("Should have exactly 1 result row from the indexed document", 1, activeAlert.queryResults.size) + + // Get the first (and only) result row + val resultRow = activeAlert.queryResults[0] + + // Verify the result row contains the expected fields from the indexed document + assertTrue("Result should contain 'timestamp' field", resultRow.containsKey("timestamp")) + assertTrue("Result should contain 'abc' field", resultRow.containsKey("abc")) + assertTrue("Result should contain 'number' field", resultRow.containsKey("number")) + + // Verify the field values match what we indexed + assertEquals("Field 'abc' should match indexed value", "abc", resultRow["abc"]) + assertEquals("Field 'number' should match indexed value", 5, resultRow["number"]) + + // Verify timestamp field exists and is not null (exact value varies due to time calculation) + assertNotNull("Field 'timestamp' should not be null", resultRow["timestamp"]) + + // Verify monitor version didn't change after execution + val monitorAfter = getMonitor(monitor.id) + assertEquals("Monitor version should not change after execution", versionBefore, monitorAfter.version) + + // Delete the document to make the trigger condition no longer true + val deleteRequest = Request("DELETE", "/$TEST_INDEX_NAME/_doc/$docId?refresh=true") + client().performRequest(deleteRequest) + + // Execute monitor again - should NOT trigger because document is deleted + val responseAfterDelete = executeMonitor(monitor.id) + + val outputAfterDelete = entityAsMap(responseAfterDelete) + assertEquals(monitor.name, outputAfterDelete["monitor_name"]) + + // Verify there are no ACTIVE alerts (moved to history) + assertTrue("Active alert should have moved to history", searchAlerts(monitor, AlertIndices.ALERT_INDEX).isEmpty()) + + // Verify the same alert is now in COMPLETED state (search ALL alert indices including history) + val alertsAfterDelete = searchAlerts(monitor, AlertIndices.ALL_ALERT_INDEX_PATTERN) + assertEquals("Should still have exactly one alert", 1, alertsAfterDelete.size) + + val completedAlert = alertsAfterDelete.single() + + // Verify it's the same alert (same ID) + assertEquals("Alert ID should be the same", activeAlert.id, completedAlert.id) + assertEquals("Alert should transition to COMPLETED state", COMPLETED, completedAlert.state) + verifyAlert(completedAlert, monitor, COMPLETED) + + // Verify the alert has an end time now + assertNotNull("Completed alert should have an end time", completedAlert.endTime) + + // Verify completed alert has no error message + assertNull("Completed alert should not have an error message", completedAlert.errorMessage) + } + + fun `test execute PPL monitor with custom condition basic case`() { + // Setup test index and data with multiple groups + createIndex(TEST_INDEX_NAME, Settings.EMPTY, TEST_INDEX_MAPPINGS) + + // Index documents for group "abc" - max number = 3 (won't satisfy condition) + indexDocFromSomeTimeAgo(1, MINUTES, "abc", 1, "abc-doc-1") + indexDocFromSomeTimeAgo(2, MINUTES, "abc", 2, "abc-doc-2") + indexDocFromSomeTimeAgo(3, MINUTES, "abc", 3, "abc-doc-3") + + // Index documents for group "def" - max number = 6 (will satisfy condition max_num > 5) + indexDocFromSomeTimeAgo(4, MINUTES, "def", 4, "def-doc-1") + indexDocFromSomeTimeAgo(5, MINUTES, "def", 5, "def-doc-2") + indexDocFromSomeTimeAgo(6, MINUTES, "def", 6, "def-doc-3") + + // Index documents for group "ghi" - max number = 9 (will satisfy condition max_num > 5) + indexDocFromSomeTimeAgo(7, MINUTES, "ghi", 7, "ghi-doc-1") + indexDocFromSomeTimeAgo(8, MINUTES, "ghi", 8, "ghi-doc-2") + indexDocFromSomeTimeAgo(9, MINUTES, "ghi", 9, "ghi-doc-3") + + // Create PPL monitor with CUSTOM condition + // Query aggregates by "abc" field and computes max(number) + // Custom condition evaluates: max_num > 5 + val monitor = createMonitor( + randomPPLMonitor( + enabled = true, + schedule = IntervalSchedule(interval = 1, unit = MINUTES), + triggers = listOf( + randomPPLTrigger( + conditionType = PPLTrigger.ConditionType.CUSTOM, + customCondition = "where max_num > 5", + numResultsCondition = null, + numResultsValue = null + ) + ), + query = "source = $TEST_INDEX_NAME | stats max(number) as max_num by abc" + ) + ) + + // Execute monitor - should trigger because def and ghi groups have max > 5 + val response = executeMonitor(monitor.id) + + // Verify monitor execution response + val output = entityAsMap(response) + assertEquals(monitor.name, output["monitor_name"]) + @Suppress("UNCHECKED_CAST") + val triggerResults = output.objectMap("trigger_results") + assertTrue("Trigger should have run", triggerResults.isNotEmpty()) + + // Verify alert was generated with ACTIVE state + val alerts = searchAlerts(monitor) + assertEquals("Alert should have been generated", 1, alerts.size) + + val activeAlert = alerts.single() + verifyAlert(activeAlert, monitor, ACTIVE) + + // Verify alert contains PPL query and aggregated results + assertNotNull("PPL query should be stored in alert", activeAlert.query) + assertEquals("source = $TEST_INDEX_NAME | stats max(number) as max_num by abc", activeAlert.query) + assertTrue("PPL query results should not be empty", activeAlert.queryResults.isNotEmpty()) + + // Verify the query results are in the new transformed format (list of maps), + // and only have the 2 buckets that met the custom condition + assertEquals("Should have 2 aggregation result rows", 2, activeAlert.queryResults.size) + + // Convert results to a map of group name -> result row for easier validation + val groupResults = activeAlert.queryResults.associateBy { it["abc"] as String } + + // Verify all expected groups are present + assertTrue("Should contain group 'def'", groupResults.containsKey("def")) + assertTrue("Should contain group 'ghi'", groupResults.containsKey("ghi")) + + // Verify each result row has the expected structure and values + val defResult = groupResults["def"]!! + assertTrue("def result should contain 'max_num' field", defResult.containsKey("max_num")) + assertTrue("def result should contain 'abc' field", defResult.containsKey("abc")) + assertEquals("Group 'def' max should be 6", 6, (defResult["max_num"] as Number).toInt()) + assertEquals("Group 'def' abc field should be 'def'", "def", defResult["abc"]) + + val ghiResult = groupResults["ghi"]!! + assertTrue("ghi result should contain 'max_num' field", ghiResult.containsKey("max_num")) + assertTrue("ghi result should contain 'abc' field", ghiResult.containsKey("abc")) + assertEquals("Group 'ghi' max should be 9", 9, (ghiResult["max_num"] as Number).toInt()) + assertEquals("Group 'ghi' abc field should be 'ghi'", "ghi", ghiResult["abc"]) + + // Custom condition "where max_num > 5" should evaluate to true for def (6 > 5) and ghi (9 > 5) + // This caused the trigger to fire and create an ACTIVE alert + + // Delete documents from "def" and "ghi" groups to make condition no longer true + // After deletion, only "abc" group remains with max=3, which doesn't satisfy max_num > 5 + val deleteDefDocs = """ + { + "query": { + "term": { + "abc": "def" + } + } + } + """.trimIndent() + val deleteGhiDocs = """ + { + "query": { + "term": { + "abc": "ghi" + } + } + } + """.trimIndent() + + val deleteDefRequest = Request("POST", "/$TEST_INDEX_NAME/_delete_by_query?refresh=true") + deleteDefRequest.setJsonEntity(deleteDefDocs) + client().performRequest(deleteDefRequest) + + val deleteGhiRequest = Request("POST", "/$TEST_INDEX_NAME/_delete_by_query?refresh=true") + deleteGhiRequest.setJsonEntity(deleteGhiDocs) + client().performRequest(deleteGhiRequest) + + // Execute monitor again - should NOT trigger because only abc group remains (max=3 <= 5) + val responseAfterDelete = executeMonitor(monitor.id) + + val outputAfterDelete = entityAsMap(responseAfterDelete) + assertEquals(monitor.name, outputAfterDelete["monitor_name"]) + + // Verify there are no ACTIVE alerts (moved to history) + assertTrue("Active alert should have moved to history", searchAlerts(monitor, AlertIndices.ALERT_INDEX).isEmpty()) + + // Verify the same alert is now in COMPLETED state (search ALL alert indices including history) + val alertsAfterDelete = searchAlerts(monitor, AlertIndices.ALL_ALERT_INDEX_PATTERN) + assertEquals("Should still have exactly one alert", 1, alertsAfterDelete.size) + + val completedAlert = alertsAfterDelete.single() + + // Verify it's the same alert (same ID) + assertEquals("Alert ID should be the same", activeAlert.id, completedAlert.id) + assertEquals("Alert should transition to COMPLETED state", COMPLETED, completedAlert.state) + verifyAlert(completedAlert, monitor, COMPLETED) + + // Verify the alert has an end time now + assertNotNull("Completed alert should have an end time", completedAlert.endTime) + } + + fun `test execute PPL monitor updates query results on subsequent runs`() { + // Setup test index and data + createIndex(TEST_INDEX_NAME, Settings.EMPTY, TEST_INDEX_MAPPINGS) + val docId1 = "test-doc-1" + indexDocFromSomeTimeAgo(2, MINUTES, "test-value", 5, docId1) + + // Create PPL monitor with NUMBER_OF_RESULTS condition + val monitor = createMonitor( + randomPPLMonitor( + enabled = true, + schedule = IntervalSchedule(interval = 1, unit = MINUTES), + triggers = listOf( + randomPPLTrigger( + conditionType = PPLTrigger.ConditionType.NUMBER_OF_RESULTS, + numResultsCondition = PPLTrigger.NumResultsCondition.GREATER_THAN, + numResultsValue = 0L, + customCondition = null + ) + ), + query = "source = $TEST_INDEX_NAME | head 10" + ) + ) + + // Execute monitor - should trigger because document exists + val response = executeMonitor(monitor.id) + + // Verify monitor execution response + val output = entityAsMap(response) + assertEquals(monitor.name, output["monitor_name"]) + + // Verify alert was generated with ACTIVE state + val alerts = searchAlerts(monitor) + assertEquals("Alert should have been generated", 1, alerts.size) + + val firstAlert = alerts.single() + verifyAlert(firstAlert, monitor, ACTIVE) + + // Verify alert contains PPL query and results with 1 document + assertNotNull("PPL query should be stored in alert", firstAlert.query) + assertEquals("source = $TEST_INDEX_NAME | head 10", firstAlert.query) + assertTrue("PPL query results should not be empty", firstAlert.queryResults.isNotEmpty()) + + // Verify the query results are in the new transformed format (list of maps) + assertEquals("Should have 1 result row from the first indexed document", 1, firstAlert.queryResults.size) + + // Get the first result row and verify it has the expected structure + val firstResultRow = firstAlert.queryResults[0] + assertTrue("Result should contain 'timestamp' field", firstResultRow.containsKey("timestamp")) + assertTrue("Result should contain 'abc' field", firstResultRow.containsKey("abc")) + assertTrue("Result should contain 'number' field", firstResultRow.containsKey("number")) + + // Verify the field values match the first document we indexed + assertEquals("Field 'abc' should match first document", "test-value", firstResultRow["abc"]) + assertEquals("Field 'number' should match first document", 5, (firstResultRow["number"] as Number).toInt()) + + // Add a second document to the index + val docId2 = "test-doc-2" + indexDocFromSomeTimeAgo(3, MINUTES, "test-value-2", 10, docId2) + + // Execute monitor again - should still trigger because documents still exist (now 2 docs) + val responseAfterSecondDoc = executeMonitor(monitor.id) + + val outputAfterSecondDoc = entityAsMap(responseAfterSecondDoc) + assertEquals(monitor.name, outputAfterSecondDoc["monitor_name"]) + + // Verify the same alert still exists + val alertsAfterSecondDoc = searchAlerts(monitor) + assertEquals("Should still have exactly one alert", 1, alertsAfterSecondDoc.size) + + val updatedAlert = alertsAfterSecondDoc.single() + + // Verify it's the same alert (same ID) + assertEquals("Alert ID should be the same", firstAlert.id, updatedAlert.id) + + // Verify the alert's query results have been updated to include both documents + assertNotNull("PPL query should still be stored in alert", updatedAlert.query) + assertTrue("PPL query results should not be empty", updatedAlert.queryResults.isNotEmpty()) + + // Verify the query results now contain 2 rows (updated from previous 1 row) + assertEquals("Should now have 2 result rows from both indexed documents", 2, updatedAlert.queryResults.size) + + // Verify both result rows have the expected structure + updatedAlert.queryResults.forEach { row -> + assertTrue("Each result row should contain 'timestamp' field", row.containsKey("timestamp")) + assertTrue("Each result row should contain 'abc' field", row.containsKey("abc")) + assertTrue("Each result row should contain 'number' field", row.containsKey("number")) + } + + // Verify we can find both documents in the results by their unique 'abc' values + val abcValues = updatedAlert.queryResults.map { it["abc"] }.toSet() + assertTrue("Results should contain first document with abc='test-value'", abcValues.contains("test-value")) + assertTrue("Results should contain second document with abc='test-value-2'", abcValues.contains("test-value-2")) + + // Verify we can find both documents by their number values + val numberValues = updatedAlert.queryResults.map { (it["number"] as Number).toInt() }.toSet() + assertTrue("Results should contain first document with number=5", numberValues.contains(5)) + assertTrue("Results should contain second document with number=10", numberValues.contains(10)) + + // Verify lastNotificationTime was updated + assertNotEquals( + "Last notification time should be updated", + firstAlert.lastNotificationTime, + updatedAlert.lastNotificationTime + ) + + // Verify startTime remains the same (alert not recreated) + assertEquals( + "Start time should remain the same", + firstAlert.startTime, + updatedAlert.startTime + ) + } + + fun `test PPL monitor action template renders query results correctly`() { + // Setup test index and data + createIndex(TEST_INDEX_NAME, Settings.EMPTY, TEST_INDEX_MAPPINGS) + indexDocFromSomeTimeAgo(1, MINUTES, "group-a", 10) + indexDocFromSomeTimeAgo(2, MINUTES, "group-b", 20) + indexDocFromSomeTimeAgo(3, MINUTES, "group-a", 30) + + // Create complex template that accesses PPL query results at multiple levels + val messageTemplate = """ + Monitor: {{ctx.monitor.name}} + Trigger: {{ctx.trigger.name}} + + === PPL Query Results === + Query: {{ctx.monitor.inputs.0.ppl_input.query}} + + --- Fine-Grained Access to First Row --- + {{#ctx.ppl_query_results}} + Row: + Field 1 (number): {{number}} + Field 2 (abc): {{abc}} + Field 3 (timestamp): {{timestamp}} + + {{/ctx.ppl_query_results}} + """.trimIndent() + + val destination = createDestination() + val action = randomAction( + template = randomTemplateScript(messageTemplate), + subjectTemplate = randomTemplateScript("A Subject"), + destinationId = destination.id + ) + + val query = "source = $TEST_INDEX_NAME | head 10" + + // Create PPL monitor with action + val monitor = createMonitor( + randomPPLMonitor( + enabled = true, + schedule = IntervalSchedule(interval = 1, unit = MINUTES), + triggers = listOf( + randomPPLTrigger( + conditionType = PPLTrigger.ConditionType.NUMBER_OF_RESULTS, + numResultsCondition = PPLTrigger.NumResultsCondition.GREATER_THAN, + numResultsValue = 0L, + customCondition = null, + actions = listOf(action) + ) + ), + query = query + ) + ) + + // Execute monitor to trigger action + val response = executeMonitor(monitor.id, params = DRYRUN_MONITOR) + val output = entityAsMap(response) + + // Verify action was executed + assertEquals(monitor.name, output["monitor_name"]) + val triggerResults = output.objectMap("trigger_results") + assertTrue("Trigger should have executed", triggerResults.isNotEmpty()) + + // Extract and verify action output + val triggerResult = triggerResults.values.first() + val actionResults = triggerResult.objectMap("action_results") + assertTrue("Action results should exist", actionResults.isNotEmpty()) + + val actionResult = actionResults.values.first() + + @Suppress("UNCHECKED_CAST") + val actionOutputMap = actionResult["output"] as? Map + assertNotNull("Action output should not be null", actionOutputMap) + + val message = actionOutputMap?.get("message") + + logger.info("Rendered message: $message") + + assertNotNull("Action message should not be null", message) + assertNull("Action should not have error", actionResult["error"]) + + // Verify basic context variables + assertTrue("Message should contain monitor name", message!!.contains("Monitor: ${monitor.name}")) + assertTrue("Message should contain trigger name", message.contains("Trigger: ${triggerResult["name"]}")) + + // Verify PPL query is accessible + assertTrue("Message should contain the PPL query", message.contains(query)) + + // Verify fine-grained access to each row's fields (order may vary) + assertTrue("Message should contain group-a", message.contains("group-a")) + assertTrue("Message should contain group-b", message.contains("group-b")) + assertTrue("Message should contain value 10", message.contains("10")) + assertTrue("Message should contain value 20", message.contains("20")) + assertTrue("Message should contain value 30", message.contains("30")) + } + private fun prepareTestAnomalyResult(detectorId: String, user: User) { val adResultIndex = ".opendistro-anomaly-results-history-2020.10.17" try { diff --git a/alerting/src/test/kotlin/org/opensearch/alerting/PPLUtilsTests.kt b/alerting/src/test/kotlin/org/opensearch/alerting/PPLUtilsTests.kt new file mode 100644 index 000000000..1ef5757ff --- /dev/null +++ b/alerting/src/test/kotlin/org/opensearch/alerting/PPLUtilsTests.kt @@ -0,0 +1,327 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.alerting + +import org.opensearch.alerting.PPLUtils.PPL_RESULTS_SIZE_EXCEEDED_MESSAGE +import org.opensearch.test.OpenSearchTestCase + +class PPLUtilsTests : OpenSearchTestCase() { + fun `test constructPPLQueryResultsMap with simple types`() { + // Arrange: Simple query result with basic types + val rawResults = mapOf( + "schema" to listOf( + mapOf("name" to "username", "type" to "string"), + mapOf("name" to "count", "type" to "integer"), + mapOf("name" to "active", "type" to "boolean") + ), + "datarows" to listOf( + listOf("alice", 42, true), + listOf("bob", 17, false), + listOf("charlie", 99, true) + ), + "total" to 3, + "size" to 3 + ) + + // Act + val result = PPLUtils.constructPPLQueryResultsMap(rawResults) + + // Assert + assertEquals(3, result.size) + + // First row + assertEquals("alice", result[0]["username"]) + assertEquals(42, result[0]["count"]) + assertEquals(true, result[0]["active"]) + + // Second row + assertEquals("bob", result[1]["username"]) + assertEquals(17, result[1]["count"]) + assertEquals(false, result[1]["active"]) + + // Third row + assertEquals("charlie", result[2]["username"]) + assertEquals(99, result[2]["count"]) + assertEquals(true, result[2]["active"]) + } + + fun `test constructPPLQueryResultsMap with size exceeded message`() { + // Arrange: Simple query result with basic types + val rawResults = mapOf( + "schema" to listOf( + mapOf("name" to "message", "type" to "string") + ), + "datarows" to listOf( + listOf(PPL_RESULTS_SIZE_EXCEEDED_MESSAGE) + ), + "total" to 3, + "size" to 3 + ) + + // Act + val result = PPLUtils.constructPPLQueryResultsMap(rawResults) + + // Assert + assertEquals(1, result.size) + + // First row + assertEquals(PPL_RESULTS_SIZE_EXCEEDED_MESSAGE, result[0]["message"]) + } + + fun `test constructPPLQueryResultsMap with nested objects and nulls`() { + // Arrange: Complex query result with nested arrays, objects, and nulls + val rawResults = mapOf( + "schema" to listOf( + mapOf("name" to "list", "type" to "bigint"), + mapOf("name" to "user", "type" to "struct"), + mapOf("name" to "abc", "type" to "string") + ), + "datarows" to listOf( + // Row 1: All fields populated with complex types + listOf( + listOf(1, 2, 3), // Nested array + mapOf("name" to "bob", "age" to 32), // Nested object + "abc" // Simple string + ), + // Row 2: First field is null, rest populated + listOf( + null, // Null array + mapOf("name" to "bob", "age" to 32), // Nested object + "abc" // Simple string + ), + // Row 3: Multiple null fields + listOf( + null, // Null array + null, // Null object + "abc" // Simple string + ) + ), + "total" to 3, + "size" to 3 + ) + + // Act + val result = PPLUtils.constructPPLQueryResultsMap(rawResults) + + // Assert + assertEquals(3, result.size) + + // Row 1: Nested array and nested object + val row1 = result[0] + val list1 = row1["list"] as? List<*> + assertNotNull(list1) + assertEquals(3, list1?.size) + assertEquals(1, list1?.get(0)) + assertEquals(2, list1?.get(1)) + assertEquals(3, list1?.get(2)) + + val user1 = row1["user"] as? Map<*, *> + assertNotNull(user1) + assertEquals("bob", user1?.get("name")) + assertEquals(32, user1?.get("age")) + + assertEquals("abc", row1["abc"]) + + // Row 2: Null list, populated user + val row2 = result[1] + assertNull(row2["list"]) + + val user2 = row2["user"] as? Map<*, *> + assertNotNull(user2) + assertEquals("bob", user2?.get("name")) + assertEquals(32, user2?.get("age")) + + assertEquals("abc", row2["abc"]) + + // Row 3: Multiple nulls + val row3 = result[2] + assertNull(row3["list"]) + assertNull(row3["user"]) + assertEquals("abc", row3["abc"]) + } + + fun `test constructPPLQueryResultsMap with empty schema`() { + // Arrange: Empty schema + val rawResults = mapOf( + "schema" to emptyList>(), + "datarows" to listOf( + listOf("value1", "value2") + ), + "total" to 1, + "size" to 1 + ) + + // Act + val result = PPLUtils.constructPPLQueryResultsMap(rawResults) + + // Assert + assertTrue(result.isEmpty()) + } + + fun `test constructPPLQueryResultsMap with empty datarows`() { + // Arrange: Empty datarows + val rawResults = mapOf( + "schema" to listOf( + mapOf("name" to "field1", "type" to "string"), + mapOf("name" to "field2", "type" to "integer") + ), + "datarows" to emptyList>(), + "total" to 0, + "size" to 0 + ) + + // Act + val result = PPLUtils.constructPPLQueryResultsMap(rawResults) + + // Assert + assertTrue(result.isEmpty()) + } + + fun `test constructPPLQueryResultsMap with missing schema`() { + // Arrange: Missing schema field + val rawResults = mapOf( + "datarows" to listOf( + listOf("value1", "value2") + ), + "total" to 1, + "size" to 1 + ) + + // Act + val result = PPLUtils.constructPPLQueryResultsMap(rawResults) + + // Assert + assertTrue(result.isEmpty()) + } + + fun `test constructPPLQueryResultsMap with missing datarows`() { + // Arrange: Missing datarows field + val rawResults = mapOf( + "schema" to listOf( + mapOf("name" to "field1", "type" to "string") + ), + "total" to 0, + "size" to 0 + ) + + // Act + val result = PPLUtils.constructPPLQueryResultsMap(rawResults) + + // Assert + assertTrue(result.isEmpty()) + } + + fun `test constructPPLQueryResultsMap with mismatched row lengths`() { + // Arrange: More schema fields than datarow values + val rawResults = mapOf( + "schema" to listOf( + mapOf("name" to "field1", "type" to "string"), + mapOf("name" to "field2", "type" to "integer"), + mapOf("name" to "field3", "type" to "boolean") + ), + "datarows" to listOf( + listOf("value1", 42), // Missing third field + listOf("value2") // Missing second and third fields + ), + "total" to 2, + "size" to 2 + ) + + // Act + val result = PPLUtils.constructPPLQueryResultsMap(rawResults) + + // Assert + assertEquals(2, result.size) + + // First row: third field should be null + assertEquals("value1", result[0]["field1"]) + assertEquals(42, result[0]["field2"]) + assertNull(result[0]["field3"]) + + // Second row: second and third fields should be null + assertEquals("value2", result[1]["field1"]) + assertNull(result[1]["field2"]) + assertNull(result[1]["field3"]) + } + + fun `test constructPPLQueryResultsMap with extra datarow values`() { + // Arrange: More datarow values than schema fields + val rawResults = mapOf( + "schema" to listOf( + mapOf("name" to "field1", "type" to "string"), + mapOf("name" to "field2", "type" to "integer") + ), + "datarows" to listOf( + listOf("value1", 42, true, "extra") // Extra values ignored + ), + "total" to 1, + "size" to 1 + ) + + // Act + val result = PPLUtils.constructPPLQueryResultsMap(rawResults) + + // Assert + assertEquals(1, result.size) + assertEquals(2, result[0].size) + assertEquals("value1", result[0]["field1"]) + assertEquals(42, result[0]["field2"]) + // Extra values are ignored + } + + fun `test constructPPLQueryResultsMap with deeply nested structures`() { + // Arrange: Deeply nested objects and arrays + val rawResults = mapOf( + "schema" to listOf( + mapOf("name" to "nested_data", "type" to "struct") + ), + "datarows" to listOf( + listOf( + mapOf( + "level1" to mapOf( + "level2" to mapOf( + "level3" to listOf(1, 2, 3) + ) + ), + "array_of_objects" to listOf( + mapOf("id" to 1, "name" to "item1"), + mapOf("id" to 2, "name" to "item2") + ) + ) + ) + ), + "total" to 1, + "size" to 1 + ) + + // Act + val result = PPLUtils.constructPPLQueryResultsMap(rawResults) + + // Assert + assertEquals(1, result.size) + + val nestedData = result[0]["nested_data"] as? Map<*, *> + assertNotNull(nestedData) + + val level1 = nestedData?.get("level1") as? Map<*, *> + assertNotNull(level1) + + val level2 = level1?.get("level2") as? Map<*, *> + assertNotNull(level2) + + val level3 = level2?.get("level3") as? List<*> + assertNotNull(level3) + assertEquals(3, level3?.size) + + val arrayOfObjects = nestedData?.get("array_of_objects") as? List<*> + assertNotNull(arrayOfObjects) + assertEquals(2, arrayOfObjects?.size) + + val firstObject = arrayOfObjects?.get(0) as? Map<*, *> + assertEquals(1, firstObject?.get("id")) + assertEquals("item1", firstObject?.get("name")) + } +} diff --git a/alerting/src/test/kotlin/org/opensearch/alerting/TestHelpers.kt b/alerting/src/test/kotlin/org/opensearch/alerting/TestHelpers.kt index 93d4a62c5..fdf43579a 100644 --- a/alerting/src/test/kotlin/org/opensearch/alerting/TestHelpers.kt +++ b/alerting/src/test/kotlin/org/opensearch/alerting/TestHelpers.kt @@ -47,6 +47,11 @@ import org.opensearch.commons.alerting.model.InputRunResults import org.opensearch.commons.alerting.model.IntervalSchedule import org.opensearch.commons.alerting.model.Monitor import org.opensearch.commons.alerting.model.MonitorRunResult +import org.opensearch.commons.alerting.model.PPLInput +import org.opensearch.commons.alerting.model.PPLInput.QueryLanguage +import org.opensearch.commons.alerting.model.PPLTrigger +import org.opensearch.commons.alerting.model.PPLTrigger.ConditionType +import org.opensearch.commons.alerting.model.PPLTrigger.NumResultsCondition import org.opensearch.commons.alerting.model.QueryLevelTrigger import org.opensearch.commons.alerting.model.QueryLevelTriggerRunResult import org.opensearch.commons.alerting.model.Schedule @@ -79,10 +84,18 @@ import org.opensearch.search.builder.SearchSourceBuilder import org.opensearch.test.OpenSearchTestCase.randomBoolean import org.opensearch.test.OpenSearchTestCase.randomInt import org.opensearch.test.OpenSearchTestCase.randomIntBetween +import org.opensearch.test.OpenSearchTestCase.randomLongBetween import org.opensearch.test.rest.OpenSearchRestTestCase +import org.opensearch.test.rest.OpenSearchRestTestCase.assertEquals import java.time.Instant import java.time.temporal.ChronoUnit +// constants for PPL Alerting tests +const val TIMESTAMP_FIELD = "timestamp" +const val TEST_INDEX_NAME = "index" +const val TEST_INDEX_MAPPINGS = + """"properties":{"timestamp":{"type":"date"},"abc":{"type":"keyword"},"number":{"type":"integer"}}""" + fun randomQueryLevelMonitor( name: String = OpenSearchRestTestCase.randomAlphaOfLength(10), user: User = randomUser(), @@ -227,6 +240,36 @@ fun randomDocumentLevelMonitor( ) } +fun randomPPLMonitor( + name: String = OpenSearchRestTestCase.randomAlphaOfLength(10), + enabled: Boolean = randomBoolean(), + schedule: Schedule = IntervalSchedule(interval = 5, unit = ChronoUnit.MINUTES), + lastUpdateTime: Instant = Instant.now().truncatedTo(ChronoUnit.MILLIS), + enabledTime: Instant? = if (enabled) Instant.now().truncatedTo(ChronoUnit.MILLIS) else null, + triggers: List = List(randomIntBetween(1, 5)) { randomPPLTrigger() }, + user: User? = randomUser(), + queryLanguage: QueryLanguage = QueryLanguage.PPL, + query: String = "source = $TEST_INDEX_NAME | head 10" +): Monitor { + return Monitor( + name = name, + enabled = enabled, + schedule = schedule, + lastUpdateTime = lastUpdateTime, + enabledTime = enabledTime, + monitorType = Monitor.MonitorType.PPL_MONITOR.value, + inputs = listOf( + PPLInput( + query = query, + queryLanguage = queryLanguage + ) + ), + triggers = triggers, + user = user, + uiMetadata = mapOf() + ) +} + fun randomWorkflow( id: String = Workflow.NO_ID, monitorIds: List, @@ -348,6 +391,35 @@ fun randomDocumentLevelTrigger( ) } +// random PPLTrigger defaults to a number_of_results trigger, because a custom condition +// would require knowledge of the PPL Monitor's query +// it is on the caller to be explicit and pass in valid arguments that would create either +// a valid PPL Monitor or one that intentionally throws an error during testing. +// e.g. to create a valid PPL Monitor, if conditionType is CUSTOM, +// numResultsCondition and numResultsValue must be null, while +// customCondition must not be null. +fun randomPPLTrigger( + id: String = UUIDs.base64UUID(), + name: String = OpenSearchRestTestCase.randomAlphaOfLength(10), + severity: String = "1", + actions: List = mutableListOf(), + conditionType: ConditionType = ConditionType.NUMBER_OF_RESULTS, + numResultsCondition: NumResultsCondition? = NumResultsCondition.entries.random(), + numResultsValue: Long? = randomLongBetween(1L, 50L), + customCondition: String? = null +): PPLTrigger { + return PPLTrigger( + id = id, + name = name, + severity = severity, + actions = actions, + conditionType = conditionType, + numResultsCondition = numResultsCondition, + numResultsValue = numResultsValue, + customCondition = customCondition + ) +} + fun randomBucketSelectorExtAggregationBuilder( name: String = OpenSearchRestTestCase.randomAlphaOfLength(10), bucketsPathsMap: MutableMap = mutableMapOf("avg" to "10"), @@ -424,10 +496,11 @@ fun randomTemplateScript( fun randomAction( name: String = OpenSearchRestTestCase.randomUnicodeOfLength(10), template: Script = randomTemplateScript("Hello World"), - destinationId: String = "", + subjectTemplate: Script = template, + destinationId: String = "abc", throttleEnabled: Boolean = false, throttle: Throttle = randomThrottle() -) = Action(name, destinationId, template, template, throttleEnabled, throttle, actionExecutionPolicy = null) +) = Action(name, destinationId, subjectTemplate, template, throttleEnabled, throttle, actionExecutionPolicy = null) fun randomActionWithPolicy( name: String = OpenSearchRestTestCase.randomUnicodeOfLength(10), @@ -810,3 +883,73 @@ fun randomAlertContext( fun Map.objectMap(key: String): Map> { return this[key] as Map> } + +fun assertPplMonitorsEqual(pplMonitor1: Monitor, pplMonitor2: Monitor) { + // note: Get and Search Monitor responses do not include User information by + // design, so that check is skipped + + // note: Update Monitor API intentionally overrides the enabledTime of the new given monitor + // with the enabledTime of the existing monitor being updated to ensure execution correctness, + // so that check is skipped + + assertEquals("Monitor enabled fields not equal", pplMonitor1.enabled, pplMonitor2.enabled) + assertEquals("Monitor schedules not equal", pplMonitor1.schedule, pplMonitor2.schedule) + assertEquals( + "Monitor query languages not equal", + (pplMonitor1.inputs[0] as PPLInput).queryLanguage, + (pplMonitor2.inputs[0] as PPLInput).queryLanguage + ) + assertEquals( + "Monitor queries not equal", + (pplMonitor1.inputs[0] as PPLInput).query, + (pplMonitor2.inputs[0] as PPLInput).query + ) + assertEquals("Number of triggers in monitor not equal", pplMonitor1.triggers.size, pplMonitor2.triggers.size) + + val sortedTriggers1 = pplMonitor1.triggers.sortedBy { it.id } + val sortedTriggers2 = pplMonitor2.triggers.sortedBy { it.id } + for (i in sortedTriggers1.indices) { + assertPplTriggersEqual(sortedTriggers1[i] as PPLTrigger, sortedTriggers2[i] as PPLTrigger) + } +} + +fun assertPplTriggersEqual(pplTrigger1: PPLTrigger, pplTrigger2: PPLTrigger) { + assertEquals( + "Monitor trigger IDs not equal", + pplTrigger1.id, + pplTrigger2.id + ) + + val id = pplTrigger1.id + + assertEquals( + "Monitor trigger $id names not equal", + pplTrigger1.name, + pplTrigger2.name + ) + assertEquals( + "Monitor trigger $id severities not equal", + pplTrigger1.severity, + pplTrigger2.severity + ) + assertEquals( + "Monitor trigger $id condition types not equal", + pplTrigger1.conditionType, + pplTrigger2.conditionType + ) + assertEquals( + "Monitor trigger $id number_of_results conditions not equal", + pplTrigger1.numResultsCondition, + pplTrigger2.numResultsCondition + ) + assertEquals( + "Monitor trigger $id number_of_results values not equal", + pplTrigger1.numResultsValue, + pplTrigger2.numResultsValue + ) + assertEquals( + "Monitor trigger $id custom conditions not equal", + pplTrigger1.customCondition, + pplTrigger2.customCondition + ) +} diff --git a/alerting/src/test/kotlin/org/opensearch/alerting/action/ExecuteMonitorRequestTests.kt b/alerting/src/test/kotlin/org/opensearch/alerting/action/ExecuteMonitorRequestTests.kt index f54b6fea6..45405f8a8 100644 --- a/alerting/src/test/kotlin/org/opensearch/alerting/action/ExecuteMonitorRequestTests.kt +++ b/alerting/src/test/kotlin/org/opensearch/alerting/action/ExecuteMonitorRequestTests.kt @@ -17,7 +17,7 @@ class ExecuteMonitorRequestTests : OpenSearchTestCase() { fun `test execute monitor request with id`() { - val req = ExecuteMonitorRequest(false, TimeValue.timeValueSeconds(100L), "1234", null) + val req = ExecuteMonitorRequest(false, false, TimeValue.timeValueSeconds(100L), "1234", null) assertNotNull(req) val out = BytesStreamOutput() @@ -32,7 +32,7 @@ class ExecuteMonitorRequestTests : OpenSearchTestCase() { fun `test execute monitor request with monitor`() { val monitor = randomQueryLevelMonitor().copy(inputs = listOf(SearchInput(emptyList(), SearchSourceBuilder()))) - val req = ExecuteMonitorRequest(false, TimeValue.timeValueSeconds(100L), null, monitor) + val req = ExecuteMonitorRequest(false, false, TimeValue.timeValueSeconds(100L), null, monitor) assertNotNull(req.monitor) val out = BytesStreamOutput() diff --git a/alerting/src/test/kotlin/org/opensearch/alerting/alerts/AlertIndicesIT.kt b/alerting/src/test/kotlin/org/opensearch/alerting/alerts/AlertIndicesIT.kt index 69a7e0363..5f19e27d7 100644 --- a/alerting/src/test/kotlin/org/opensearch/alerting/alerts/AlertIndicesIT.kt +++ b/alerting/src/test/kotlin/org/opensearch/alerting/alerts/AlertIndicesIT.kt @@ -54,7 +54,7 @@ class AlertIndicesIT : AlertingRestTestCase() { putAlertMappings( AlertIndices.alertMapping().trimStart('{').trimEnd('}') - .replace("\"schema_version\": 5", "\"schema_version\": 0") + .replace("\"schema_version\": 6", "\"schema_version\": 0") ) assertIndexExists(AlertIndices.ALERT_INDEX) assertIndexExists(AlertIndices.ALERT_HISTORY_WRITE_INDEX) @@ -64,9 +64,9 @@ class AlertIndicesIT : AlertingRestTestCase() { executeMonitor(createRandomMonitor()) assertIndexExists(AlertIndices.ALERT_INDEX) assertIndexExists(AlertIndices.ALERT_HISTORY_WRITE_INDEX) - verifyIndexSchemaVersion(ScheduledJob.SCHEDULED_JOBS_INDEX, 8) - verifyIndexSchemaVersion(AlertIndices.ALERT_INDEX, 5) - verifyIndexSchemaVersion(AlertIndices.ALERT_HISTORY_WRITE_INDEX, 5) + verifyIndexSchemaVersion(ScheduledJob.SCHEDULED_JOBS_INDEX, 9) + verifyIndexSchemaVersion(AlertIndices.ALERT_INDEX, 6) + verifyIndexSchemaVersion(AlertIndices.ALERT_HISTORY_WRITE_INDEX, 6) } fun `test update finding index mapping with new schema version`() { @@ -88,7 +88,7 @@ class AlertIndicesIT : AlertingRestTestCase() { val trueMonitor = createMonitor(randomDocumentLevelMonitor(inputs = listOf(docLevelInput), triggers = listOf(trigger))) executeMonitor(trueMonitor.id) assertIndexExists(AlertIndices.FINDING_HISTORY_WRITE_INDEX) - verifyIndexSchemaVersion(ScheduledJob.SCHEDULED_JOBS_INDEX, 8) + verifyIndexSchemaVersion(ScheduledJob.SCHEDULED_JOBS_INDEX, 9) verifyIndexSchemaVersion(AlertIndices.FINDING_HISTORY_WRITE_INDEX, 4) } diff --git a/alerting/src/test/kotlin/org/opensearch/alerting/resthandler/MonitorRestApiIT.kt b/alerting/src/test/kotlin/org/opensearch/alerting/resthandler/MonitorRestApiIT.kt index 2e3743572..183579a79 100644 --- a/alerting/src/test/kotlin/org/opensearch/alerting/resthandler/MonitorRestApiIT.kt +++ b/alerting/src/test/kotlin/org/opensearch/alerting/resthandler/MonitorRestApiIT.kt @@ -11,10 +11,14 @@ import org.apache.hc.core5.http.message.BasicHeader import org.opensearch.alerting.ALERTING_BASE_URI import org.opensearch.alerting.ALWAYS_RUN import org.opensearch.alerting.ANOMALY_DETECTOR_INDEX +import org.opensearch.alerting.AlertingPlugin.Companion.MONITOR_BASE_URI import org.opensearch.alerting.AlertingRestTestCase import org.opensearch.alerting.LEGACY_OPENDISTRO_ALERTING_BASE_URI +import org.opensearch.alerting.TEST_INDEX_MAPPINGS +import org.opensearch.alerting.TEST_INDEX_NAME import org.opensearch.alerting.alerts.AlertIndices import org.opensearch.alerting.anomalyDetectorIndexMapping +import org.opensearch.alerting.assertPplMonitorsEqual import org.opensearch.alerting.core.settings.ScheduledJobSettings import org.opensearch.alerting.makeRequest import org.opensearch.alerting.model.destination.Chime @@ -30,15 +34,24 @@ import org.opensearch.alerting.randomDocLevelMonitorInput import org.opensearch.alerting.randomDocLevelQuery import org.opensearch.alerting.randomDocumentLevelMonitor import org.opensearch.alerting.randomDocumentLevelTrigger +import org.opensearch.alerting.randomPPLMonitor +import org.opensearch.alerting.randomPPLTrigger import org.opensearch.alerting.randomQueryLevelMonitor import org.opensearch.alerting.randomQueryLevelTrigger +import org.opensearch.alerting.randomTemplateScript import org.opensearch.alerting.randomThrottle import org.opensearch.alerting.randomUser import org.opensearch.alerting.settings.AlertingSettings +import org.opensearch.alerting.settings.AlertingSettings.Companion.ALERTING_MAX_MONITORS +import org.opensearch.alerting.settings.AlertingSettings.Companion.NOTIFICATION_MESSAGE_SOURCE_MAX_LENGTH +import org.opensearch.alerting.settings.AlertingSettings.Companion.NOTIFICATION_SUBJECT_SOURCE_MAX_LENGTH +import org.opensearch.alerting.settings.AlertingSettings.Companion.PPL_MAX_QUERY_LENGTH import org.opensearch.alerting.toJsonString import org.opensearch.alerting.util.DestinationType import org.opensearch.client.ResponseException import org.opensearch.client.WarningFailureException +import org.opensearch.common.UUIDs +import org.opensearch.common.settings.Settings import org.opensearch.common.unit.TimeValue import org.opensearch.common.xcontent.XContentType import org.opensearch.commons.alerting.model.Alert @@ -47,6 +60,7 @@ import org.opensearch.commons.alerting.model.DocLevelMonitorInput import org.opensearch.commons.alerting.model.DocLevelQuery import org.opensearch.commons.alerting.model.DocumentLevelTrigger import org.opensearch.commons.alerting.model.Monitor +import org.opensearch.commons.alerting.model.PPLTrigger.ConditionType import org.opensearch.commons.alerting.model.QueryLevelTrigger import org.opensearch.commons.alerting.model.ScheduledJob import org.opensearch.commons.alerting.model.SearchInput @@ -55,6 +69,8 @@ import org.opensearch.core.common.bytes.BytesReference import org.opensearch.core.rest.RestStatus import org.opensearch.core.xcontent.ToXContent import org.opensearch.core.xcontent.XContentBuilder +import org.opensearch.core.xcontent.XContentParser +import org.opensearch.core.xcontent.XContentParserUtils import org.opensearch.index.query.QueryBuilders import org.opensearch.script.Script import org.opensearch.search.aggregations.AggregationBuilders @@ -72,7 +88,9 @@ import java.util.concurrent.TimeUnit @Suppress("UNCHECKED_CAST") class MonitorRestApiIT : AlertingRestTestCase() { - val USE_TYPED_KEYS = ToXContent.MapParams(mapOf("with_type" to "true")) + companion object { + val USE_TYPED_KEYS = ToXContent.MapParams(mapOf("with_type" to "true")) + } @Throws(Exception::class) fun `test plugin is loaded`() { @@ -1528,23 +1546,354 @@ class MonitorRestApiIT : AlertingRestTestCase() { } } - private fun validateAlertingStatsNodeResponse(nodesResponse: Map) { - assertEquals("Incorrect number of nodes", numberOfNodes, nodesResponse["total"]) - assertEquals("Failed nodes found during monitor stats call", 0, nodesResponse["failed"]) - assertEquals("More than $numberOfNodes successful node", numberOfNodes, nodesResponse["successful"]) + /* PPL Monitor Simple Case Tests */ + fun `test create ppl monitor`() { + createIndex(TEST_INDEX_NAME, Settings.EMPTY, TEST_INDEX_MAPPINGS) + val pplMonitor = randomPPLMonitor() + + val response = client().makeRequest("POST", MONITOR_BASE_URI, emptyMap(), pplMonitor.toHttpEntity()) + assertEquals("Unable to create a new monitor v2", RestStatus.CREATED, response.restStatus()) + + val responseBody = response.asMap() + val createdId = responseBody["_id"] as String + val createdVersion = responseBody["_version"] as Int + assertNotEquals("response is missing Id", Monitor.NO_ID, createdId) + assertEquals("incorrect version", 1, createdVersion) + } + + fun `test update ppl monitor`() { + val originalMonitor = createRandomPPLMonitor() + + val newMonitorConfig = randomPPLMonitor() + + val updateResponse = client().makeRequest( + "PUT", + "$MONITOR_BASE_URI/${originalMonitor.id}", + emptyMap(), newMonitorConfig.toHttpEntity() + ) + + assertEquals("Update monitor failed", RestStatus.OK, updateResponse.restStatus()) + val responseBody = updateResponse.asMap() + assertEquals("Updated monitor id doesn't match", originalMonitor.id, responseBody["_id"] as String) + assertEquals("Version not incremented", (originalMonitor.version + 1).toInt(), responseBody["_version"] as Int) + + val updatedMonitor = getMonitor(originalMonitor.id) + assertPplMonitorsEqual(newMonitorConfig, updatedMonitor) } - private fun isMonitorScheduled(monitorId: String, alertingStatsResponse: Map): Boolean { - val nodesInfo = alertingStatsResponse["nodes"] as Map - for (nodeId in nodesInfo.keys) { - val nodeInfo = nodesInfo[nodeId] as Map - val jobsInfo = nodeInfo["jobs_info"] as Map - if (jobsInfo.keys.contains(monitorId)) { - return true + fun `test get ppl monitor`() { + // first create the monitor + createIndex(TEST_INDEX_NAME, Settings.EMPTY, TEST_INDEX_MAPPINGS) + val pplMonitor = randomPPLMonitor() + + val createResponse = client().makeRequest("POST", MONITOR_BASE_URI, emptyMap(), pplMonitor.toHttpEntity()) + assertEquals("Unable to create a new monitor v2", RestStatus.CREATED, createResponse.restStatus()) + + val responseBody = createResponse.asMap() + val pplMonitorId = responseBody["_id"] as String + val pplMonitorVersion = (responseBody["_version"] as Int).toLong() + + // then attempt to get it + val response = client().makeRequest("GET", "$MONITOR_BASE_URI/$pplMonitorId") + assertEquals("Unable to get monitorV2 $pplMonitorId", RestStatus.OK, response.restStatus()) + + val parser = createParser(XContentType.JSON.xContent(), response.entity.content) + XContentParserUtils.ensureExpectedToken(XContentParser.Token.START_OBJECT, parser.nextToken(), parser) + + lateinit var id: String + var version: Long = 0 + lateinit var storedPplMonitor: Monitor + + while (parser.nextToken() != XContentParser.Token.END_OBJECT) { + parser.nextToken() + + when (parser.currentName()) { + "_id" -> id = parser.text() + "_version" -> version = parser.longValue() + "monitor" -> storedPplMonitor = Monitor.parse(parser) + "associated_workflows" -> { + XContentParserUtils.ensureExpectedToken( + XContentParser.Token.START_ARRAY, + parser.currentToken(), + parser + ) + while (parser.nextToken() != XContentParser.Token.END_ARRAY) { + // do nothing + } + } } } - return false + assertEquals( + "Monitor V2 ID from Get Monitor doesn't match one from Create Monitor response", + pplMonitorId, id + ) + assertEquals( + "Monitor V2 version from Get Monitor doesn't match one from Create Monitor response", + pplMonitorVersion, version + ) + assertPplMonitorsEqual(pplMonitor, storedPplMonitor) + } + + fun `test head ppl monitor`() { + val submittedPplMonitor = createRandomPPLMonitor() + val response = client().makeRequest("HEAD", "$MONITOR_BASE_URI/${submittedPplMonitor.id}") + assertEquals("Unable to get monitorV2 ${submittedPplMonitor.id}", RestStatus.OK, response.restStatus()) + } + + fun `test search ppl monitor with GET and match_all`() { + createRandomPPLMonitor() + + val search = SearchSourceBuilder().query(QueryBuilders.matchAllQuery()).toString() + val searchResponse = client().makeRequest( + "GET", "$MONITOR_BASE_URI/_search", + emptyMap(), StringEntity(search, ContentType.APPLICATION_JSON) + ) + + assertEquals("Search monitor failed", RestStatus.OK, searchResponse.restStatus()) + val xcp = createParser(XContentType.JSON.xContent(), searchResponse.entity.content) + val hits = xcp.map()["hits"]!! as Map> + val numberDocsFound = hits["total"]?.get("value") + assertEquals("PPL Monitor not found during search", 1, numberDocsFound) + } + + fun `test search ppl monitor with POST and term query on ID`() { + val pplMonitor = createRandomPPLMonitor() + + val search = SearchSourceBuilder().query(QueryBuilders.termQuery("_id", pplMonitor.id)).toString() + val searchResponse = client().makeRequest( + "POST", "$MONITOR_BASE_URI/_search", + emptyMap(), StringEntity(search, ContentType.APPLICATION_JSON) + ) + + assertEquals("Search monitor failed", RestStatus.OK, searchResponse.restStatus()) + val xcp = createParser(XContentType.JSON.xContent(), searchResponse.entity.content) + val hits = xcp.map()["hits"]!! as Map> + val numberDocsFound = hits["total"]?.get("value") + assertEquals("PPL Monitor not found during search", 1, numberDocsFound) + } + + fun `test delete ppl monitor`() { + val pplMonitor = createRandomPPLMonitor() + + val deleteResponse = client().makeRequest("DELETE", "$MONITOR_BASE_URI/${pplMonitor.id}") + assertEquals("Delete failed", RestStatus.OK, deleteResponse.restStatus()) + + val getResponse = client().makeRequest("HEAD", "$MONITOR_BASE_URI/${pplMonitor.id}") + assertEquals("Deleted monitor still exists", RestStatus.NOT_FOUND, getResponse.restStatus()) + } + + fun `test parsing ppl monitor as a scheduled job`() { + val monitorV2 = createRandomPPLMonitor() + + val builder = monitorV2.toXContentWithUser(XContentBuilder.builder(XContentType.JSON.xContent()), USE_TYPED_KEYS) + val string = BytesReference.bytes(builder).utf8ToString() + val xcp = createParser(XContentType.JSON.xContent(), string) + val scheduledJob = ScheduledJob.parse(xcp, monitorV2.id, monitorV2.version) + assertEquals(monitorV2, scheduledJob) + } + + /* PPL Monitor Validation Tests */ + fun `test create ppl monitor that queries nonexistent index fails`() { + val pplMonitorConfig = randomPPLMonitor( + query = "source = nonexistent_index | head 10" + ) + + // ensure the request fails + try { + createRandomPPLMonitor(pplMonitorConfig) + fail("Expected request to fail with BAD_REQUEST but it succeeded") + } catch (e: ResponseException) { + assertEquals("Unexpected status", RestStatus.BAD_REQUEST, e.response.restStatus()) + } + + // ensure no monitor was created + ensureNumMonitors(0) + } + + fun `test create ppl monitor with more than max allowed monitors fails`() { + adminClient().updateSettings(ALERTING_MAX_MONITORS.key, 1) + + createRandomPPLMonitor() + + // ensure the request fails + try { + createRandomPPLMonitor() + fail("Expected request to fail with BAD_REQUEST but it succeeded") + } catch (e: ResponseException) { + assertEquals("Unexpected status", RestStatus.BAD_REQUEST, e.response.restStatus()) + } + + // ensure no monitor was created + ensureNumMonitors(1) + } + + fun `test create ppl monitor with invalid query fails`() { + // ensure the request fails + try { + createRandomPPLMonitor( + randomPPLMonitor( + query = "source = $TEST_INDEX_NAME | not valid ppl" + ) + ) + fail("Expected request to fail with BAD_REQUEST but it succeeded") + } catch (e: ResponseException) { + assertEquals("Unexpected status", RestStatus.BAD_REQUEST, e.response.restStatus()) + } + + // ensure no monitor was created + ensureNumMonitors(0) + } + + fun `test create ppl monitor with query that's too long fails`() { + adminClient().updateSettings(PPL_MAX_QUERY_LENGTH.key, 1) + + // ensure the request fails + try { + createRandomPPLMonitor( + randomPPLMonitor( + query = "source = $TEST_INDEX_NAME | head 10" + ) + ) + fail("Expected request to fail with BAD_REQUEST but it succeeded") + } catch (e: ResponseException) { + assertEquals("Unexpected status", RestStatus.BAD_REQUEST, e.response.restStatus()) + } + + // ensure no monitor was created + ensureNumMonitors(0) + } + + fun `test create ppl monitor with invalid custom condition fails`() { + // ensure the request fails + try { + createRandomPPLMonitor( + randomPPLMonitor( + triggers = listOf( + randomPPLTrigger( + conditionType = ConditionType.CUSTOM, + customCondition = "eval result = 3 > 1", + numResultsCondition = null, + numResultsValue = null + ) + ), + query = "source = $TEST_INDEX_NAME | head 10" + ) + ) + fail("Expected request to fail with BAD_REQUEST but it succeeded") + } catch (e: ResponseException) { + assertEquals("Unexpected status", RestStatus.BAD_REQUEST, e.response.restStatus()) + } + + // ensure no monitor was created + ensureNumMonitors(0) + } + + fun `test create ppl monitor with notification subject source too long fails`() { + adminClient().updateSettings(NOTIFICATION_SUBJECT_SOURCE_MAX_LENGTH.key, 100) + + var subjectTooLong = "" + for (i in 0 until 101) { + subjectTooLong += "a" + } + + // ensure the request fails + try { + createRandomPPLMonitor( + randomPPLMonitor( + triggers = listOf( + randomPPLTrigger( + actions = listOf( + randomAction( + template = randomTemplateScript( + source = "some message" + ), + subjectTemplate = randomTemplateScript( + source = subjectTooLong + ) + ) + ) + ) + ) + ) + ) + fail("Expected request to fail with BAD_REQUEST but it succeeded") + } catch (e: ResponseException) { + assertEquals("Unexpected status", RestStatus.BAD_REQUEST, e.response.restStatus()) + } + + // ensure no monitor was created + ensureNumMonitors(0) + } + + fun `test create ppl monitor with notification message source too long fails`() { + adminClient().updateSettings(NOTIFICATION_MESSAGE_SOURCE_MAX_LENGTH.key, 1000) + + var messageTooLong = "" + for (i in 0 until 1001) { + messageTooLong += "a" + } + + // ensure the request fails + try { + createRandomPPLMonitor( + randomPPLMonitor( + triggers = listOf( + randomPPLTrigger( + actions = listOf( + randomAction( + template = randomTemplateScript( + source = messageTooLong + ), + subjectTemplate = randomTemplateScript( + source = "some subject" + ) + ) + ) + ) + ) + ) + ) + fail("Expected request to fail with BAD_REQUEST but it succeeded") + } catch (e: ResponseException) { + assertEquals("Unexpected status", RestStatus.BAD_REQUEST, e.response.restStatus()) + } + + // ensure no monitor was created + ensureNumMonitors(0) + } + + fun `test update nonexistent ppl monitor fails`() { + // the random monitor query searches index TEST_INDEX_NAME, + // so we need to create that first to ensure at least the request body is valid + createIndex(TEST_INDEX_NAME, Settings.EMPTY, TEST_INDEX_MAPPINGS) + + val monitorV2 = randomPPLMonitor() + val randomId = UUIDs.base64UUID() + + try { + client().makeRequest("PUT", "$MONITOR_BASE_URI/$randomId", emptyMap(), monitorV2.toHttpEntity()) + fail("Expected request to fail with NOT_FOUND but it succeeded") + } catch (e: ResponseException) { + assertEquals("Unexpected status", RestStatus.NOT_FOUND, e.response.restStatus()) + } + } + + fun `test delete nonexistent ppl monitor fails`() { + val randomId = UUIDs.base64UUID() + + try { + client().makeRequest("DELETE", "$MONITOR_BASE_URI/$randomId") + fail("Expected request to fail with NOT_FOUND but it succeeded") + } catch (e: ResponseException) { + assertEquals("Unexpected status", RestStatus.NOT_FOUND, e.response.restStatus()) + } + } + + private fun validateAlertingStatsNodeResponse(nodesResponse: Map) { + assertEquals("Incorrect number of nodes", numberOfNodes, nodesResponse["total"]) + assertEquals("Failed nodes found during monitor stats call", 0, nodesResponse["failed"]) + assertEquals("More than $numberOfNodes successful node", numberOfNodes, nodesResponse["successful"]) } private fun assertAlertingStatsSweeperEnabled(alertingStatsResponse: Map, expected: Boolean) { diff --git a/alerting/src/test/kotlin/org/opensearch/alerting/transport/AlertingSingleNodeTestCase.kt b/alerting/src/test/kotlin/org/opensearch/alerting/transport/AlertingSingleNodeTestCase.kt index 06af4c3d3..ea4b8ed3f 100644 --- a/alerting/src/test/kotlin/org/opensearch/alerting/transport/AlertingSingleNodeTestCase.kt +++ b/alerting/src/test/kotlin/org/opensearch/alerting/transport/AlertingSingleNodeTestCase.kt @@ -98,7 +98,7 @@ abstract class AlertingSingleNodeTestCase : OpenSearchSingleNodeTestCase() { } protected fun executeMonitor(monitor: Monitor, id: String?, dryRun: Boolean = true): ExecuteMonitorResponse? { - val request = ExecuteMonitorRequest(dryRun, TimeValue(Instant.now().toEpochMilli()), id, monitor) + val request = ExecuteMonitorRequest(dryRun, false, TimeValue(Instant.now().toEpochMilli()), id, monitor) return client().execute(ExecuteMonitorAction.INSTANCE, request).get() } @@ -491,7 +491,7 @@ abstract class AlertingSingleNodeTestCase : OpenSearchSingleNodeTestCase() { } protected fun executeWorkflow(workflow: Workflow? = null, id: String? = null, dryRun: Boolean = true): ExecuteWorkflowResponse? { - val request = ExecuteWorkflowRequest(dryRun, TimeValue(Instant.now().toEpochMilli()), id, workflow) + val request = ExecuteWorkflowRequest(dryRun, false, TimeValue(Instant.now().toEpochMilli()), id, workflow) return client().execute(ExecuteWorkflowAction.INSTANCE, request).get() } diff --git a/build.gradle b/build.gradle index 261213c07..ca13dade9 100644 --- a/build.gradle +++ b/build.gradle @@ -7,7 +7,7 @@ buildscript { apply from: 'build-tools/repositories.gradle' ext { - opensearch_version = System.getProperty("opensearch.version", "3.6.0-SNAPSHOT") + opensearch_version = System.getProperty("opensearch.version", "3.5.0-SNAPSHOT") buildVersionQualifier = System.getProperty("build.version_qualifier", "") isSnapshot = "true" == System.getProperty("build.snapshot", "true") // 3.0.0-SNAPSHOT -> 3.0.0.0-SNAPSHOT diff --git a/core/build.gradle b/core/build.gradle index cfce74c42..9aad7da88 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -8,7 +8,8 @@ apply plugin: 'opensearch.java-rest-test' apply plugin: 'org.jetbrains.kotlin.jvm' apply plugin: 'jacoco' -configurations{ +configurations { + zipArchive all { resolutionStrategy { // force commons-beanutils to a non-vulnerable version @@ -17,6 +18,18 @@ configurations{ } } +def sqlJarDirectory = "$buildDir/dependencies/opensearch-sql-plugin" + +task addJarsToClasspath(type: Copy) { + from(fileTree(dir: sqlJarDirectory)) { + include "opensearch-sql-${opensearch_build}.jar" + include "ppl-${opensearch_build}.jar" + include "protocol-${opensearch_build}.jar" + include "core-${opensearch_build}.jar" + } + into("$buildDir/classes") +} + dependencies { compileOnly "org.opensearch:opensearch:${opensearch_version}" implementation "org.jetbrains.kotlin:kotlin-stdlib:${kotlin_version}" @@ -26,8 +39,44 @@ dependencies { api "org.opensearch.client:opensearch-rest-client:${opensearch_version}" api "org.opensearch:common-utils:${common_utils_version}@jar" implementation 'commons-validator:commons-validator:1.7' + implementation 'org.json:json:20240303' + + api fileTree(dir: sqlJarDirectory, include: ["opensearch-sql-thin-${opensearch_build}.jar", "ppl-${opensearch_build}.jar", "protocol-${opensearch_build}.jar", "core-${opensearch_build}.jar"]) + + zipArchive group: 'org.opensearch.plugin', name:'opensearch-sql-plugin', version: "${opensearch_build}" testImplementation "org.opensearch.test:framework:${opensearch_version}" testImplementation "org.jetbrains.kotlin:kotlin-test:${kotlin_version}" testImplementation "org.jetbrains.kotlin:kotlin-test-junit:${kotlin_version}" } + +task extractSqlJar(type: Copy) { + mustRunAfter() + from(zipTree(configurations.zipArchive.find { it.name.startsWith("opensearch-sql-plugin") })) + into sqlJarDirectory +} + +task extractSqlClass(type: Copy, dependsOn: [extractSqlJar]) { + from zipTree("${sqlJarDirectory}/opensearch-sql-${opensearch_build}.jar") + into("$buildDir/opensearch-sql") + include 'org/opensearch/sql/**' +} + +task replaceSqlJar(type: Jar, dependsOn: [extractSqlClass]) { + from("$buildDir/opensearch-sql") + archiveFileName = "opensearch-sql-thin-${opensearch_build}.jar" + destinationDirectory = file(sqlJarDirectory) + doLast { + file("${sqlJarDirectory}/opensearch-sql-${opensearch_build}.jar").delete() + } +} + +tasks.addJarsToClasspath.dependsOn(replaceSqlJar) + +compileJava { + dependsOn addJarsToClasspath +} + +compileKotlin { + dependsOn addJarsToClasspath +} diff --git a/core/src/main/kotlin/org/opensearch/alerting/core/action/node/ScheduledJobStats.kt b/core/src/main/kotlin/org/opensearch/alerting/core/action/node/ScheduledJobStats.kt index 07792d553..4be049334 100644 --- a/core/src/main/kotlin/org/opensearch/alerting/core/action/node/ScheduledJobStats.kt +++ b/core/src/main/kotlin/org/opensearch/alerting/core/action/node/ScheduledJobStats.kt @@ -7,7 +7,8 @@ package org.opensearch.alerting.core.action.node import org.opensearch.action.support.nodes.BaseNodeResponse import org.opensearch.alerting.core.JobSweeperMetrics -import org.opensearch.alerting.core.resthandler.RestScheduledJobStatsHandler +import org.opensearch.alerting.core.resthandler.RestScheduledJobStatsHandler.Companion.JOBS_INFO +import org.opensearch.alerting.core.resthandler.RestScheduledJobStatsHandler.Companion.JOB_SCHEDULING_METRICS import org.opensearch.alerting.core.schedule.JobSchedulerMetrics import org.opensearch.cluster.node.DiscoveryNode import org.opensearch.core.common.io.stream.StreamInput @@ -69,13 +70,13 @@ class ScheduledJobStats : BaseNodeResponse, ToXContentFragment { builder.field("schedule_status", status) builder.field("roles", node.roles.map { it.roleName().uppercase(Locale.getDefault()) }) if (jobSweeperMetrics != null) { - builder.startObject(RestScheduledJobStatsHandler.JOB_SCHEDULING_METRICS) + builder.startObject(JOB_SCHEDULING_METRICS) jobSweeperMetrics!!.toXContent(builder, params) builder.endObject() } if (jobInfos != null) { - builder.startObject(RestScheduledJobStatsHandler.JOBS_INFO) + builder.startObject(JOBS_INFO) for (job in jobInfos!!) { builder.startObject(job.scheduledJobId) job.toXContent(builder, params) diff --git a/core/src/main/kotlin/org/opensearch/alerting/core/ppl/PPLPluginInterface.kt b/core/src/main/kotlin/org/opensearch/alerting/core/ppl/PPLPluginInterface.kt new file mode 100644 index 000000000..8ff15165c --- /dev/null +++ b/core/src/main/kotlin/org/opensearch/alerting/core/ppl/PPLPluginInterface.kt @@ -0,0 +1,43 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +package org.opensearch.alerting.core.ppl + +import org.opensearch.commons.utils.recreateObject +import org.opensearch.core.action.ActionListener +import org.opensearch.core.action.ActionResponse +import org.opensearch.sql.plugin.transport.PPLQueryAction +import org.opensearch.sql.plugin.transport.TransportPPLQueryRequest +import org.opensearch.sql.plugin.transport.TransportPPLQueryResponse +import org.opensearch.transport.client.node.NodeClient + +/** + * Transport action plugin interfaces for the SQL/PPL plugin + */ +@Suppress("UNCHECKED_CAST") +object PPLPluginInterface { + fun executeQuery( + client: NodeClient, + request: TransportPPLQueryRequest, + listener: ActionListener, + ) { + val wrappedListener = object : ActionListener { + override fun onResponse(response: ActionResponse) { + val recreated = recreateObject(response) { TransportPPLQueryResponse(it) } + listener.onResponse(recreated) + } + + override fun onFailure(exception: Exception) { + listener.onFailure(exception) + } + } as ActionListener + + client.execute( + PPLQueryAction.INSTANCE, + request, + wrappedListener + ) + } +} diff --git a/core/src/main/kotlin/org/opensearch/alerting/core/resthandler/RestScheduledJobStatsHandler.kt b/core/src/main/kotlin/org/opensearch/alerting/core/resthandler/RestScheduledJobStatsHandler.kt index fbe57ab19..e2384efbc 100644 --- a/core/src/main/kotlin/org/opensearch/alerting/core/resthandler/RestScheduledJobStatsHandler.kt +++ b/core/src/main/kotlin/org/opensearch/alerting/core/resthandler/RestScheduledJobStatsHandler.kt @@ -9,7 +9,6 @@ import org.opensearch.alerting.core.action.node.ScheduledJobsStatsAction import org.opensearch.alerting.core.action.node.ScheduledJobsStatsRequest import org.opensearch.core.common.Strings import org.opensearch.rest.BaseRestHandler -import org.opensearch.rest.BaseRestHandler.RestChannelConsumer import org.opensearch.rest.RestHandler import org.opensearch.rest.RestHandler.Route import org.opensearch.rest.RestRequest diff --git a/core/src/main/kotlin/org/opensearch/alerting/opensearchapi/OpenSearchExtensions.kt b/core/src/main/kotlin/org/opensearch/alerting/opensearchapi/OpenSearchExtensions.kt index 582d13fbe..fd500ef1d 100644 --- a/core/src/main/kotlin/org/opensearch/alerting/opensearchapi/OpenSearchExtensions.kt +++ b/core/src/main/kotlin/org/opensearch/alerting/opensearchapi/OpenSearchExtensions.kt @@ -14,6 +14,7 @@ import org.opensearch.OpenSearchException import org.opensearch.action.bulk.BackoffPolicy import org.opensearch.action.search.SearchResponse import org.opensearch.action.search.ShardSearchFailure +import org.opensearch.alerting.core.ppl.PPLPluginInterface import org.opensearch.common.settings.Settings import org.opensearch.common.util.concurrent.ThreadContext import org.opensearch.common.xcontent.XContentHelper @@ -170,6 +171,20 @@ suspend fun NotificationsPluginInterface.suspendUntil(block: NotificationsPl }) } +/** + * Converts [PPLPluginInterface] methods that take a callback into a kotlin suspending function. + * + * @param block - a block of code that is passed an [ActionListener] that should be passed to the PPLPluginInterface API. + */ +suspend fun PPLPluginInterface.suspendUntil(block: PPLPluginInterface.(ActionListener) -> Unit): T = + suspendCoroutine { cont -> + block(object : ActionListener { + override fun onResponse(response: T) = cont.resume(response) + + override fun onFailure(e: Exception) = cont.resumeWithException(e) + }) + } + class InjectorContextElement( id: String, settings: Settings, diff --git a/core/src/main/resources/mappings/scheduled-jobs.json b/core/src/main/resources/mappings/scheduled-jobs.json index 6e3d31c51..154829d64 100644 --- a/core/src/main/resources/mappings/scheduled-jobs.json +++ b/core/src/main/resources/mappings/scheduled-jobs.json @@ -1,6 +1,6 @@ { "_meta" : { - "schema_version": 8 + "schema_version": 9 }, "properties": { "monitor": { diff --git a/release-notes/opensearch-alerting.release-notes-3.5.0.0.md b/release-notes/opensearch-alerting.release-notes-3.5.0.0.md deleted file mode 100644 index e9c00745e..000000000 --- a/release-notes/opensearch-alerting.release-notes-3.5.0.0.md +++ /dev/null @@ -1,7 +0,0 @@ -## Version 3.5.0 Release Notes - -Compatible with OpenSearch and OpenSearch Dashboards version 3.5.0 - -### Features - -* Access control for results in trigger execution context ([#1991](https://github.com/opensearch-project/alerting/pull/1991)) \ No newline at end of file