diff --git a/build.gradle b/build.gradle index 9a73b963..6f28dd67 100644 --- a/build.gradle +++ b/build.gradle @@ -75,12 +75,16 @@ dependencies { compileOnly "org.jetbrains.kotlin:kotlin-stdlib:${kotlin_version}" compileOnly "org.jetbrains.kotlin:kotlin-stdlib-common:${kotlin_version}" compileOnly "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.3" // ${kotlin_version} does not work for coroutines + compileOnly "com.cronutils:cron-utils:9.1.6" + compileOnly "commons-validator:commons-validator:1.7" testImplementation "org.opensearch.test:framework:${opensearch_version}" testImplementation "org.jetbrains.kotlin:kotlin-test:${kotlin_version}" testImplementation "org.mockito:mockito-core:3.10.0" testImplementation 'org.junit.jupiter:junit-jupiter-api:5.7.2' testImplementation 'org.mockito:mockito-junit-jupiter:3.10.0' testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0" + testImplementation "com.cronutils:cron-utils:9.1.6" + testImplementation "commons-validator:commons-validator:1.7" testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.7.2' ktlint "com.pinterest:ktlint:0.44.0" @@ -213,4 +217,4 @@ task updateVersion { // Include the required files that needs to be updated with new Version ant.replaceregexp(file:'build.gradle', match: '"opensearch.version", "\\d.*"', replace: '"opensearch.version", "' + newVersion.tokenize('-')[0] + '-SNAPSHOT"', flags:'g', byline:true) } -} \ No newline at end of file +} \ No newline at end of file diff --git a/detekt.yml b/detekt.yml index 251da688..5d1d194d 100644 --- a/detekt.yml +++ b/detekt.yml @@ -7,19 +7,48 @@ style: ForbiddenComment: active: false + LoopWithTooManyJumpStatements: + maxJumpCount: 4 MaxLineLength: - maxLineLength: 150 + maxLineLength: 200 ThrowsCount: active: true max: 10 ReturnCount: active: true max: 10 + UtilityClassWithPublicConstructor: + active: false + +empty-blocks: + EmptyCatchBlock: + excludes: ['**/test/**'] + +exceptions: + SwallowedException: + excludes: ['**/test/**'] + ignoredExceptionTypes: + - 'ZoneRulesException' + - 'DateTimeException' complexity: LargeClass: excludes: ['**/test/**'] LongMethod: excludes: ['**/test/**'] + threshold: 110 LongParameterList: excludes: ['**/test/**'] + constructorThreshold: 8 + ComplexMethod: + threshold: 27 + NestedBlockDepth: + threshold: 10 + +naming: + ObjectPropertyNaming: + constantPattern: '[_A-Za-z][_A-Za-z0-9]*' + +performance: + SpreadOperator: + active: false diff --git a/src/main/kotlin/org/opensearch/commons/alerting/AlertingPluginInterface.kt b/src/main/kotlin/org/opensearch/commons/alerting/AlertingPluginInterface.kt new file mode 100644 index 00000000..a3ea6df0 --- /dev/null +++ b/src/main/kotlin/org/opensearch/commons/alerting/AlertingPluginInterface.kt @@ -0,0 +1,62 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package org.opensearch.commons.alerting + +import org.opensearch.action.ActionListener +import org.opensearch.action.ActionResponse +import org.opensearch.client.node.NodeClient +import org.opensearch.common.io.stream.Writeable +import org.opensearch.commons.alerting.action.AlertingActions +import org.opensearch.commons.alerting.action.IndexMonitorRequest +import org.opensearch.commons.alerting.action.IndexMonitorResponse +import org.opensearch.commons.notifications.action.BaseResponse +import org.opensearch.commons.utils.recreateObject + +/** + * All the transport action plugin interfaces for the Alerting plugin + */ +object AlertingPluginInterface { + + /** + * Index monitor interface. + * @param client Node client for making transport action + * @param request The request object + * @param listener The listener for getting response + */ + fun indexMonitor( + client: NodeClient, + request: IndexMonitorRequest, + listener: ActionListener + ) { + client.execute( + AlertingActions.INDEX_MONITOR_ACTION_TYPE, + request, + wrapActionListener(listener) { response -> + recreateObject(response) { + IndexMonitorResponse( + it + ) + } + } + ) + } + + @Suppress("UNCHECKED_CAST") + private fun wrapActionListener( + listener: ActionListener, + recreate: (Writeable) -> Response + ): ActionListener { + return object : ActionListener { + override fun onResponse(response: ActionResponse) { + val recreated = response as? Response ?: recreate(response) + listener.onResponse(recreated) + } + + override fun onFailure(exception: java.lang.Exception) { + listener.onFailure(exception) + } + } as ActionListener + } +} diff --git a/src/main/kotlin/org/opensearch/commons/alerting/action/AlertingActions.kt b/src/main/kotlin/org/opensearch/commons/alerting/action/AlertingActions.kt new file mode 100644 index 00000000..9e4b4003 --- /dev/null +++ b/src/main/kotlin/org/opensearch/commons/alerting/action/AlertingActions.kt @@ -0,0 +1,14 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +package org.opensearch.commons.alerting.action + +import org.opensearch.action.ActionType + +object AlertingActions { + const val INDEX_MONITOR_ACTION_NAME = "cluster:admin/opendistro/alerting/monitor/write" + + val INDEX_MONITOR_ACTION_TYPE = + ActionType(INDEX_MONITOR_ACTION_NAME, ::IndexMonitorResponse) +} diff --git a/src/main/kotlin/org/opensearch/commons/alerting/action/IndexMonitorRequest.kt b/src/main/kotlin/org/opensearch/commons/alerting/action/IndexMonitorRequest.kt new file mode 100644 index 00000000..6a9b75dd --- /dev/null +++ b/src/main/kotlin/org/opensearch/commons/alerting/action/IndexMonitorRequest.kt @@ -0,0 +1,59 @@ +package org.opensearch.commons.alerting.action + +import org.opensearch.action.ActionRequest +import org.opensearch.action.ActionRequestValidationException +import org.opensearch.action.support.WriteRequest +import org.opensearch.common.io.stream.StreamInput +import org.opensearch.common.io.stream.StreamOutput +import org.opensearch.commons.alerting.model.Monitor +import org.opensearch.rest.RestRequest +import java.io.IOException + +class IndexMonitorRequest : ActionRequest { + val monitorId: String + val seqNo: Long + val primaryTerm: Long + val refreshPolicy: WriteRequest.RefreshPolicy + val method: RestRequest.Method + var monitor: Monitor + + constructor( + monitorId: String, + seqNo: Long, + primaryTerm: Long, + refreshPolicy: WriteRequest.RefreshPolicy, + method: RestRequest.Method, + monitor: Monitor + ) : super() { + this.monitorId = monitorId + this.seqNo = seqNo + this.primaryTerm = primaryTerm + this.refreshPolicy = refreshPolicy + this.method = method + this.monitor = monitor + } + + @Throws(IOException::class) + constructor(sin: StreamInput) : this( + monitorId = sin.readString(), + seqNo = sin.readLong(), + primaryTerm = sin.readLong(), + refreshPolicy = WriteRequest.RefreshPolicy.readFrom(sin), + method = sin.readEnum(RestRequest.Method::class.java), + monitor = Monitor.readFrom(sin) as Monitor + ) + + override fun validate(): ActionRequestValidationException? { + return null + } + + @Throws(IOException::class) + override fun writeTo(out: StreamOutput) { + out.writeString(monitorId) + out.writeLong(seqNo) + out.writeLong(primaryTerm) + refreshPolicy.writeTo(out) + out.writeEnum(method) + monitor.writeTo(out) + } +} diff --git a/src/main/kotlin/org/opensearch/commons/alerting/action/IndexMonitorResponse.kt b/src/main/kotlin/org/opensearch/commons/alerting/action/IndexMonitorResponse.kt new file mode 100644 index 00000000..5b62a843 --- /dev/null +++ b/src/main/kotlin/org/opensearch/commons/alerting/action/IndexMonitorResponse.kt @@ -0,0 +1,64 @@ +package org.opensearch.commons.alerting.action + +import org.opensearch.common.io.stream.StreamInput +import org.opensearch.common.io.stream.StreamOutput +import org.opensearch.common.xcontent.ToXContent +import org.opensearch.common.xcontent.XContentBuilder +import org.opensearch.commons.alerting.model.Monitor +import org.opensearch.commons.alerting.util.IndexUtils.Companion._ID +import org.opensearch.commons.alerting.util.IndexUtils.Companion._PRIMARY_TERM +import org.opensearch.commons.alerting.util.IndexUtils.Companion._SEQ_NO +import org.opensearch.commons.alerting.util.IndexUtils.Companion._VERSION +import org.opensearch.commons.notifications.action.BaseResponse +import java.io.IOException + +class IndexMonitorResponse : BaseResponse { + var id: String + var version: Long + var seqNo: Long + var primaryTerm: Long + var monitor: Monitor + + constructor( + id: String, + version: Long, + seqNo: Long, + primaryTerm: Long, + monitor: Monitor + ) : super() { + this.id = id + this.version = version + this.seqNo = seqNo + this.primaryTerm = primaryTerm + this.monitor = monitor + } + + @Throws(IOException::class) + constructor(sin: StreamInput) : this( + sin.readString(), // id + sin.readLong(), // version + sin.readLong(), // seqNo + sin.readLong(), // primaryTerm + Monitor.readFrom(sin) as Monitor // monitor + ) + + @Throws(IOException::class) + override fun writeTo(out: StreamOutput) { + out.writeString(id) + out.writeLong(version) + out.writeLong(seqNo) + out.writeLong(primaryTerm) + monitor.writeTo(out) + } + + @Throws(IOException::class) + override fun toXContent(builder: XContentBuilder, params: ToXContent.Params): XContentBuilder { + return builder.startObject() + .field(_ID, id) + .field(_VERSION, version) + .field(_SEQ_NO, seqNo) + .field(_PRIMARY_TERM, primaryTerm) + .field("monitor", monitor) + .endObject() + } +} diff --git a/src/main/kotlin/org/opensearch/commons/alerting/aggregation/bucketselectorext/BucketSelectorExtAggregationBuilder.kt b/src/main/kotlin/org/opensearch/commons/alerting/aggregation/bucketselectorext/BucketSelectorExtAggregationBuilder.kt new file mode 100644 index 00000000..d19d0877 --- /dev/null +++ b/src/main/kotlin/org/opensearch/commons/alerting/aggregation/bucketselectorext/BucketSelectorExtAggregationBuilder.kt @@ -0,0 +1,244 @@ +package org.opensearch.commons.alerting.aggregation.bucketselectorext + +import org.opensearch.common.ParseField +import org.opensearch.common.ParsingException +import org.opensearch.common.io.stream.StreamInput +import org.opensearch.common.io.stream.StreamOutput +import org.opensearch.common.xcontent.ToXContent +import org.opensearch.common.xcontent.XContentBuilder +import org.opensearch.common.xcontent.XContentParser +import org.opensearch.commons.alerting.aggregation.bucketselectorext.BucketSelectorExtFilter.Companion.BUCKET_SELECTOR_COMPOSITE_AGG_FILTER +import org.opensearch.commons.alerting.aggregation.bucketselectorext.BucketSelectorExtFilter.Companion.BUCKET_SELECTOR_FILTER +import org.opensearch.script.Script +import org.opensearch.search.aggregations.pipeline.AbstractPipelineAggregationBuilder +import org.opensearch.search.aggregations.pipeline.BucketHelpers +import org.opensearch.search.aggregations.pipeline.PipelineAggregator +import java.io.IOException +import java.util.Objects +import kotlin.collections.ArrayList +import kotlin.collections.HashMap + +class BucketSelectorExtAggregationBuilder : + AbstractPipelineAggregationBuilder { + private val bucketsPathsMap: Map + val parentBucketPath: String + val script: Script + val filter: BucketSelectorExtFilter? + private var gapPolicy = BucketHelpers.GapPolicy.SKIP + + constructor( + name: String, + bucketsPathsMap: Map, + script: Script, + parentBucketPath: String, + filter: BucketSelectorExtFilter? + ) : super(name, NAME.preferredName, listOf(parentBucketPath).toTypedArray()) { + this.bucketsPathsMap = bucketsPathsMap + this.script = script + this.parentBucketPath = parentBucketPath + this.filter = filter + } + + @Throws(IOException::class) + @Suppress("UNCHECKED_CAST") + constructor(sin: StreamInput) : super(sin, NAME.preferredName) { + bucketsPathsMap = sin.readMap() as MutableMap + script = Script(sin) + gapPolicy = BucketHelpers.GapPolicy.readFrom(sin) + parentBucketPath = sin.readString() + filter = if (sin.readBoolean()) { + BucketSelectorExtFilter(sin) + } else { + null + } + } + + @Throws(IOException::class) + override fun doWriteTo(out: StreamOutput) { + out.writeMap(bucketsPathsMap) + script.writeTo(out) + gapPolicy.writeTo(out) + out.writeString(parentBucketPath) + if (filter != null) { + out.writeBoolean(true) + filter.writeTo(out) + } else { + out.writeBoolean(false) + } + } + + /** + * Sets the gap policy to use for this aggregation. + */ + fun gapPolicy(gapPolicy: BucketHelpers.GapPolicy?): BucketSelectorExtAggregationBuilder { + requireNotNull(gapPolicy) { "[gapPolicy] must not be null: [$name]" } + this.gapPolicy = gapPolicy + return this + } + + override fun createInternal(metaData: Map?): PipelineAggregator { + return BucketSelectorExtAggregator(name, bucketsPathsMap, parentBucketPath, script, gapPolicy, filter, metaData) + } + + @Throws(IOException::class) + public override fun internalXContent(builder: XContentBuilder, params: ToXContent.Params): XContentBuilder { + builder.field(PipelineAggregator.Parser.BUCKETS_PATH.preferredName, bucketsPathsMap as Map?) + .field(PARENT_BUCKET_PATH.preferredName, parentBucketPath) + .field(Script.SCRIPT_PARSE_FIELD.preferredName, script) + .field(PipelineAggregator.Parser.GAP_POLICY.preferredName, gapPolicy.getName()) + if (filter != null) { + if (filter.isCompositeAggregation) { + builder.startObject(BUCKET_SELECTOR_COMPOSITE_AGG_FILTER.preferredName) + .value(filter) + .endObject() + } else { + builder.startObject(BUCKET_SELECTOR_FILTER.preferredName) + .value(filter) + .endObject() + } + } + return builder + } + + override fun overrideBucketsPath(): Boolean { + return true + } + + override fun validate(context: ValidationContext) { + // Nothing to check + } + + override fun hashCode(): Int { + return Objects.hash(super.hashCode(), bucketsPathsMap, script, gapPolicy) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || javaClass != other.javaClass) return false + if (!super.equals(other)) return false + val otherCast = other as BucketSelectorExtAggregationBuilder + return ( + bucketsPathsMap == otherCast.bucketsPathsMap && + script == otherCast.script && + gapPolicy == otherCast.gapPolicy + ) + } + + override fun getWriteableName(): String { + return NAME.preferredName + } + + companion object { + val NAME = ParseField("bucket_selector_ext") + val PARENT_BUCKET_PATH = ParseField("parent_bucket_path") + + @Throws(IOException::class) + fun parse(reducerName: String, parser: XContentParser): BucketSelectorExtAggregationBuilder { + var token: XContentParser.Token + var script: Script? = null + var currentFieldName: String? = null + var bucketsPathsMap: MutableMap? = null + var gapPolicy: BucketHelpers.GapPolicy? = null + var parentBucketPath: String? = null + var filter: BucketSelectorExtFilter? = null + while (parser.nextToken().also { token = it } !== XContentParser.Token.END_OBJECT) { + if (token === XContentParser.Token.FIELD_NAME) { + currentFieldName = parser.currentName() + } else if (token === XContentParser.Token.VALUE_STRING) { + when { + PipelineAggregator.Parser.BUCKETS_PATH.match(currentFieldName, parser.deprecationHandler) -> { + bucketsPathsMap = HashMap() + bucketsPathsMap["_value"] = parser.text() + } + PipelineAggregator.Parser.GAP_POLICY.match(currentFieldName, parser.deprecationHandler) -> { + gapPolicy = BucketHelpers.GapPolicy.parse(parser.text(), parser.tokenLocation) + } + Script.SCRIPT_PARSE_FIELD.match(currentFieldName, parser.deprecationHandler) -> { + script = Script.parse(parser) + } + PARENT_BUCKET_PATH.match(currentFieldName, parser.deprecationHandler) -> { + parentBucketPath = parser.text() + } + else -> { + throw ParsingException( + parser.tokenLocation, + "Unknown key for a $token in [$reducerName]: [$currentFieldName]." + ) + } + } + } else if (token === XContentParser.Token.START_ARRAY) { + if (PipelineAggregator.Parser.BUCKETS_PATH.match(currentFieldName, parser.deprecationHandler)) { + val paths: MutableList = ArrayList() + while (parser.nextToken().also { token = it } !== XContentParser.Token.END_ARRAY) { + val path = parser.text() + paths.add(path) + } + bucketsPathsMap = HashMap() + for (i in paths.indices) { + bucketsPathsMap["_value$i"] = paths[i] + } + } else { + throw ParsingException( + parser.tokenLocation, + "Unknown key for a $token in [$reducerName]: [$currentFieldName]." + ) + } + } else if (token === XContentParser.Token.START_OBJECT) { + when { + Script.SCRIPT_PARSE_FIELD.match(currentFieldName, parser.deprecationHandler) -> { + script = Script.parse(parser) + } + PipelineAggregator.Parser.BUCKETS_PATH.match(currentFieldName, parser.deprecationHandler) -> { + val map = parser.map() + bucketsPathsMap = HashMap() + for ((key, value) in map) { + bucketsPathsMap[key] = value.toString() + } + } + BUCKET_SELECTOR_FILTER.match(currentFieldName, parser.deprecationHandler) -> { + filter = BucketSelectorExtFilter.parse(reducerName, false, parser) + } + BUCKET_SELECTOR_COMPOSITE_AGG_FILTER.match( + currentFieldName, + parser.deprecationHandler + ) -> { + filter = BucketSelectorExtFilter.parse(reducerName, true, parser) + } + else -> { + throw ParsingException( + parser.tokenLocation, + "Unknown key for a $token in [$reducerName]: [$currentFieldName]." + ) + } + } + } else { + throw ParsingException(parser.tokenLocation, "Unexpected token $token in [$reducerName].") + } + } + if (bucketsPathsMap == null) { + throw ParsingException( + parser.tokenLocation, + "Missing required field [" + PipelineAggregator.Parser.BUCKETS_PATH.preferredName + "] for bucket_selector aggregation [" + reducerName + "]" + ) + } + if (script == null) { + throw ParsingException( + parser.tokenLocation, + "Missing required field [" + Script.SCRIPT_PARSE_FIELD.preferredName + "] for bucket_selector aggregation [" + reducerName + "]" + ) + } + + if (parentBucketPath == null) { + throw ParsingException( + parser.tokenLocation, + "Missing required field [" + PARENT_BUCKET_PATH + "] for bucket_selector aggregation [" + reducerName + "]" + ) + } + val factory = BucketSelectorExtAggregationBuilder(reducerName, bucketsPathsMap, script, parentBucketPath, filter) + if (gapPolicy != null) { + factory.gapPolicy(gapPolicy) + } + return factory + } + } +} diff --git a/src/main/kotlin/org/opensearch/commons/alerting/aggregation/bucketselectorext/BucketSelectorExtAggregator.kt b/src/main/kotlin/org/opensearch/commons/alerting/aggregation/bucketselectorext/BucketSelectorExtAggregator.kt new file mode 100644 index 00000000..373bd5e9 --- /dev/null +++ b/src/main/kotlin/org/opensearch/commons/alerting/aggregation/bucketselectorext/BucketSelectorExtAggregator.kt @@ -0,0 +1,155 @@ +package org.opensearch.commons.alerting.aggregation.bucketselectorext + +import org.apache.lucene.util.BytesRef +import org.opensearch.common.io.stream.StreamInput +import org.opensearch.common.io.stream.StreamOutput +import org.opensearch.commons.alerting.aggregation.bucketselectorext.BucketSelectorExtAggregationBuilder.Companion.NAME +import org.opensearch.script.BucketAggregationSelectorScript +import org.opensearch.script.Script +import org.opensearch.search.DocValueFormat +import org.opensearch.search.aggregations.Aggregations +import org.opensearch.search.aggregations.InternalAggregation +import org.opensearch.search.aggregations.InternalMultiBucketAggregation +import org.opensearch.search.aggregations.bucket.SingleBucketAggregation +import org.opensearch.search.aggregations.bucket.composite.InternalComposite +import org.opensearch.search.aggregations.bucket.terms.IncludeExclude +import org.opensearch.search.aggregations.pipeline.BucketHelpers +import org.opensearch.search.aggregations.pipeline.SiblingPipelineAggregator +import org.opensearch.search.aggregations.support.AggregationPath +import java.io.IOException + +class BucketSelectorExtAggregator : SiblingPipelineAggregator { + private var name: String? = null + private var bucketsPathsMap: Map + private var parentBucketPath: String + private var script: Script + private var gapPolicy: BucketHelpers.GapPolicy + private var bucketSelectorExtFilter: BucketSelectorExtFilter? = null + + constructor( + name: String?, + bucketsPathsMap: Map, + parentBucketPath: String, + script: Script, + gapPolicy: BucketHelpers.GapPolicy, + filter: BucketSelectorExtFilter?, + metadata: Map? + ) : super(name, bucketsPathsMap.values.toTypedArray(), metadata) { + this.bucketsPathsMap = bucketsPathsMap + this.parentBucketPath = parentBucketPath + this.script = script + this.gapPolicy = gapPolicy + this.bucketSelectorExtFilter = filter + } + + /** + * Read from a stream. + */ + @Suppress("UNCHECKED_CAST") + @Throws(IOException::class) + constructor(sin: StreamInput) : super(sin.readString(), null, null) { + script = Script(sin) + gapPolicy = BucketHelpers.GapPolicy.readFrom(sin) + bucketsPathsMap = sin.readMap() as Map + parentBucketPath = sin.readString() + if (sin.readBoolean()) { + bucketSelectorExtFilter = BucketSelectorExtFilter(sin) + } else { + bucketSelectorExtFilter = null + } + } + + @Throws(IOException::class) + override fun doWriteTo(out: StreamOutput) { + out.writeString(name) + script.writeTo(out) + gapPolicy.writeTo(out) + out.writeGenericValue(bucketsPathsMap) + out.writeString(parentBucketPath) + if (bucketSelectorExtFilter != null) { + out.writeBoolean(true) + bucketSelectorExtFilter!!.writeTo(out) + } else { + out.writeBoolean(false) + } + } + + override fun getWriteableName(): String { + return NAME.preferredName + } + + override fun doReduce(aggregations: Aggregations, reduceContext: InternalAggregation.ReduceContext): InternalAggregation { + val parentBucketPathList = AggregationPath.parse(parentBucketPath).pathElementsAsStringList + var subAggregations: Aggregations = aggregations + for (i in 0 until parentBucketPathList.size - 1) { + subAggregations = subAggregations.get(parentBucketPathList[0]).aggregations + } + val originalAgg = subAggregations.get(parentBucketPathList.last()) as InternalMultiBucketAggregation<*, *> + val buckets = originalAgg.buckets + val factory = reduceContext.scriptService().compile(script, BucketAggregationSelectorScript.CONTEXT) + val selectedBucketsIndex: MutableList = ArrayList() + for (i in buckets.indices) { + val bucket = buckets[i] + if (bucketSelectorExtFilter != null) { + var accepted = true + if (bucketSelectorExtFilter!!.isCompositeAggregation) { + val compBucketKeyObj = (bucket as InternalComposite.InternalBucket).key + val filtersMap: HashMap? = bucketSelectorExtFilter!!.filtersMap + for (sourceKey in compBucketKeyObj.keys) { + if (filtersMap != null) { + if (filtersMap.containsKey(sourceKey)) { + val obj = compBucketKeyObj[sourceKey] + accepted = isAccepted(obj!!, filtersMap[sourceKey]) + if (!accepted) break + } else { + accepted = false + break + } + } + } + } else { + accepted = isAccepted(bucket.key, bucketSelectorExtFilter!!.filters) + } + if (!accepted) continue + } + + val vars: MutableMap = HashMap() + if (script.params != null) { + vars.putAll(script.params) + } + for ((varName, bucketsPath) in bucketsPathsMap) { + val value = BucketHelpers.resolveBucketValue(originalAgg, bucket, bucketsPath, gapPolicy) + vars[varName] = value + } + val executableScript = factory.newInstance(vars) + // TODO: can we use one instance of the script for all buckets? it should be stateless? + if (executableScript.execute()) { + selectedBucketsIndex.add(i) + } + } + + return BucketSelectorIndices( + name(), parentBucketPath, selectedBucketsIndex, originalAgg.metadata + ) + } + + private fun isAccepted(obj: Any, filter: IncludeExclude?): Boolean { + return when (obj.javaClass) { + String::class.java -> { + val stringFilter = filter!!.convertToStringFilter(DocValueFormat.RAW) + stringFilter.accept(BytesRef(obj as String)) + } + java.lang.Long::class.java, Long::class.java -> { + val longFilter = filter!!.convertToLongFilter(DocValueFormat.RAW) + longFilter.accept(obj as Long) + } + java.lang.Double::class.java, Double::class.java -> { + val doubleFilter = filter!!.convertToDoubleFilter() + doubleFilter.accept(obj as Long) + } + else -> { + throw IllegalStateException("Object is not comparable. Please use one of String, Long or Double type.") + } + } + } +} diff --git a/src/main/kotlin/org/opensearch/commons/alerting/aggregation/bucketselectorext/BucketSelectorExtFilter.kt b/src/main/kotlin/org/opensearch/commons/alerting/aggregation/bucketselectorext/BucketSelectorExtFilter.kt new file mode 100644 index 00000000..0a776d0f --- /dev/null +++ b/src/main/kotlin/org/opensearch/commons/alerting/aggregation/bucketselectorext/BucketSelectorExtFilter.kt @@ -0,0 +1,140 @@ +package org.opensearch.commons.alerting.aggregation.bucketselectorext + +import org.opensearch.common.ParseField +import org.opensearch.common.ParsingException +import org.opensearch.common.io.stream.StreamInput +import org.opensearch.common.io.stream.StreamOutput +import org.opensearch.common.xcontent.ToXContent +import org.opensearch.common.xcontent.XContentBuilder +import org.opensearch.common.xcontent.XContentParser +import org.opensearch.commons.notifications.model.BaseModel +import org.opensearch.search.aggregations.bucket.terms.IncludeExclude +import java.io.IOException + +class BucketSelectorExtFilter : BaseModel { + // used for composite aggregations + val filtersMap: HashMap? + // used for filtering string term aggregation + val filters: IncludeExclude? + + constructor(filters: IncludeExclude?) { + filtersMap = null + this.filters = filters + } + + constructor(filtersMap: HashMap?) { + this.filtersMap = filtersMap + filters = null + } + + constructor(sin: StreamInput) { + if (sin.readBoolean()) { + val size: Int = sin.readVInt() + filtersMap = java.util.HashMap() + + var i = 0 + while (i <= size) { + filtersMap[sin.readString()] = IncludeExclude(sin) + ++i + } + filters = null + } else { + filters = IncludeExclude(sin) + filtersMap = null + } + } + + @Throws(IOException::class) + override fun writeTo(out: StreamOutput) { + val isCompAgg = isCompositeAggregation + out.writeBoolean(isCompAgg) + if (isCompAgg) { + out.writeVInt(filtersMap!!.size) + for ((key, value) in filtersMap) { + out.writeString(key) + value.writeTo(out) + } + } else { + filters!!.writeTo(out) + } + } + + @Throws(IOException::class) + override fun toXContent(builder: XContentBuilder, params: ToXContent.Params): XContentBuilder { + if (isCompositeAggregation) { + for ((key, filter) in filtersMap!!) { + builder.startObject(key) + filter.toXContent(builder, params) + builder.endObject() + } + } else { + filters!!.toXContent(builder, params) + } + return builder + } + + val isCompositeAggregation: Boolean + get() = if (filtersMap != null && filters == null) { + true + } else if (filtersMap == null && filters != null) { + false + } else { + throw IllegalStateException("Type of selector cannot be determined") + } + + companion object { + const val NAME = "filter" + var BUCKET_SELECTOR_FILTER = ParseField("filter") + var BUCKET_SELECTOR_COMPOSITE_AGG_FILTER = ParseField("composite_agg_filter") + + @Throws(IOException::class) + fun parse(reducerName: String, isCompositeAggregation: Boolean, parser: XContentParser): BucketSelectorExtFilter { + var token: XContentParser.Token + return if (isCompositeAggregation) { + val filtersMap = HashMap() + while (parser.nextToken().also { token = it } !== XContentParser.Token.END_OBJECT) { + if (token === XContentParser.Token.FIELD_NAME) { + val sourceKey = parser.currentName() + token = parser.nextToken() + filtersMap[sourceKey] = parseIncludeExclude(reducerName, parser) + } else { + throw ParsingException( + parser.tokenLocation, + "Unknown key for a " + token + " in [" + reducerName + "]: [" + parser.currentName() + "]." + ) + } + } + BucketSelectorExtFilter(filtersMap) + } else { + BucketSelectorExtFilter(parseIncludeExclude(reducerName, parser)) + } + } + + @Throws(IOException::class) + private fun parseIncludeExclude(reducerName: String, parser: XContentParser): IncludeExclude { + var token: XContentParser.Token + var include: IncludeExclude? = null + var exclude: IncludeExclude? = null + while (parser.nextToken().also { token = it } !== XContentParser.Token.END_OBJECT) { + val fieldName = parser.currentName() + when { + IncludeExclude.INCLUDE_FIELD.match(fieldName, parser.deprecationHandler) -> { + parser.nextToken() + include = IncludeExclude.parseInclude(parser) + } + IncludeExclude.EXCLUDE_FIELD.match(fieldName, parser.deprecationHandler) -> { + parser.nextToken() + exclude = IncludeExclude.parseExclude(parser) + } + else -> { + throw ParsingException( + parser.tokenLocation, + "Unknown key for a $token in [$reducerName]: [$fieldName]." + ) + } + } + } + return IncludeExclude.merge(include, exclude) + } + } +} diff --git a/src/main/kotlin/org/opensearch/commons/alerting/aggregation/bucketselectorext/BucketSelectorIndices.kt b/src/main/kotlin/org/opensearch/commons/alerting/aggregation/bucketselectorext/BucketSelectorIndices.kt new file mode 100644 index 00000000..9cb238e7 --- /dev/null +++ b/src/main/kotlin/org/opensearch/commons/alerting/aggregation/bucketselectorext/BucketSelectorIndices.kt @@ -0,0 +1,68 @@ +package org.opensearch.commons.alerting.aggregation.bucketselectorext + +import org.opensearch.common.io.stream.StreamOutput +import org.opensearch.common.xcontent.ToXContent +import org.opensearch.common.xcontent.XContentBuilder +import org.opensearch.search.aggregations.InternalAggregation +import java.io.IOException +import java.util.Objects + +open class BucketSelectorIndices( + name: String?, + private var parentBucketPath: String, + var bucketIndices: List, + metaData: Map? +) : InternalAggregation(name, metaData) { + + @Throws(IOException::class) + override fun doWriteTo(out: StreamOutput) { + out.writeString(parentBucketPath) + out.writeIntArray(bucketIndices.stream().mapToInt { i: Int? -> i!! }.toArray()) + } + + override fun getWriteableName(): String { + return name + } + + override fun reduce(aggregations: List, reduceContext: ReduceContext): BucketSelectorIndices { + throw UnsupportedOperationException("Not supported") + } + + override fun mustReduceOnSingleInternalAgg(): Boolean { + return false + } + + override fun getProperty(path: MutableList?): Any { + throw UnsupportedOperationException("Not supported") + } + + object Fields { + const val PARENT_BUCKET_PATH = "parent_bucket_path" + const val BUCKET_INDICES = "bucket_indices" + } + + @Throws(IOException::class) + override fun doXContentBody(builder: XContentBuilder, params: ToXContent.Params): XContentBuilder { + builder.field(Fields.PARENT_BUCKET_PATH, parentBucketPath) + builder.field(Fields.BUCKET_INDICES, bucketIndices) + otherStatsToXContent(builder) + return builder + } + + @Throws(IOException::class) + protected fun otherStatsToXContent(builder: XContentBuilder): XContentBuilder { + return builder + } + + override fun hashCode(): Int { + return Objects.hash(super.hashCode(), parentBucketPath) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || javaClass != other.javaClass) return false + if (!super.equals(other)) return false + val otherCast = other as BucketSelectorIndices + return name == otherCast.name && parentBucketPath == otherCast.parentBucketPath + } +} diff --git a/src/main/kotlin/org/opensearch/commons/alerting/model/BucketLevelTrigger.kt b/src/main/kotlin/org/opensearch/commons/alerting/model/BucketLevelTrigger.kt new file mode 100644 index 00000000..56975beb --- /dev/null +++ b/src/main/kotlin/org/opensearch/commons/alerting/model/BucketLevelTrigger.kt @@ -0,0 +1,143 @@ +package org.opensearch.commons.alerting.model + +import org.opensearch.common.CheckedFunction +import org.opensearch.common.ParseField +import org.opensearch.common.UUIDs +import org.opensearch.common.io.stream.StreamInput +import org.opensearch.common.io.stream.StreamOutput +import org.opensearch.common.xcontent.NamedXContentRegistry +import org.opensearch.common.xcontent.ToXContent +import org.opensearch.common.xcontent.XContentBuilder +import org.opensearch.common.xcontent.XContentParser +import org.opensearch.common.xcontent.XContentParserUtils +import org.opensearch.commons.alerting.aggregation.bucketselectorext.BucketSelectorExtAggregationBuilder +import org.opensearch.commons.alerting.model.Trigger.Companion.ACTIONS_FIELD +import org.opensearch.commons.alerting.model.Trigger.Companion.ID_FIELD +import org.opensearch.commons.alerting.model.Trigger.Companion.NAME_FIELD +import org.opensearch.commons.alerting.model.Trigger.Companion.SEVERITY_FIELD +import org.opensearch.commons.alerting.model.action.Action +import java.io.IOException + +data class BucketLevelTrigger( + override val id: String = UUIDs.base64UUID(), + override val name: String, + override val severity: String, + val bucketSelector: BucketSelectorExtAggregationBuilder, + override val actions: List +) : Trigger { + + @Throws(IOException::class) + constructor(sin: StreamInput) : this( + sin.readString(), // id + sin.readString(), // name + sin.readString(), // severity + BucketSelectorExtAggregationBuilder(sin), // condition + sin.readList(::Action) // actions + ) + + override fun toXContent(builder: XContentBuilder, params: ToXContent.Params): XContentBuilder { + builder.startObject() + .startObject(BUCKET_LEVEL_TRIGGER_FIELD) + .field(ID_FIELD, id) + .field(NAME_FIELD, name) + .field(SEVERITY_FIELD, severity) + .startObject(CONDITION_FIELD) + bucketSelector.internalXContent(builder, params) + builder.endObject() + .field(ACTIONS_FIELD, actions.toTypedArray()) + .endObject() + .endObject() + return builder + } + + override fun name(): String { + return BUCKET_LEVEL_TRIGGER_FIELD + } + + @Throws(IOException::class) + override fun writeTo(out: StreamOutput) { + out.writeString(id) + out.writeString(name) + out.writeString(severity) + bucketSelector.writeTo(out) + out.writeCollection(actions) + } + + fun asTemplateArg(): Map { + return mapOf( + ID_FIELD to id, + NAME_FIELD to name, + SEVERITY_FIELD to severity, + ACTIONS_FIELD to actions.map { it.asTemplateArg() }, + PARENT_BUCKET_PATH to getParentBucketPath() + ) + } + + fun getParentBucketPath(): String { + return bucketSelector.parentBucketPath + } + + companion object { + const val BUCKET_LEVEL_TRIGGER_FIELD = "bucket_level_trigger" + const val CONDITION_FIELD = "condition" + const val PARENT_BUCKET_PATH = "parentBucketPath" + + val XCONTENT_REGISTRY = NamedXContentRegistry.Entry( + Trigger::class.java, ParseField(BUCKET_LEVEL_TRIGGER_FIELD), + CheckedFunction { parseInner(it) } + ) + + @JvmStatic + @Throws(IOException::class) + fun parseInner(xcp: XContentParser): BucketLevelTrigger { + var id = UUIDs.base64UUID() // assign a default triggerId if one is not specified + lateinit var name: String + lateinit var severity: String + val actions: MutableList = mutableListOf() + XContentParserUtils.ensureExpectedToken(XContentParser.Token.START_OBJECT, xcp.currentToken(), xcp) + lateinit var bucketSelector: BucketSelectorExtAggregationBuilder + + while (xcp.nextToken() != XContentParser.Token.END_OBJECT) { + val fieldName = xcp.currentName() + + xcp.nextToken() + when (fieldName) { + ID_FIELD -> id = xcp.text() + NAME_FIELD -> name = xcp.text() + SEVERITY_FIELD -> severity = xcp.text() + CONDITION_FIELD -> { + // Using the trigger id as the name in the bucket selector since it is validated for uniqueness within Monitors. + // The contents of the trigger definition are round-tripped through parse and toXContent during Monitor creation + // ensuring that the id is available here in the version of the Monitor object that will be executed, even if the + // user submitted a custom trigger id after the condition definition. + bucketSelector = BucketSelectorExtAggregationBuilder.parse(id, xcp) + } + ACTIONS_FIELD -> { + XContentParserUtils.ensureExpectedToken( + XContentParser.Token.START_ARRAY, + xcp.currentToken(), + xcp + ) + while (xcp.nextToken() != XContentParser.Token.END_ARRAY) { + actions.add(Action.parse(xcp)) + } + } + } + } + + return BucketLevelTrigger( + id = requireNotNull(id) { "Trigger id is null." }, + name = requireNotNull(name) { "Trigger name is null" }, + severity = requireNotNull(severity) { "Trigger severity is null" }, + bucketSelector = requireNotNull(bucketSelector) { "Trigger condition is null" }, + actions = requireNotNull(actions) { "Trigger actions are null" } + ) + } + + @JvmStatic + @Throws(IOException::class) + fun readFrom(sin: StreamInput): BucketLevelTrigger { + return BucketLevelTrigger(sin) + } + } +} diff --git a/src/main/kotlin/org/opensearch/commons/alerting/model/ClusterMetricsInput.kt b/src/main/kotlin/org/opensearch/commons/alerting/model/ClusterMetricsInput.kt new file mode 100644 index 00000000..fc805917 --- /dev/null +++ b/src/main/kotlin/org/opensearch/commons/alerting/model/ClusterMetricsInput.kt @@ -0,0 +1,316 @@ +package org.opensearch.commons.alerting.model + +import org.apache.commons.validator.routines.UrlValidator +import org.apache.http.client.utils.URIBuilder +import org.opensearch.common.CheckedFunction +import org.opensearch.common.ParseField +import org.opensearch.common.io.stream.StreamInput +import org.opensearch.common.io.stream.StreamOutput +import org.opensearch.common.xcontent.NamedXContentRegistry +import org.opensearch.common.xcontent.ToXContent +import org.opensearch.common.xcontent.XContentBuilder +import org.opensearch.common.xcontent.XContentParser +import org.opensearch.common.xcontent.XContentParserUtils +import java.io.IOException +import java.net.URI + +val ILLEGAL_PATH_PARAMETER_CHARACTERS = arrayOf(':', '"', '+', '\\', '|', '?', '#', '>', '<', ' ') + +/** + * This is a data class for a URI type of input for Monitors specifically for local clusters. + */ +data class ClusterMetricsInput( + var path: String, + var pathParams: String = "", + var url: String +) : Input { + val clusterMetricType: ClusterMetricType + val constructedUri: URI + + // Verify parameters are valid during creation + init { + require(validateFields()) { + "The uri.api_type field, uri.path field, or uri.uri field must be defined." + } + + // Create an UrlValidator that only accepts "http" and "https" as valid scheme and allows local URLs. + val urlValidator = UrlValidator(arrayOf("http", "https"), UrlValidator.ALLOW_LOCAL_URLS) + + // Build url field by field if not provided as whole. + constructedUri = toConstructedUri() + + require(urlValidator.isValid(constructedUri.toString())) { + "Invalid URI constructed from the path and path_params inputs, or the url input." + } + + if (url.isNotEmpty() && validateFieldsNotEmpty()) + require(constructedUri == constructUrlFromInputs()) { + "The provided URL and URI fields form different URLs." + } + + require(constructedUri.host.lowercase() == SUPPORTED_HOST) { + "Only host '$SUPPORTED_HOST' is supported." + } + require(constructedUri.port == SUPPORTED_PORT) { + "Only port '$SUPPORTED_PORT' is supported." + } + + clusterMetricType = findApiType(constructedUri.path) + this.parseEmptyFields() + } + + @Throws(IOException::class) + constructor(sin: StreamInput) : this( + sin.readString(), // path + sin.readString(), // path params + sin.readString() // url + ) + + override fun toXContent(builder: XContentBuilder, params: ToXContent.Params): XContentBuilder { + return builder.startObject() + .startObject(URI_FIELD) + .field(API_TYPE_FIELD, clusterMetricType) + .field(PATH_FIELD, path) + .field(PATH_PARAMS_FIELD, pathParams) + .field(URL_FIELD, url) + .endObject() + .endObject() + } + + override fun name(): String { + return URI_FIELD + } + + override fun writeTo(out: StreamOutput) { + out.writeString(clusterMetricType.toString()) + out.writeString(path) + out.writeString(pathParams) + out.writeString(url) + } + + companion object { + const val SUPPORTED_SCHEME = "http" + const val SUPPORTED_HOST = "localhost" + const val SUPPORTED_PORT = 9200 + + const val API_TYPE_FIELD = "api_type" + const val PATH_FIELD = "path" + const val PATH_PARAMS_FIELD = "path_params" + const val URL_FIELD = "url" + const val URI_FIELD = "uri" + + val XCONTENT_REGISTRY = NamedXContentRegistry.Entry(Input::class.java, ParseField(URI_FIELD), CheckedFunction { parseInner(it) }) + + /** + * This parse function uses [XContentParser] to parse JSON input and store corresponding fields to create a [ClusterMetricsInput] object + */ + @JvmStatic @Throws(IOException::class) + fun parseInner(xcp: XContentParser): ClusterMetricsInput { + var path = "" + var pathParams = "" + var url = "" + + XContentParserUtils.ensureExpectedToken(XContentParser.Token.START_OBJECT, xcp.currentToken(), xcp) + + while (xcp.nextToken() != XContentParser.Token.END_OBJECT) { + val fieldName = xcp.currentName() + xcp.nextToken() + when (fieldName) { + PATH_FIELD -> path = xcp.text() + PATH_PARAMS_FIELD -> pathParams = xcp.text() + URL_FIELD -> url = xcp.text() + } + } + return ClusterMetricsInput(path, pathParams, url) + } + } + + /** + * Constructs the [URI] using either the provided [url], or the + * supported scheme, host, and port and provided [path]+[pathParams]. + * @return The [URI] constructed from [url] if it's defined; + * otherwise a [URI] constructed from the provided [URI] fields. + */ + private fun toConstructedUri(): URI { + return if (url.isEmpty()) { + constructUrlFromInputs() + } else { + URIBuilder(url).build() + } + } + + /** + * Isolates just the path parameters from the [ClusterMetricsInput] URI. + * @return The path parameters portion of the [ClusterMetricsInput] URI. + * @throws IllegalArgumentException if the [ClusterMetricType] requires path parameters, but none are supplied; + * or when path parameters are provided for an [ClusterMetricType] that does not use path parameters. + */ + fun parsePathParams(): String { + val path = this.constructedUri.path + val apiType = this.clusterMetricType + + var pathParams: String + if (this.pathParams.isNotEmpty()) { + pathParams = this.pathParams + } else { + val prependPath = if (apiType.supportsPathParams) apiType.prependPath else apiType.defaultPath + pathParams = path.removePrefix(prependPath) + pathParams = pathParams.removeSuffix(apiType.appendPath) + } + + if (pathParams.isNotEmpty()) { + pathParams = pathParams.trim('/') + ILLEGAL_PATH_PARAMETER_CHARACTERS.forEach { character -> + if (pathParams.contains(character)) + throw IllegalArgumentException( + "The provided path parameters contain invalid characters or spaces. Please omit: " + "${ILLEGAL_PATH_PARAMETER_CHARACTERS.joinToString(" ")}" + ) + } + } + + if (apiType.requiresPathParams && pathParams.isEmpty()) + throw IllegalArgumentException("The API requires path parameters.") + if (!apiType.supportsPathParams && pathParams.isNotEmpty()) + throw IllegalArgumentException("The API does not use path parameters.") + + return pathParams + } + + /** + * Examines the path of a [ClusterMetricsInput] to determine which API is being called. + * @param uriPath The path to examine. + * @return The [ClusterMetricType] associated with the [ClusterMetricsInput] monitor. + * @throws IllegalArgumentException when the API to call cannot be determined from the URI. + */ + private fun findApiType(uriPath: String): ClusterMetricType { + var apiType = ClusterMetricType.BLANK + ClusterMetricType.values() + .filter { option -> option != ClusterMetricType.BLANK } + .forEach { option -> + if (uriPath.startsWith(option.prependPath) || uriPath.startsWith(option.defaultPath)) + apiType = option + } + if (apiType.isBlank()) + throw IllegalArgumentException("The API could not be determined from the provided URI.") + return apiType + } + + /** + * Constructs a [URI] from the supported scheme, host, and port, and the provided [path], and [pathParams]. + * @return The constructed [URI]. + */ + private fun constructUrlFromInputs(): URI { + val uriBuilder = URIBuilder() + .setScheme(SUPPORTED_SCHEME) + .setHost(SUPPORTED_HOST) + .setPort(SUPPORTED_PORT) + .setPath(path + pathParams) + return uriBuilder.build() + } + + /** + * If [url] field is empty, populates it with [constructedUri]. + * If [path] and [pathParams] are empty, populates them with values from [url]. + */ + private fun parseEmptyFields() { + if (pathParams.isEmpty()) + pathParams = this.parsePathParams() + if (path.isEmpty()) + path = if (pathParams.isEmpty()) clusterMetricType.defaultPath else clusterMetricType.prependPath + if (url.isEmpty()) + url = constructedUri.toString() + } + + /** + * Helper function to confirm at least [url], or required URI component fields are defined. + * @return TRUE if at least either [url] or the other components are provided; otherwise FALSE. + */ + private fun validateFields(): Boolean { + return url.isNotEmpty() || validateFieldsNotEmpty() + } + + /** + * Confirms that required URI component fields are defined. + * Only validating path for now, as that's the only required field. + * @return TRUE if all those fields are defined; otherwise FALSE. + */ + private fun validateFieldsNotEmpty(): Boolean { + return path.isNotEmpty() + } + + /** + * An enum class to quickly reference various supported API. + */ + enum class ClusterMetricType( + val defaultPath: String, + val prependPath: String, + val appendPath: String, + val supportsPathParams: Boolean, + val requiresPathParams: Boolean + ) { + BLANK("", "", "", false, false), + CAT_PENDING_TASKS( + "/_cat/pending_tasks", + "/_cat/pending_tasks", + "", + false, + false + ), + CAT_RECOVERY( + "/_cat/recovery", + "/_cat/recovery", + "", + true, + false + ), + CAT_SNAPSHOTS( + "/_cat/snapshots", + "/_cat/snapshots", + "", + true, + true + ), + CAT_TASKS( + "/_cat/tasks", + "/_cat/tasks", + "", + false, + false + ), + CLUSTER_HEALTH( + "/_cluster/health", + "/_cluster/health", + "", + true, + false + ), + CLUSTER_SETTINGS( + "/_cluster/settings", + "/_cluster/settings", + "", + false, + false + ), + CLUSTER_STATS( + "/_cluster/stats", + "/_cluster/stats", + "", + true, + false + ), + NODES_STATS( + "/_nodes/stats", + "/_nodes", + "", + false, + false + ); + + /** + * @return TRUE if the [ClusterMetricType] is [BLANK]; otherwise FALSE. + */ + fun isBlank(): Boolean { + return this === BLANK + } + } +} diff --git a/src/main/kotlin/org/opensearch/commons/alerting/model/DocLevelMonitorInput.kt b/src/main/kotlin/org/opensearch/commons/alerting/model/DocLevelMonitorInput.kt new file mode 100644 index 00000000..9a914f3e --- /dev/null +++ b/src/main/kotlin/org/opensearch/commons/alerting/model/DocLevelMonitorInput.kt @@ -0,0 +1,114 @@ +package org.opensearch.commons.alerting.model + +import org.opensearch.common.CheckedFunction +import org.opensearch.common.ParseField +import org.opensearch.common.io.stream.StreamInput +import org.opensearch.common.io.stream.StreamOutput +import org.opensearch.common.xcontent.NamedXContentRegistry +import org.opensearch.common.xcontent.ToXContent +import org.opensearch.common.xcontent.XContentBuilder +import org.opensearch.common.xcontent.XContentParser +import org.opensearch.common.xcontent.XContentParserUtils +import java.io.IOException + +data class DocLevelMonitorInput( + val description: String = NO_DESCRIPTION, + val indices: List, + val queries: List +) : Input { + + @Throws(IOException::class) + constructor(sin: StreamInput) : this( + sin.readString(), // description + sin.readStringList(), // indices + sin.readList(::DocLevelQuery) // docLevelQueries + ) + + fun asTemplateArg(): Map { + return mapOf( + DESCRIPTION_FIELD to description, + INDICES_FIELD to indices, + QUERIES_FIELD to queries.map { it.asTemplateArg() } + ) + } + + override fun name(): String { + return DOC_LEVEL_INPUT_FIELD + } + + @Throws(IOException::class) + override fun writeTo(out: StreamOutput) { + out.writeString(description) + out.writeStringCollection(indices) + out.writeCollection(queries) + } + + override fun toXContent(builder: XContentBuilder, params: ToXContent.Params): XContentBuilder { + builder.startObject() + .startObject(DOC_LEVEL_INPUT_FIELD) + .field(DESCRIPTION_FIELD, description) + .field(INDICES_FIELD, indices.toTypedArray()) + .field(QUERIES_FIELD, queries.toTypedArray()) + .endObject() + .endObject() + return builder + } + + companion object { + const val DESCRIPTION_FIELD = "description" + const val INDICES_FIELD = "indices" + const val DOC_LEVEL_INPUT_FIELD = "doc_level_input" + const val QUERIES_FIELD = "queries" + + const val NO_DESCRIPTION = "" + + val XCONTENT_REGISTRY = NamedXContentRegistry.Entry( + Input::class.java, + ParseField(DOC_LEVEL_INPUT_FIELD), CheckedFunction { parse(it) } + ) + + @JvmStatic @Throws(IOException::class) + fun parse(xcp: XContentParser): DocLevelMonitorInput { + var description: String = NO_DESCRIPTION + val indices: MutableList = mutableListOf() + val docLevelQueries: MutableList = mutableListOf() + + XContentParserUtils.ensureExpectedToken(XContentParser.Token.START_OBJECT, xcp.currentToken(), xcp) + while (xcp.nextToken() != XContentParser.Token.END_OBJECT) { + val fieldName = xcp.currentName() + xcp.nextToken() + + when (fieldName) { + DESCRIPTION_FIELD -> description = xcp.text() + INDICES_FIELD -> { + XContentParserUtils.ensureExpectedToken( + XContentParser.Token.START_ARRAY, + xcp.currentToken(), + xcp + ) + while (xcp.nextToken() != XContentParser.Token.END_ARRAY) { + indices.add(xcp.text()) + } + } + QUERIES_FIELD -> { + XContentParserUtils.ensureExpectedToken( + XContentParser.Token.START_ARRAY, + xcp.currentToken(), + xcp + ) + while (xcp.nextToken() != XContentParser.Token.END_ARRAY) { + docLevelQueries.add(DocLevelQuery.parse(xcp)) + } + } + } + } + + return DocLevelMonitorInput(description = description, indices = indices, queries = docLevelQueries) + } + + @JvmStatic @Throws(IOException::class) + fun readFrom(sin: StreamInput): DocLevelMonitorInput { + return DocLevelMonitorInput(sin) + } + } +} diff --git a/src/main/kotlin/org/opensearch/commons/alerting/model/DocLevelQuery.kt b/src/main/kotlin/org/opensearch/commons/alerting/model/DocLevelQuery.kt new file mode 100644 index 00000000..220c928d --- /dev/null +++ b/src/main/kotlin/org/opensearch/commons/alerting/model/DocLevelQuery.kt @@ -0,0 +1,130 @@ +package org.opensearch.commons.alerting.model + +import org.opensearch.common.io.stream.StreamInput +import org.opensearch.common.io.stream.StreamOutput +import org.opensearch.common.xcontent.ToXContent +import org.opensearch.common.xcontent.XContentBuilder +import org.opensearch.common.xcontent.XContentParser +import org.opensearch.common.xcontent.XContentParserUtils +import org.opensearch.commons.notifications.model.BaseModel +import java.io.IOException +import java.lang.IllegalArgumentException +import java.util.UUID + +data class DocLevelQuery( + val id: String = UUID.randomUUID().toString(), + val name: String, + val query: String, + val tags: List = mutableListOf() +) : BaseModel { + + init { + // Ensure the name and tags have valid characters + validateQuery(name) + for (tag in tags) { + validateQuery(tag) + } + } + + @Throws(IOException::class) + constructor(sin: StreamInput) : this( + sin.readString(), // id + sin.readString(), // name + sin.readString(), // query + sin.readStringList() // tags + ) + + fun asTemplateArg(): Map { + return mapOf( + QUERY_ID_FIELD to id, + NAME_FIELD to name, + QUERY_FIELD to query, + TAGS_FIELD to tags + ) + } + + @Throws(IOException::class) + override fun writeTo(out: StreamOutput) { + out.writeString(id) + out.writeString(name) + out.writeString(query) + out.writeStringCollection(tags) + } + + override fun toXContent(builder: XContentBuilder, params: ToXContent.Params): XContentBuilder { + builder.startObject() + .field(QUERY_ID_FIELD, id) + .field(NAME_FIELD, name) + .field(QUERY_FIELD, query) + .field(TAGS_FIELD, tags.toTypedArray()) + .endObject() + return builder + } + + companion object { + const val QUERY_ID_FIELD = "id" + const val NAME_FIELD = "name" + const val QUERY_FIELD = "query" + const val TAGS_FIELD = "tags" + const val NO_ID = "" + val INVALID_CHARACTERS: List = listOf(" ", "[", "]", "{", "}", "(", ")") + + @JvmStatic @Throws(IOException::class) + fun parse(xcp: XContentParser): DocLevelQuery { + var id: String = UUID.randomUUID().toString() + lateinit var query: String + lateinit var name: String + val tags: MutableList = mutableListOf() + + XContentParserUtils.ensureExpectedToken(XContentParser.Token.START_OBJECT, xcp.currentToken(), xcp) + while (xcp.nextToken() != XContentParser.Token.END_OBJECT) { + val fieldName = xcp.currentName() + xcp.nextToken() + + when (fieldName) { + QUERY_ID_FIELD -> id = xcp.text() + NAME_FIELD -> { + name = xcp.text() + validateQuery(name) + } + QUERY_FIELD -> query = xcp.text() + TAGS_FIELD -> { + XContentParserUtils.ensureExpectedToken( + XContentParser.Token.START_ARRAY, + xcp.currentToken(), + xcp + ) + while (xcp.nextToken() != XContentParser.Token.END_ARRAY) { + val tag = xcp.text() + validateQuery(tag) + tags.add(tag) + } + } + } + } + + return DocLevelQuery( + id = id, + name = name, + query = query, + tags = tags + ) + } + + @JvmStatic @Throws(IOException::class) + fun readFrom(sin: StreamInput): DocLevelQuery { + return DocLevelQuery(sin) + } + + // TODO: add test for this + private fun validateQuery(stringVal: String) { + for (inValidChar in INVALID_CHARACTERS) { + if (stringVal.contains(inValidChar)) { + throw IllegalArgumentException( + "They query name or tag, $stringVal, contains an invalid character: [' ','[',']','{','}','(',')']" + ) + } + } + } + } +} diff --git a/src/main/kotlin/org/opensearch/commons/alerting/model/DocumentLevelTrigger.kt b/src/main/kotlin/org/opensearch/commons/alerting/model/DocumentLevelTrigger.kt new file mode 100644 index 00000000..a9cd0d69 --- /dev/null +++ b/src/main/kotlin/org/opensearch/commons/alerting/model/DocumentLevelTrigger.kt @@ -0,0 +1,159 @@ +package org.opensearch.commons.alerting.model + +import org.opensearch.common.CheckedFunction +import org.opensearch.common.ParseField +import org.opensearch.common.UUIDs +import org.opensearch.common.io.stream.StreamInput +import org.opensearch.common.io.stream.StreamOutput +import org.opensearch.common.xcontent.NamedXContentRegistry +import org.opensearch.common.xcontent.ToXContent +import org.opensearch.common.xcontent.XContentBuilder +import org.opensearch.common.xcontent.XContentParser +import org.opensearch.common.xcontent.XContentParserUtils +import org.opensearch.commons.alerting.model.Trigger.Companion.ACTIONS_FIELD +import org.opensearch.commons.alerting.model.Trigger.Companion.ID_FIELD +import org.opensearch.commons.alerting.model.Trigger.Companion.NAME_FIELD +import org.opensearch.commons.alerting.model.Trigger.Companion.SEVERITY_FIELD +import org.opensearch.commons.alerting.model.action.Action +import org.opensearch.script.Script +import java.io.IOException + +data class DocumentLevelTrigger( + override val id: String = UUIDs.base64UUID(), + override val name: String, + override val severity: String, + override val actions: List, + val condition: Script +) : Trigger { + + @Throws(IOException::class) + constructor(sin: StreamInput) : this( + sin.readString(), // id + sin.readString(), // name + sin.readString(), // severity + sin.readList(::Action), // actions + Script(sin) + ) + + override fun toXContent(builder: XContentBuilder, params: ToXContent.Params): XContentBuilder { + builder.startObject() + .startObject(DOCUMENT_LEVEL_TRIGGER_FIELD) + .field(ID_FIELD, id) + .field(NAME_FIELD, name) + .field(SEVERITY_FIELD, severity) + .startObject(CONDITION_FIELD) + .field(SCRIPT_FIELD, condition) + .endObject() + .field(ACTIONS_FIELD, actions.toTypedArray()) + .endObject() + .endObject() + return builder + } + + override fun name(): String { + return DOCUMENT_LEVEL_TRIGGER_FIELD + } + + /** Returns a representation of the trigger suitable for passing into painless and mustache scripts. */ + fun asTemplateArg(): Map { + return mapOf( + ID_FIELD to id, + NAME_FIELD to name, + SEVERITY_FIELD to severity, + ACTIONS_FIELD to actions.map { it.asTemplateArg() } + ) + } + + @Throws(IOException::class) + override fun writeTo(out: StreamOutput) { + out.writeString(id) + out.writeString(name) + out.writeString(severity) + out.writeCollection(actions) + condition.writeTo(out) + } + + companion object { + const val DOCUMENT_LEVEL_TRIGGER_FIELD = "document_level_trigger" + const val CONDITION_FIELD = "condition" + const val SCRIPT_FIELD = "script" + const val QUERY_IDS_FIELD = "query_ids" + + val XCONTENT_REGISTRY = NamedXContentRegistry.Entry( + Trigger::class.java, ParseField(DOCUMENT_LEVEL_TRIGGER_FIELD), + CheckedFunction { parseInner(it) } + ) + + @JvmStatic @Throws(IOException::class) + fun parseInner(xcp: XContentParser): DocumentLevelTrigger { + var id = UUIDs.base64UUID() // assign a default triggerId if one is not specified + lateinit var name: String + lateinit var severity: String + lateinit var condition: Script + val queryIds: MutableList = mutableListOf() + val actions: MutableList = mutableListOf() + + if (xcp.currentToken() != XContentParser.Token.START_OBJECT && xcp.currentToken() != XContentParser.Token.FIELD_NAME) { + XContentParserUtils.throwUnknownToken(xcp.currentToken(), xcp.tokenLocation) + } + + // If the parser began on START_OBJECT, move to the next token so that the while loop enters on + // the fieldName (or END_OBJECT if it's empty). + if (xcp.currentToken() == XContentParser.Token.START_OBJECT) xcp.nextToken() + + while (xcp.currentToken() != XContentParser.Token.END_OBJECT) { + val fieldName = xcp.currentName() + + xcp.nextToken() + when (fieldName) { + ID_FIELD -> id = xcp.text() + NAME_FIELD -> name = xcp.text() + SEVERITY_FIELD -> severity = xcp.text() + CONDITION_FIELD -> { + xcp.nextToken() + condition = Script.parse(xcp) + require(condition.lang == Script.DEFAULT_SCRIPT_LANG) { + "Invalid script language. Allowed languages are [${Script.DEFAULT_SCRIPT_LANG}]" + } + xcp.nextToken() + } + QUERY_IDS_FIELD -> { + XContentParserUtils.ensureExpectedToken( + XContentParser.Token.START_ARRAY, + xcp.currentToken(), + xcp + ) + while (xcp.nextToken() != XContentParser.Token.END_ARRAY) { + queryIds.add(xcp.text()) + } + } + ACTIONS_FIELD -> { + XContentParserUtils.ensureExpectedToken( + XContentParser.Token.START_ARRAY, + xcp.currentToken(), + xcp + ) + while (xcp.nextToken() != XContentParser.Token.END_ARRAY) { + actions.add(Action.parse(xcp)) + } + } + } + xcp.nextToken() + } + + return DocumentLevelTrigger( + name = requireNotNull(name) { "Trigger name is null" }, + severity = requireNotNull(severity) { "Trigger severity is null" }, + condition = requireNotNull(condition) { "Trigger condition is null" }, + actions = requireNotNull(actions) { "Trigger actions are null" }, + id = requireNotNull(id) { "Trigger id is null." } + ) + } + + @JvmStatic + @Throws(IOException::class) + fun readFrom(sin: StreamInput): DocumentLevelTrigger { + return DocumentLevelTrigger(sin) + } + } +} diff --git a/src/main/kotlin/org/opensearch/commons/alerting/model/Input.kt b/src/main/kotlin/org/opensearch/commons/alerting/model/Input.kt new file mode 100644 index 00000000..1a193ba1 --- /dev/null +++ b/src/main/kotlin/org/opensearch/commons/alerting/model/Input.kt @@ -0,0 +1,57 @@ +package org.opensearch.commons.alerting.model + +import org.opensearch.common.io.stream.StreamInput +import org.opensearch.common.xcontent.XContentParser +import org.opensearch.common.xcontent.XContentParserUtils +import org.opensearch.commons.alerting.model.ClusterMetricsInput.Companion.URI_FIELD +import org.opensearch.commons.alerting.model.DocLevelMonitorInput.Companion.DOC_LEVEL_INPUT_FIELD +import org.opensearch.commons.alerting.model.SearchInput.Companion.SEARCH_FIELD +import org.opensearch.commons.notifications.model.BaseModel +import java.io.IOException + +interface Input : BaseModel { + + enum class Type(val value: String) { + DOCUMENT_LEVEL_INPUT(DOC_LEVEL_INPUT_FIELD), + CLUSTER_METRICS_INPUT(URI_FIELD), + SEARCH_INPUT(SEARCH_FIELD); + + override fun toString(): String { + return value + } + } + + companion object { + + @Throws(IOException::class) + fun parse(xcp: XContentParser): Input { + XContentParserUtils.ensureExpectedToken(XContentParser.Token.START_OBJECT, xcp.currentToken(), xcp) + XContentParserUtils.ensureExpectedToken(XContentParser.Token.FIELD_NAME, xcp.nextToken(), xcp) + XContentParserUtils.ensureExpectedToken(XContentParser.Token.START_OBJECT, xcp.nextToken(), xcp) + val input = if (xcp.currentName() == Type.SEARCH_INPUT.value) { + SearchInput.parseInner(xcp) + } else if (xcp.currentName() == Type.CLUSTER_METRICS_INPUT.value) { + ClusterMetricsInput.parseInner(xcp) + } else { + DocLevelMonitorInput.parse(xcp) + } + XContentParserUtils.ensureExpectedToken(XContentParser.Token.END_OBJECT, xcp.nextToken(), xcp) + return input + } + + @JvmStatic + @Throws(IOException::class) + fun readFrom(sin: StreamInput): Input { + return when (val type = sin.readEnum(Input.Type::class.java)) { + Type.DOCUMENT_LEVEL_INPUT -> DocLevelMonitorInput(sin) + Type.CLUSTER_METRICS_INPUT -> ClusterMetricsInput(sin) + Type.SEARCH_INPUT -> SearchInput(sin) + // This shouldn't be reachable but ensuring exhaustiveness as Kotlin warns + // enum can be null in Java + else -> throw IllegalStateException("Unexpected input [$type] when reading Trigger") + } + } + } + + fun name(): String +} diff --git a/src/main/kotlin/org/opensearch/commons/alerting/model/Monitor.kt b/src/main/kotlin/org/opensearch/commons/alerting/model/Monitor.kt new file mode 100644 index 00000000..6e7c9c45 --- /dev/null +++ b/src/main/kotlin/org/opensearch/commons/alerting/model/Monitor.kt @@ -0,0 +1,316 @@ +package org.opensearch.commons.alerting.model + +import org.opensearch.common.CheckedFunction +import org.opensearch.common.ParseField +import org.opensearch.common.io.stream.StreamInput +import org.opensearch.common.io.stream.StreamOutput +import org.opensearch.common.xcontent.NamedXContentRegistry +import org.opensearch.common.xcontent.ToXContent +import org.opensearch.common.xcontent.XContentBuilder +import org.opensearch.common.xcontent.XContentParser +import org.opensearch.common.xcontent.XContentParserUtils +import org.opensearch.commons.alerting.util.IndexUtils.Companion.MONITOR_MAX_INPUTS +import org.opensearch.commons.alerting.util.IndexUtils.Companion.MONITOR_MAX_TRIGGERS +import org.opensearch.commons.alerting.util.IndexUtils.Companion.NO_SCHEMA_VERSION +import org.opensearch.commons.alerting.util.IndexUtils.Companion._ID +import org.opensearch.commons.alerting.util.IndexUtils.Companion._VERSION +import org.opensearch.commons.alerting.util.IndexUtils.Companion.supportedClusterMetricsSettings +import org.opensearch.commons.alerting.util.instant +import org.opensearch.commons.alerting.util.isBucketLevelMonitor +import org.opensearch.commons.alerting.util.optionalTimeField +import org.opensearch.commons.alerting.util.optionalUserField +import org.opensearch.commons.authuser.User +import java.io.IOException +import java.time.Instant +import java.util.Locale + +data class Monitor( + override val id: String = NO_ID, + override val version: Long = NO_VERSION, + override val name: String, + override val enabled: Boolean, + override val schedule: Schedule, + override val lastUpdateTime: Instant, + override val enabledTime: Instant?, + // TODO: Check how this behaves during rolling upgrade/multi-version cluster + // Can read/write and parsing break if it's done from an old -> new version of the plugin? + val monitorType: MonitorType, + val user: User?, + val schemaVersion: Int = NO_SCHEMA_VERSION, + val inputs: List, + val triggers: List, + val uiMetadata: Map +) : ScheduledJob { + + override val type = MONITOR_TYPE + + init { + // Ensure that trigger ids are unique within a monitor + val triggerIds = mutableSetOf() + triggers.forEach { trigger -> + require(triggerIds.add(trigger.id)) { "Duplicate trigger id: ${trigger.id}. Trigger ids must be unique." } + // Verify Trigger type based on Monitor type + when (monitorType) { + MonitorType.QUERY_LEVEL_MONITOR -> + require(trigger is QueryLevelTrigger) { "Incompatible trigger [${trigger.id}] for monitor type [$monitorType]" } + MonitorType.BUCKET_LEVEL_MONITOR -> + require(trigger is BucketLevelTrigger) { "Incompatible trigger [${trigger.id}] for monitor type [$monitorType]" } + MonitorType.CLUSTER_METRICS_MONITOR -> + require(trigger is QueryLevelTrigger) { "Incompatible trigger [${trigger.id}] for monitor type [$monitorType]" } + MonitorType.DOC_LEVEL_MONITOR -> + require(trigger is DocumentLevelTrigger) { "Incompatible trigger [${trigger.id}] for monitor type [$monitorType]" } + } + } + if (enabled) { + requireNotNull(enabledTime) + } else { + require(enabledTime == null) + } + require(inputs.size <= MONITOR_MAX_INPUTS) { "Monitors can only have $MONITOR_MAX_INPUTS search input." } + require(triggers.size <= MONITOR_MAX_TRIGGERS) { "Monitors can only support up to $MONITOR_MAX_TRIGGERS triggers." } + if (this.isBucketLevelMonitor()) { + inputs.forEach { input -> + require(input is SearchInput) { "Unsupported input [$input] for Monitor" } + // TODO: Keeping query validation simple for now, only term aggregations have full support for the "group by" on the + // initial release. Should either add tests for other aggregation types or add validation to prevent using them. + require(input.query.aggregations() != null && !input.query.aggregations().aggregatorFactories.isEmpty()) { + "At least one aggregation is required for the input [$input]" + } + } + } + } + + @Throws(IOException::class) + constructor(sin: StreamInput) : this( + id = sin.readString(), + version = sin.readLong(), + name = sin.readString(), + enabled = sin.readBoolean(), + schedule = Schedule.readFrom(sin), + lastUpdateTime = sin.readInstant(), + enabledTime = sin.readOptionalInstant(), + monitorType = sin.readEnum(MonitorType::class.java), + user = if (sin.readBoolean()) { + User(sin) + } else null, + schemaVersion = sin.readInt(), + inputs = sin.readList((Input)::readFrom), + triggers = sin.readList((Trigger)::readFrom), + uiMetadata = suppressWarning(sin.readMap()) + ) + + // This enum classifies different Monitors + // This is different from 'type' which denotes the Scheduled Job type + enum class MonitorType(val value: String) { + QUERY_LEVEL_MONITOR("query_level_monitor"), + BUCKET_LEVEL_MONITOR("bucket_level_monitor"), + CLUSTER_METRICS_MONITOR("cluster_metrics_monitor"), + DOC_LEVEL_MONITOR("doc_level_monitor"); + + override fun toString(): String { + return value + } + } + + /** Returns a representation of the monitor suitable for passing into painless and mustache scripts. */ + fun asTemplateArg(): Map { + return mapOf(_ID to id, _VERSION to version, NAME_FIELD to name, ENABLED_FIELD to enabled) + } + + fun toXContentWithUser(builder: XContentBuilder, params: ToXContent.Params): XContentBuilder { + return createXContentBuilder(builder, params, false) + } + + override fun toXContent(builder: XContentBuilder, params: ToXContent.Params): XContentBuilder { + return createXContentBuilder(builder, params, true) + } + + private fun createXContentBuilder(builder: XContentBuilder, params: ToXContent.Params, secure: Boolean): XContentBuilder { + builder.startObject() + if (params.paramAsBoolean("with_type", false)) builder.startObject(type) + builder.field(TYPE_FIELD, type) + .field(SCHEMA_VERSION_FIELD, schemaVersion) + .field(NAME_FIELD, name) + .field(MONITOR_TYPE_FIELD, monitorType) + + if (!secure) { + builder.optionalUserField(USER_FIELD, user) + } + + builder.field(ENABLED_FIELD, enabled) + .optionalTimeField(ENABLED_TIME_FIELD, enabledTime) + .field(SCHEDULE_FIELD, schedule) + .field(INPUTS_FIELD, inputs.toTypedArray()) + .field(TRIGGERS_FIELD, triggers.toTypedArray()) + .optionalTimeField(LAST_UPDATE_TIME_FIELD, lastUpdateTime) + if (uiMetadata.isNotEmpty()) builder.field(UI_METADATA_FIELD, uiMetadata) + if (params.paramAsBoolean("with_type", false)) builder.endObject() + return builder.endObject() + } + + override fun fromDocument(id: String, version: Long): Monitor = copy(id = id, version = version) + + @Throws(IOException::class) + override fun writeTo(out: StreamOutput) { + out.writeString(id) + out.writeLong(version) + out.writeString(name) + out.writeBoolean(enabled) + if (schedule is CronSchedule) { + out.writeEnum(Schedule.TYPE.CRON) + } else { + out.writeEnum(Schedule.TYPE.INTERVAL) + } + schedule.writeTo(out) + out.writeInstant(lastUpdateTime) + out.writeOptionalInstant(enabledTime) + out.writeEnum(monitorType) + out.writeBoolean(user != null) + user?.writeTo(out) + out.writeInt(schemaVersion) + // Outputting type with each Input so that the generic Input.readFrom() can read it + out.writeVInt(inputs.size) + inputs.forEach { + if (it is SearchInput) out.writeEnum(Input.Type.SEARCH_INPUT) + else out.writeEnum(Input.Type.DOCUMENT_LEVEL_INPUT) + it.writeTo(out) + } + // Outputting type with each Trigger so that the generic Trigger.readFrom() can read it + out.writeVInt(triggers.size) + triggers.forEach { + when (it) { + is BucketLevelTrigger -> out.writeEnum(Trigger.Type.BUCKET_LEVEL_TRIGGER) + is DocumentLevelTrigger -> out.writeEnum(Trigger.Type.DOCUMENT_LEVEL_TRIGGER) + else -> out.writeEnum(Trigger.Type.QUERY_LEVEL_TRIGGER) + } + it.writeTo(out) + } + out.writeMap(uiMetadata) + } + + companion object { + const val MONITOR_TYPE = "monitor" + const val TYPE_FIELD = "type" + const val MONITOR_TYPE_FIELD = "monitor_type" + const val SCHEMA_VERSION_FIELD = "schema_version" + const val NAME_FIELD = "name" + const val USER_FIELD = "user" + const val ENABLED_FIELD = "enabled" + const val SCHEDULE_FIELD = "schedule" + const val TRIGGERS_FIELD = "triggers" + const val NO_ID = "" + const val NO_VERSION = 1L + const val INPUTS_FIELD = "inputs" + const val LAST_UPDATE_TIME_FIELD = "last_update_time" + const val UI_METADATA_FIELD = "ui_metadata" + const val ENABLED_TIME_FIELD = "enabled_time" + + // This is defined here instead of in ScheduledJob to avoid having the ScheduledJob class know about all + // the different subclasses and creating circular dependencies + val XCONTENT_REGISTRY = NamedXContentRegistry.Entry( + ScheduledJob::class.java, + ParseField(MONITOR_TYPE), + CheckedFunction { parse(it) } + ) + + @JvmStatic + @JvmOverloads + @Throws(IOException::class) + fun parse(xcp: XContentParser, id: String = NO_ID, version: Long = NO_VERSION): Monitor { + var name: String? = null + // Default to QUERY_LEVEL_MONITOR to cover Monitors that existed before the addition of MonitorType + var monitorType: String = MonitorType.QUERY_LEVEL_MONITOR.toString() + var user: User? = null + var schedule: Schedule? = null + var lastUpdateTime: Instant? = null + var enabledTime: Instant? = null + var uiMetadata: Map = mapOf() + var enabled = true + var schemaVersion = NO_SCHEMA_VERSION + val triggers: MutableList = mutableListOf() + val inputs: MutableList = mutableListOf() + + XContentParserUtils.ensureExpectedToken(XContentParser.Token.START_OBJECT, xcp.currentToken(), xcp) + while (xcp.nextToken() != XContentParser.Token.END_OBJECT) { + val fieldName = xcp.currentName() + xcp.nextToken() + + when (fieldName) { + SCHEMA_VERSION_FIELD -> schemaVersion = xcp.intValue() + NAME_FIELD -> name = xcp.text() + MONITOR_TYPE_FIELD -> { + monitorType = xcp.text() + val allowedTypes = MonitorType.values().map { it.value } + if (!allowedTypes.contains(monitorType)) { + throw IllegalStateException("Monitor type should be one of $allowedTypes") + } + } + USER_FIELD -> user = if (xcp.currentToken() == XContentParser.Token.VALUE_NULL) null else User.parse(xcp) + ENABLED_FIELD -> enabled = xcp.booleanValue() + SCHEDULE_FIELD -> schedule = Schedule.parse(xcp) + INPUTS_FIELD -> { + XContentParserUtils.ensureExpectedToken( + XContentParser.Token.START_ARRAY, + xcp.currentToken(), + xcp + ) + while (xcp.nextToken() != XContentParser.Token.END_ARRAY) { + val input = Input.parse(xcp) + if (input is ClusterMetricsInput) + supportedClusterMetricsSettings?.validateApiType(input) + inputs.add(input) + } + } + TRIGGERS_FIELD -> { + XContentParserUtils.ensureExpectedToken( + XContentParser.Token.START_ARRAY, + xcp.currentToken(), + xcp + ) + while (xcp.nextToken() != XContentParser.Token.END_ARRAY) { + triggers.add(Trigger.parse(xcp)) + } + } + ENABLED_TIME_FIELD -> enabledTime = xcp.instant() + LAST_UPDATE_TIME_FIELD -> lastUpdateTime = xcp.instant() + UI_METADATA_FIELD -> uiMetadata = xcp.map() + else -> { + xcp.skipChildren() + } + } + } + + if (enabled && enabledTime == null) { + enabledTime = Instant.now() + } else if (!enabled) { + enabledTime = null + } + return Monitor( + id, + version, + requireNotNull(name) { "Monitor name is null" }, + enabled, + requireNotNull(schedule) { "Monitor schedule is null" }, + lastUpdateTime ?: Instant.now(), + enabledTime, + MonitorType.valueOf(monitorType.uppercase(Locale.ROOT)), + user, + schemaVersion, + inputs.toList(), + triggers.toList(), + uiMetadata + ) + } + + @JvmStatic + @Throws(IOException::class) + fun readFrom(sin: StreamInput): Monitor? { + return Monitor(sin) + } + + @Suppress("UNCHECKED_CAST") + fun suppressWarning(map: MutableMap?): MutableMap { + return map as MutableMap + } + } +} diff --git a/src/main/kotlin/org/opensearch/commons/alerting/model/QueryLevelTrigger.kt b/src/main/kotlin/org/opensearch/commons/alerting/model/QueryLevelTrigger.kt new file mode 100644 index 00000000..98011ff5 --- /dev/null +++ b/src/main/kotlin/org/opensearch/commons/alerting/model/QueryLevelTrigger.kt @@ -0,0 +1,177 @@ +package org.opensearch.commons.alerting.model + +import org.opensearch.common.CheckedFunction +import org.opensearch.common.ParseField +import org.opensearch.common.UUIDs +import org.opensearch.common.io.stream.StreamInput +import org.opensearch.common.io.stream.StreamOutput +import org.opensearch.common.xcontent.NamedXContentRegistry +import org.opensearch.common.xcontent.ToXContent +import org.opensearch.common.xcontent.XContentBuilder +import org.opensearch.common.xcontent.XContentParser +import org.opensearch.common.xcontent.XContentParserUtils +import org.opensearch.commons.alerting.model.Trigger.Companion.ACTIONS_FIELD +import org.opensearch.commons.alerting.model.Trigger.Companion.ID_FIELD +import org.opensearch.commons.alerting.model.Trigger.Companion.NAME_FIELD +import org.opensearch.commons.alerting.model.Trigger.Companion.SEVERITY_FIELD +import org.opensearch.commons.alerting.model.action.Action +import org.opensearch.script.Script +import java.io.IOException + +data class QueryLevelTrigger( + override val id: String = UUIDs.base64UUID(), + override val name: String, + override val severity: String, + override val actions: List, + val condition: Script +) : Trigger { + + @Throws(IOException::class) + constructor(sin: StreamInput) : this( + sin.readString(), // id + sin.readString(), // name + sin.readString(), // severity + sin.readList(::Action), // actions + Script(sin) // condition + ) + + override fun toXContent(builder: XContentBuilder, params: ToXContent.Params): XContentBuilder { + builder.startObject() + .startObject(QUERY_LEVEL_TRIGGER_FIELD) + .field(ID_FIELD, id) + .field(NAME_FIELD, name) + .field(SEVERITY_FIELD, severity) + .startObject(CONDITION_FIELD) + .field(SCRIPT_FIELD, condition) + .endObject() + .field(ACTIONS_FIELD, actions.toTypedArray()) + .endObject() + .endObject() + return builder + } + + override fun name(): String { + return QUERY_LEVEL_TRIGGER_FIELD + } + + /** Returns a representation of the trigger suitable for passing into painless and mustache scripts. */ + fun asTemplateArg(): Map { + return mapOf( + ID_FIELD to id, NAME_FIELD to name, SEVERITY_FIELD to severity, + ACTIONS_FIELD to actions.map { it.asTemplateArg() } + ) + } + + @Throws(IOException::class) + override fun writeTo(out: StreamOutput) { + out.writeString(id) + out.writeString(name) + out.writeString(severity) + out.writeCollection(actions) + condition.writeTo(out) + } + + companion object { + const val QUERY_LEVEL_TRIGGER_FIELD = "query_level_trigger" + const val CONDITION_FIELD = "condition" + const val SCRIPT_FIELD = "script" + + val XCONTENT_REGISTRY = NamedXContentRegistry.Entry( + Trigger::class.java, ParseField(QUERY_LEVEL_TRIGGER_FIELD), + CheckedFunction { parseInner(it) } + ) + + /** + * This parse method needs to account for both the old and new Trigger format. + * In the old format, only one Trigger existed (which is now QueryLevelTrigger) and it was + * not a named object. + * + * The parse() method in the Trigger interface needs to consume the outer START_OBJECT to be able + * to infer whether it is dealing with the old or new Trigger format. This means that the currentToken at + * the time this parseInner method is called could differ based on which format is being dealt with. + * + * Old Format + * ---------- + * { + * "id": ..., + * ^ + * Current token starts here + * "name" ..., + * ... + * } + * + * New Format + * ---------- + * { + * "query_level_trigger": { + * "id": ..., ^ Current token starts here + * "name": ..., + * ... + * } + * } + * + * It isn't typically conventional but this parse method will account for both START_OBJECT + * and FIELD_NAME as the starting token to cover both cases. + */ + @JvmStatic @Throws(IOException::class) + fun parseInner(xcp: XContentParser): QueryLevelTrigger { + var id = UUIDs.base64UUID() // assign a default triggerId if one is not specified + lateinit var name: String + lateinit var severity: String + lateinit var condition: Script + val actions: MutableList = mutableListOf() + + if (xcp.currentToken() != XContentParser.Token.START_OBJECT && xcp.currentToken() != XContentParser.Token.FIELD_NAME) { + XContentParserUtils.throwUnknownToken(xcp.currentToken(), xcp.tokenLocation) + } + + // If the parser began on START_OBJECT, move to the next token so that the while loop enters on + // the fieldName (or END_OBJECT if it's empty). + if (xcp.currentToken() == XContentParser.Token.START_OBJECT) xcp.nextToken() + + while (xcp.currentToken() != XContentParser.Token.END_OBJECT) { + val fieldName = xcp.currentName() + + xcp.nextToken() + when (fieldName) { + ID_FIELD -> id = xcp.text() + NAME_FIELD -> name = xcp.text() + SEVERITY_FIELD -> severity = xcp.text() + CONDITION_FIELD -> { + xcp.nextToken() + condition = Script.parse(xcp) + require(condition.lang == Script.DEFAULT_SCRIPT_LANG) { + "Invalid script language. Allowed languages are [${Script.DEFAULT_SCRIPT_LANG}]" + } + xcp.nextToken() + } + ACTIONS_FIELD -> { + XContentParserUtils.ensureExpectedToken( + XContentParser.Token.START_ARRAY, + xcp.currentToken(), + xcp + ) + while (xcp.nextToken() != XContentParser.Token.END_ARRAY) { + actions.add(Action.parse(xcp)) + } + } + } + xcp.nextToken() + } + + return QueryLevelTrigger( + name = requireNotNull(name) { "Trigger name is null" }, + severity = requireNotNull(severity) { "Trigger severity is null" }, + condition = requireNotNull(condition) { "Trigger condition is null" }, + actions = requireNotNull(actions) { "Trigger actions are null" }, + id = requireNotNull(id) { "Trigger id is null." } + ) + } + + @JvmStatic + @Throws(IOException::class) + fun readFrom(sin: StreamInput): QueryLevelTrigger { + return QueryLevelTrigger(sin) + } + } +} diff --git a/src/main/kotlin/org/opensearch/commons/alerting/model/Schedule.kt b/src/main/kotlin/org/opensearch/commons/alerting/model/Schedule.kt new file mode 100644 index 00000000..68c174ca --- /dev/null +++ b/src/main/kotlin/org/opensearch/commons/alerting/model/Schedule.kt @@ -0,0 +1,354 @@ +package org.opensearch.commons.alerting.model + +import com.cronutils.model.CronType +import com.cronutils.model.definition.CronDefinitionBuilder +import com.cronutils.model.time.ExecutionTime +import com.cronutils.parser.CronParser +import org.opensearch.common.io.stream.StreamInput +import org.opensearch.common.io.stream.StreamOutput +import org.opensearch.common.xcontent.ToXContent +import org.opensearch.common.xcontent.XContentBuilder +import org.opensearch.common.xcontent.XContentParser +import org.opensearch.common.xcontent.XContentParserUtils +import org.opensearch.commons.notifications.model.BaseModel +import java.io.IOException +import java.time.DateTimeException +import java.time.Duration +import java.time.Instant +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.temporal.ChronoUnit +import java.time.zone.ZoneRulesException +import java.util.Locale + +sealed class Schedule : BaseModel { + enum class TYPE { CRON, INTERVAL } + companion object { + const val CRON_FIELD = "cron" + const val EXPRESSION_FIELD = "expression" + const val TIMEZONE_FIELD = "timezone" + const val PERIOD_FIELD = "period" + const val INTERVAL_FIELD = "interval" + const val UNIT_FIELD = "unit" + + val cronParser = CronParser(CronDefinitionBuilder.instanceDefinitionFor(CronType.UNIX)) + + @JvmStatic @Throws(IOException::class) + fun parse(xcp: XContentParser): Schedule { + var expression: String? = null + var timezone: ZoneId? = null + var interval: Int? = null + var unit: ChronoUnit? = null + var schedule: Schedule? = null + var type: TYPE? = null + XContentParserUtils.ensureExpectedToken(XContentParser.Token.START_OBJECT, xcp.currentToken(), xcp) + while (xcp.nextToken() != XContentParser.Token.END_OBJECT) { + val fieldname = xcp.currentName() + xcp.nextToken() + // If the type field has already been set the customer has provide more than one type of schedule. + if (type != null) { + throw IllegalArgumentException("You can only specify one type of schedule.") + } + when (fieldname) { + CRON_FIELD -> { + type = TYPE.CRON + while (xcp.nextToken() != XContentParser.Token.END_OBJECT) { + val cronFieldName = xcp.currentName() + xcp.nextToken() + when (cronFieldName) { + EXPRESSION_FIELD -> expression = xcp.textOrNull() + TIMEZONE_FIELD -> timezone = getTimeZone(xcp.text()) + } + } + } + PERIOD_FIELD -> { + type = TYPE.INTERVAL + while (xcp.nextToken() != XContentParser.Token.END_OBJECT) { + val cronFieldName = xcp.currentName() + xcp.nextToken() + when (cronFieldName) { + INTERVAL_FIELD -> interval = xcp.intValue() + UNIT_FIELD -> unit = ChronoUnit.valueOf(xcp.text().uppercase(Locale.getDefault())) + } + } + } + else -> { + throw IllegalArgumentException("Invalid field: [$fieldname] found in schedule.") + } + } + } + if (type == TYPE.CRON) { + schedule = CronSchedule( + requireNotNull(expression) { "Expression in cron schedule is null." }, + requireNotNull(timezone) { "Timezone in cron schedule is null." } + ) + } else if (type == TYPE.INTERVAL) { + schedule = IntervalSchedule( + requireNotNull(interval) { "Interval in period schedule is null." }, + requireNotNull(unit) { "Unit in period schedule is null." } + ) + } + return requireNotNull(schedule) { "Schedule is null." } + } + + @JvmStatic @Throws(IllegalArgumentException::class) + private fun getTimeZone(timeZone: String): ZoneId { + try { + return ZoneId.of(timeZone) + } catch (zre: ZoneRulesException) { + throw IllegalArgumentException("Timezone $timeZone is not supported") + } catch (dte: DateTimeException) { + throw IllegalArgumentException("Timezone $timeZone is not supported") + } + } + + @JvmStatic + @Throws(IOException::class) + fun readFrom(sin: StreamInput): Schedule { + val type = sin.readEnum(Schedule.TYPE::class.java) + if (type == Schedule.TYPE.CRON) + return CronSchedule(sin) + else + return IntervalSchedule(sin) + } + } + + /** + * @param enabledTime is used in IntervalSchedule to calculate next time to execute the schedule. + */ + abstract fun nextTimeToExecute(enabledTime: Instant): Duration? + + /** + * @param expectedPreviousExecutionTime is the calculated previous execution time that should always be correct, + * the first time this is called the value passed in is the enabledTime which acts as the expectedPreviousExecutionTime + */ + abstract fun getExpectedNextExecutionTime(enabledTime: Instant, expectedPreviousExecutionTime: Instant?): Instant? + + /** + * Returns the start and end time for this schedule starting at the given start time (if provided). + * If not, the start time is assumed to be the last time the Schedule would have executed (if it's a Cron schedule) + * or [Instant.now] if it's an interval schedule. + * + * If this is a schedule that runs only once this function will return [Instant.now] for both start and end time. + */ + abstract fun getPeriodStartingAt(startTime: Instant?): Pair + + /** + * Returns the start and end time for this schedule ending at the given end time (if provided). + * If not, the end time is assumed to be the next time the Schedule would have executed (if it's a Cron schedule) + * or [Instant.now] if it's an interval schedule. + * + * If this is a schedule that runs only once this function will return [Instant.now] for both start and end time. + */ + abstract fun getPeriodEndingAt(endTime: Instant?): Pair + + abstract fun runningOnTime(lastExecutionTime: Instant?): Boolean +} + +/** + * @param testInstant Normally this not be set and it should only be used in unit test to control time. + */ +data class CronSchedule( + val expression: String, + val timezone: ZoneId, + // visible for testing + @Transient val testInstant: Instant? = null +) : Schedule() { + @Transient + val executionTime: ExecutionTime = ExecutionTime.forCron(cronParser.parse(expression)) + + @Throws(IOException::class) + constructor(sin: StreamInput) : this( + sin.readString(), // expression + sin.readZoneId() // timezone + ) + + companion object { + @JvmStatic + @Throws(IOException::class) + fun readFrom(sin: StreamInput): CronSchedule { + return CronSchedule(sin) + } + } + + /* + * @param enabledTime is not used in CronSchedule. + */ + override fun nextTimeToExecute(enabledTime: Instant): Duration? { + val zonedDateTime = ZonedDateTime.ofInstant(testInstant ?: Instant.now(), timezone) + val timeToNextExecution = executionTime.timeToNextExecution(zonedDateTime) + return timeToNextExecution.orElse(null) + } + + override fun getExpectedNextExecutionTime(enabledTime: Instant, expectedPreviousExecutionTime: Instant?): Instant? { + val zonedDateTime = ZonedDateTime.ofInstant(expectedPreviousExecutionTime ?: testInstant ?: Instant.now(), timezone) + val nextExecution = executionTime.nextExecution(zonedDateTime) + return nextExecution.orElse(null)?.toInstant() + } + + override fun getPeriodStartingAt(startTime: Instant?): Pair { + val realStartTime = if (startTime != null) { + startTime + } else { + // Probably the first time we're running. Try to figure out the last execution time + val lastExecutionTime = executionTime.lastExecution(ZonedDateTime.now(timezone)) + // This shouldn't happen unless the cron is configured to run only once, which our current cron syntax doesn't support + if (!lastExecutionTime.isPresent) { + val currentTime = Instant.now() + return Pair(currentTime, currentTime) + } + lastExecutionTime.get().toInstant() + } + val zonedDateTime = ZonedDateTime.ofInstant(realStartTime, timezone) + val newEndTime = executionTime.nextExecution(zonedDateTime).orElse(null) + return Pair(realStartTime, newEndTime?.toInstant() ?: realStartTime) + } + + override fun getPeriodEndingAt(endTime: Instant?): Pair { + val realEndTime = if (endTime != null) { + endTime + } else { + val nextExecutionTime = executionTime.nextExecution(ZonedDateTime.now(timezone)) + // This shouldn't happen unless the cron is configured to run only once which our current cron syntax doesn't support + if (!nextExecutionTime.isPresent) { + val currentTime = Instant.now() + return Pair(currentTime, currentTime) + } + nextExecutionTime.get().toInstant() + } + val zonedDateTime = ZonedDateTime.ofInstant(realEndTime, timezone) + val newStartTime = executionTime.lastExecution(zonedDateTime).orElse(null) + return Pair(newStartTime?.toInstant() ?: realEndTime, realEndTime) + } + + override fun runningOnTime(lastExecutionTime: Instant?): Boolean { + if (lastExecutionTime == null) { + return true + } + + val zonedDateTime = ZonedDateTime.ofInstant(testInstant ?: Instant.now(), timezone) + val expectedExecutionTime = executionTime.lastExecution(zonedDateTime) + + if (!expectedExecutionTime.isPresent) { + // At this point we know lastExecutionTime is not null, this should never happen. + // If expected execution time is null, we shouldn't have executed the ScheduledJob. + return false + } + val actualExecutionTime = ZonedDateTime.ofInstant(lastExecutionTime, timezone) + + return ChronoUnit.SECONDS.between(expectedExecutionTime.get(), actualExecutionTime) == 0L + } + + override fun toXContent(builder: XContentBuilder, params: ToXContent.Params): XContentBuilder { + builder.startObject() + .startObject(CRON_FIELD) + .field(EXPRESSION_FIELD, expression) + .field(TIMEZONE_FIELD, timezone.id) + .endObject() + .endObject() + return builder + } + + @Throws(IOException::class) + override fun writeTo(out: StreamOutput) { + out.writeString(expression) + out.writeZoneId(timezone) + } +} + +data class IntervalSchedule( + val interval: Int, + val unit: ChronoUnit, + // visible for testing + @Transient val testInstant: Instant? = null +) : Schedule() { + @Throws(IOException::class) + constructor(sin: StreamInput) : this( + sin.readInt(), // interval + sin.readEnum(ChronoUnit::class.java) // unit + ) + companion object { + @Transient + private val SUPPORTED_UNIT = listOf(ChronoUnit.MINUTES, ChronoUnit.HOURS, ChronoUnit.DAYS) + + @JvmStatic + @Throws(IOException::class) + fun readFrom(sin: StreamInput): IntervalSchedule { + return IntervalSchedule(sin) + } + } + + init { + if (!SUPPORTED_UNIT.contains(unit)) { + throw IllegalArgumentException("Timezone $unit is not supported expected $SUPPORTED_UNIT") + } + + if (interval <= 0) { + throw IllegalArgumentException("Interval is not allowed to be 0 or negative") + } + } + + @Transient + private val intervalInMills = Duration.of(interval.toLong(), unit).toMillis() + + override fun nextTimeToExecute(enabledTime: Instant): Duration? { + val enabledTimeEpochMillis = enabledTime.toEpochMilli() + + val currentTime = testInstant ?: Instant.now() + val delta = currentTime.toEpochMilli() - enabledTimeEpochMillis + // Remainder of the Delta time is how much we have already spent waiting. + // We need to subtract remainder of that time from the interval time to get remaining schedule time to wait. + val remainingScheduleTime = intervalInMills - delta.rem(intervalInMills) + return Duration.of(remainingScheduleTime, ChronoUnit.MILLIS) + } + + override fun getExpectedNextExecutionTime(enabledTime: Instant, expectedPreviousExecutionTime: Instant?): Instant? { + val expectedPreviousExecutionTimeEpochMillis = (expectedPreviousExecutionTime ?: enabledTime).toEpochMilli() + // We still need to calculate the delta even when using expectedPreviousExecutionTime because the initial value passed in + // is the enabledTime (which also happens with cluster/node restart) + val currentTime = testInstant ?: Instant.now() + val delta = currentTime.toEpochMilli() - expectedPreviousExecutionTimeEpochMillis + // Remainder of the Delta time is how much we have already spent waiting. + // We need to subtract remainder of that time from the interval time to get remaining schedule time to wait. + val remainingScheduleTime = intervalInMills - delta.rem(intervalInMills) + return Instant.ofEpochMilli(currentTime.toEpochMilli() + remainingScheduleTime) + } + + override fun getPeriodStartingAt(startTime: Instant?): Pair { + val realStartTime = startTime ?: Instant.now() + val newEndTime = realStartTime.plusMillis(intervalInMills) + return Pair(realStartTime, newEndTime) + } + + override fun getPeriodEndingAt(endTime: Instant?): Pair { + val realEndTime = endTime ?: Instant.now() + val newStartTime = realEndTime.minusMillis(intervalInMills) + return Pair(newStartTime, realEndTime) + } + + override fun runningOnTime(lastExecutionTime: Instant?): Boolean { + if (lastExecutionTime == null) { + return true + } + + // Make sure the lastExecutionTime is less than interval time. + val delta = ChronoUnit.MILLIS.between(lastExecutionTime, testInstant ?: Instant.now()) + return 0 < delta && delta < intervalInMills + } + + override fun toXContent(builder: XContentBuilder, params: ToXContent.Params): XContentBuilder { + builder.startObject() + .startObject(PERIOD_FIELD) + .field(INTERVAL_FIELD, interval) + .field(UNIT_FIELD, unit.name) + .endObject() + .endObject() + return builder + } + + @Throws(IOException::class) + override fun writeTo(out: StreamOutput) { + out.writeInt(interval) + out.writeEnum(unit) + } +} diff --git a/src/main/kotlin/org/opensearch/commons/alerting/model/ScheduledJob.kt b/src/main/kotlin/org/opensearch/commons/alerting/model/ScheduledJob.kt new file mode 100644 index 00000000..ac1037f0 --- /dev/null +++ b/src/main/kotlin/org/opensearch/commons/alerting/model/ScheduledJob.kt @@ -0,0 +1,85 @@ +package org.opensearch.commons.alerting.model + +import org.opensearch.common.xcontent.ToXContent +import org.opensearch.common.xcontent.XContentBuilder +import org.opensearch.common.xcontent.XContentParser +import org.opensearch.common.xcontent.XContentParserUtils +import org.opensearch.commons.notifications.model.BaseModel +import java.io.IOException +import java.time.Instant + +interface ScheduledJob : BaseModel { + + fun toXContentWithType(builder: XContentBuilder): XContentBuilder = toXContent(builder, XCONTENT_WITH_TYPE) + + companion object { + /** The name of the ElasticSearch index in which we store jobs */ + const val SCHEDULED_JOBS_INDEX = ".opendistro-alerting-config" + const val DOC_LEVEL_QUERIES_INDEX = ".opensearch-alerting-queries" + + const val NO_ID = "" + + const val NO_VERSION = 1L + + private val XCONTENT_WITH_TYPE = ToXContent.MapParams(mapOf("with_type" to "true")) + + /** + * This function parses the job, delegating to the specific subtype parser registered in the [XContentParser.getXContentRegistry] + * at runtime. Each concrete job subclass is expected to register a parser in this registry. + * The Job's json representation is expected to be of the form: + * { "" : { } } + * + * If the job comes from an OpenSearch index it's [id] and [version] can also be supplied. + */ + @Throws(IOException::class) + fun parse(xcp: XContentParser, id: String = NO_ID, version: Long = NO_VERSION): ScheduledJob { + XContentParserUtils.ensureExpectedToken(XContentParser.Token.START_OBJECT, xcp.nextToken(), xcp) + XContentParserUtils.ensureExpectedToken(XContentParser.Token.FIELD_NAME, xcp.nextToken(), xcp) + XContentParserUtils.ensureExpectedToken(XContentParser.Token.START_OBJECT, xcp.nextToken(), xcp) + val job = xcp.namedObject(ScheduledJob::class.java, xcp.currentName(), null) + XContentParserUtils.ensureExpectedToken(XContentParser.Token.END_OBJECT, xcp.nextToken(), xcp) + return job.fromDocument(id, version) + } + + /** + * This function parses the job, but expects the type to be passed in. This is for the specific + * use case in sweeper where we first want to check if the job is allowed to be swept before + * trying to fully parse it. If you need to parse a job, you most likely want to use + * the above parse function. + */ + @Throws(IOException::class) + fun parse(xcp: XContentParser, type: String, id: String = NO_ID, version: Long = NO_VERSION): ScheduledJob { + XContentParserUtils.ensureExpectedToken(XContentParser.Token.START_OBJECT, xcp.nextToken(), xcp) + val job = xcp.namedObject(ScheduledJob::class.java, type, null) + XContentParserUtils.ensureExpectedToken(XContentParser.Token.END_OBJECT, xcp.nextToken(), xcp) + return job.fromDocument(id, version) + } + } + + /** The id of the job in the [SCHEDULED_JOBS_INDEX] or [NO_ID] if not persisted */ + val id: String + + /** The version of the job in the [SCHEDULED_JOBS_INDEX] or [NO_VERSION] if not persisted */ + val version: Long + + /** The name of the job */ + val name: String + + /** The type of the job */ + val type: String + + /** Controls whether the job will be scheduled or not */ + val enabled: Boolean + + /** The schedule for running the job */ + val schedule: Schedule + + /** The last time the job was updated */ + val lastUpdateTime: Instant + + /** The time the job was enabled */ + val enabledTime: Instant? + + /** Copy constructor for persisted jobs */ + fun fromDocument(id: String, version: Long): ScheduledJob +} diff --git a/src/main/kotlin/org/opensearch/commons/alerting/model/SearchInput.kt b/src/main/kotlin/org/opensearch/commons/alerting/model/SearchInput.kt new file mode 100644 index 00000000..d12e935b --- /dev/null +++ b/src/main/kotlin/org/opensearch/commons/alerting/model/SearchInput.kt @@ -0,0 +1,87 @@ +package org.opensearch.commons.alerting.model + +import org.opensearch.common.CheckedFunction +import org.opensearch.common.ParseField +import org.opensearch.common.io.stream.StreamInput +import org.opensearch.common.io.stream.StreamOutput +import org.opensearch.common.xcontent.NamedXContentRegistry +import org.opensearch.common.xcontent.ToXContent +import org.opensearch.common.xcontent.XContentBuilder +import org.opensearch.common.xcontent.XContentParser +import org.opensearch.common.xcontent.XContentParserUtils +import org.opensearch.search.builder.SearchSourceBuilder +import java.io.IOException + +data class SearchInput(val indices: List, val query: SearchSourceBuilder) : Input { + + @Throws(IOException::class) + constructor(sin: StreamInput) : this( + sin.readStringList(), // indices + SearchSourceBuilder(sin) // query + ) + + override fun toXContent(builder: XContentBuilder, params: ToXContent.Params): XContentBuilder { + return builder.startObject() + .startObject(SEARCH_FIELD) + .field(INDICES_FIELD, indices.toTypedArray()) + .field(QUERY_FIELD, query) + .endObject() + .endObject() + } + + override fun name(): String { + return SEARCH_FIELD + } + + @Throws(IOException::class) + override fun writeTo(out: StreamOutput) { + out.writeStringCollection(indices) + query.writeTo(out) + } + + companion object { + const val INDICES_FIELD = "indices" + const val QUERY_FIELD = "query" + const val SEARCH_FIELD = "search" + + val XCONTENT_REGISTRY = NamedXContentRegistry.Entry(Input::class.java, ParseField("search"), CheckedFunction { parseInner(it) }) + + @JvmStatic @Throws(IOException::class) + fun parseInner(xcp: XContentParser): SearchInput { + val indices = mutableListOf() + lateinit var searchSourceBuilder: SearchSourceBuilder + + XContentParserUtils.ensureExpectedToken(XContentParser.Token.START_OBJECT, xcp.currentToken(), xcp) + while (xcp.nextToken() != XContentParser.Token.END_OBJECT) { + val fieldName = xcp.currentName() + xcp.nextToken() + when (fieldName) { + INDICES_FIELD -> { + XContentParserUtils.ensureExpectedToken( + XContentParser.Token.START_ARRAY, + xcp.currentToken(), + xcp + ) + while (xcp.nextToken() != XContentParser.Token.END_ARRAY) { + indices.add(xcp.text()) + } + } + QUERY_FIELD -> { + searchSourceBuilder = SearchSourceBuilder.fromXContent(xcp, false) + } + } + } + + return SearchInput( + indices, + requireNotNull(searchSourceBuilder) { "SearchInput query is null" } + ) + } + + @JvmStatic + @Throws(IOException::class) + fun readFrom(sin: StreamInput): SearchInput { + return SearchInput(sin) + } + } +} diff --git a/src/main/kotlin/org/opensearch/commons/alerting/model/Trigger.kt b/src/main/kotlin/org/opensearch/commons/alerting/model/Trigger.kt new file mode 100644 index 00000000..dfc2797d --- /dev/null +++ b/src/main/kotlin/org/opensearch/commons/alerting/model/Trigger.kt @@ -0,0 +1,75 @@ +package org.opensearch.commons.alerting.model + +import org.opensearch.common.io.stream.StreamInput +import org.opensearch.common.xcontent.XContentParser +import org.opensearch.common.xcontent.XContentParserUtils +import org.opensearch.commons.alerting.model.action.Action +import org.opensearch.commons.notifications.model.BaseModel +import java.io.IOException + +interface Trigger : BaseModel { + + enum class Type(val value: String) { + DOCUMENT_LEVEL_TRIGGER(DocumentLevelTrigger.DOCUMENT_LEVEL_TRIGGER_FIELD), + QUERY_LEVEL_TRIGGER(QueryLevelTrigger.QUERY_LEVEL_TRIGGER_FIELD), + BUCKET_LEVEL_TRIGGER(BucketLevelTrigger.BUCKET_LEVEL_TRIGGER_FIELD); + + override fun toString(): String { + return value + } + } + + companion object { + const val ID_FIELD = "id" + const val NAME_FIELD = "name" + const val SEVERITY_FIELD = "severity" + const val ACTIONS_FIELD = "actions" + + @Throws(IOException::class) + fun parse(xcp: XContentParser): Trigger { + val trigger: Trigger + + XContentParserUtils.ensureExpectedToken(XContentParser.Token.START_OBJECT, xcp.currentToken(), xcp) + XContentParserUtils.ensureExpectedToken(XContentParser.Token.FIELD_NAME, xcp.nextToken(), xcp) + val triggerTypeNames = Type.values().map { it.toString() } + if (triggerTypeNames.contains(xcp.currentName())) { + XContentParserUtils.ensureExpectedToken(XContentParser.Token.START_OBJECT, xcp.nextToken(), xcp) + trigger = xcp.namedObject(Trigger::class.java, xcp.currentName(), null) + XContentParserUtils.ensureExpectedToken(XContentParser.Token.END_OBJECT, xcp.nextToken(), xcp) + } else { + // Infer the old Trigger (now called QueryLevelTrigger) when it is not defined as a named + // object to remain backwards compatible when parsing the old format + trigger = QueryLevelTrigger.parseInner(xcp) + XContentParserUtils.ensureExpectedToken(XContentParser.Token.END_OBJECT, xcp.currentToken(), xcp) + } + return trigger + } + + @JvmStatic + @Throws(IOException::class) + fun readFrom(sin: StreamInput): Trigger { + return when (val type = sin.readEnum(Trigger.Type::class.java)) { + Type.QUERY_LEVEL_TRIGGER -> QueryLevelTrigger(sin) + Type.BUCKET_LEVEL_TRIGGER -> BucketLevelTrigger(sin) + Type.DOCUMENT_LEVEL_TRIGGER -> DocumentLevelTrigger(sin) + // This shouldn't be reachable but ensuring exhaustiveness as Kotlin warns + // enum can be null in Java + else -> throw IllegalStateException("Unexpected input [$type] when reading Trigger") + } + } + } + + /** The id of the Trigger in the [SCHEDULED_JOBS_INDEX] */ + val id: String + + /** The name of the Trigger */ + val name: String + + /** The severity of the Trigger, used to classify the subsequent Alert */ + val severity: String + + /** The actions executed if the Trigger condition evaluates to true */ + val actions: List + + fun name(): String +} diff --git a/src/main/kotlin/org/opensearch/commons/alerting/model/action/Action.kt b/src/main/kotlin/org/opensearch/commons/alerting/model/action/Action.kt new file mode 100644 index 00000000..e8b9f87e --- /dev/null +++ b/src/main/kotlin/org/opensearch/commons/alerting/model/action/Action.kt @@ -0,0 +1,178 @@ +package org.opensearch.commons.alerting.model.action + +import org.opensearch.common.UUIDs +import org.opensearch.common.io.stream.StreamInput +import org.opensearch.common.io.stream.StreamOutput +import org.opensearch.common.xcontent.ToXContent +import org.opensearch.common.xcontent.XContentBuilder +import org.opensearch.common.xcontent.XContentParser +import org.opensearch.common.xcontent.XContentParserUtils +import org.opensearch.commons.notifications.model.BaseModel +import org.opensearch.script.Script +import java.io.IOException + +data class Action( + val name: String, + val destinationId: String, + val subjectTemplate: Script?, + val messageTemplate: Script, + val throttleEnabled: Boolean, + val throttle: Throttle?, + val id: String = UUIDs.base64UUID(), + val actionExecutionPolicy: ActionExecutionPolicy? = null +) : BaseModel { + + init { + if (subjectTemplate != null) { + require(subjectTemplate.lang == MUSTACHE) { "subject_template must be a mustache script" } + } + require(messageTemplate.lang == MUSTACHE) { "message_template must be a mustache script" } + + if (actionExecutionPolicy?.actionExecutionScope is PerExecutionActionScope) { + require(throttle == null) { "Throttle is currently not supported for per execution action scope" } + } + } + + @Throws(IOException::class) + constructor(sin: StreamInput) : this( + sin.readString(), // name + sin.readString(), // destinationId + sin.readOptionalWriteable(::Script), // subjectTemplate + Script(sin), // messageTemplate + sin.readBoolean(), // throttleEnabled + sin.readOptionalWriteable(::Throttle), // throttle + sin.readString(), // id + sin.readOptionalWriteable(::ActionExecutionPolicy) // actionExecutionPolicy + ) + + override fun toXContent(builder: XContentBuilder, params: ToXContent.Params): XContentBuilder { + val xContentBuilder = builder.startObject() + .field(ID_FIELD, id) + .field(NAME_FIELD, name) + .field(DESTINATION_ID_FIELD, destinationId) + .field(MESSAGE_TEMPLATE_FIELD, messageTemplate) + .field(THROTTLE_ENABLED_FIELD, throttleEnabled) + if (subjectTemplate != null) { + xContentBuilder.field(SUBJECT_TEMPLATE_FIELD, subjectTemplate) + } + if (throttle != null) { + xContentBuilder.field(THROTTLE_FIELD, throttle) + } + if (actionExecutionPolicy != null) { + xContentBuilder.field(ACTION_EXECUTION_POLICY_FIELD, actionExecutionPolicy) + } + return xContentBuilder.endObject() + } + + fun asTemplateArg(): Map { + return mapOf(NAME_FIELD to name) + } + + @Throws(IOException::class) + override fun writeTo(out: StreamOutput) { + out.writeString(name) + out.writeString(destinationId) + if (subjectTemplate != null) { + out.writeBoolean(true) + subjectTemplate.writeTo(out) + } else { + out.writeBoolean(false) + } + messageTemplate.writeTo(out) + out.writeBoolean(throttleEnabled) + if (throttle != null) { + out.writeBoolean(true) + throttle.writeTo(out) + } else { + out.writeBoolean(false) + } + out.writeString(id) + if (actionExecutionPolicy != null) { + out.writeBoolean(true) + actionExecutionPolicy.writeTo(out) + } else { + out.writeBoolean(false) + } + } + + companion object { + const val ID_FIELD = "id" + const val NAME_FIELD = "name" + const val DESTINATION_ID_FIELD = "destination_id" + const val SUBJECT_TEMPLATE_FIELD = "subject_template" + const val MESSAGE_TEMPLATE_FIELD = "message_template" + const val THROTTLE_ENABLED_FIELD = "throttle_enabled" + const val THROTTLE_FIELD = "throttle" + const val ACTION_EXECUTION_POLICY_FIELD = "action_execution_policy" + const val MUSTACHE = "mustache" + const val SUBJECT = "subject" + const val MESSAGE = "message" + const val MESSAGE_ID = "messageId" + + @JvmStatic + @Throws(IOException::class) + fun parse(xcp: XContentParser): Action { + var id = UUIDs.base64UUID() // assign a default action id if one is not specified + lateinit var name: String + lateinit var destinationId: String + var subjectTemplate: Script? = null // subject template could be null for some destinations + lateinit var messageTemplate: Script + var throttleEnabled = false + var throttle: Throttle? = null + var actionExecutionPolicy: ActionExecutionPolicy? = null + + XContentParserUtils.ensureExpectedToken(XContentParser.Token.START_OBJECT, xcp.currentToken(), xcp) + while (xcp.nextToken() != XContentParser.Token.END_OBJECT) { + val fieldName = xcp.currentName() + xcp.nextToken() + when (fieldName) { + ID_FIELD -> id = xcp.text() + NAME_FIELD -> name = xcp.textOrNull() + DESTINATION_ID_FIELD -> destinationId = xcp.textOrNull() + SUBJECT_TEMPLATE_FIELD -> { + subjectTemplate = if (xcp.currentToken() == XContentParser.Token.VALUE_NULL) null else + Script.parse(xcp, Script.DEFAULT_TEMPLATE_LANG) + } + MESSAGE_TEMPLATE_FIELD -> messageTemplate = Script.parse(xcp, Script.DEFAULT_TEMPLATE_LANG) + THROTTLE_FIELD -> { + throttle = if (xcp.currentToken() == XContentParser.Token.VALUE_NULL) null else Throttle.parse(xcp) + } + THROTTLE_ENABLED_FIELD -> { + throttleEnabled = xcp.booleanValue() + } + ACTION_EXECUTION_POLICY_FIELD -> { + actionExecutionPolicy = if (xcp.currentToken() == XContentParser.Token.VALUE_NULL) { + null + } else { + ActionExecutionPolicy.parse(xcp) + } + } + else -> { + throw IllegalStateException("Unexpected field: $fieldName, while parsing action") + } + } + } + + if (throttleEnabled) { + requireNotNull(throttle, { "Action throttle enabled but not set throttle value" }) + } + + return Action( + requireNotNull(name) { "Action name is null" }, + requireNotNull(destinationId) { "Destination id is null" }, + subjectTemplate, + requireNotNull(messageTemplate) { "Action message template is null" }, + throttleEnabled, + throttle, + id = requireNotNull(id), + actionExecutionPolicy = actionExecutionPolicy + ) + } + + @JvmStatic + @Throws(IOException::class) + fun readFrom(sin: StreamInput): Action { + return Action(sin) + } + } +} diff --git a/src/main/kotlin/org/opensearch/commons/alerting/model/action/ActionExecutionPolicy.kt b/src/main/kotlin/org/opensearch/commons/alerting/model/action/ActionExecutionPolicy.kt new file mode 100644 index 00000000..044f3a24 --- /dev/null +++ b/src/main/kotlin/org/opensearch/commons/alerting/model/action/ActionExecutionPolicy.kt @@ -0,0 +1,92 @@ +package org.opensearch.commons.alerting.model.action + +import org.opensearch.common.io.stream.StreamInput +import org.opensearch.common.io.stream.StreamOutput +import org.opensearch.common.xcontent.ToXContent +import org.opensearch.common.xcontent.XContentBuilder +import org.opensearch.common.xcontent.XContentParser +import org.opensearch.common.xcontent.XContentParserUtils +import org.opensearch.commons.notifications.model.BaseModel +import java.io.IOException + +data class ActionExecutionPolicy( + val actionExecutionScope: ActionExecutionScope +) : BaseModel { + + @Throws(IOException::class) + constructor(sin: StreamInput) : this ( + ActionExecutionScope.readFrom(sin) // actionExecutionScope + ) + + override fun toXContent(builder: XContentBuilder, params: ToXContent.Params): XContentBuilder { + builder.startObject() + .field(ACTION_EXECUTION_SCOPE, actionExecutionScope) + return builder.endObject() + } + + @Throws(IOException::class) + override fun writeTo(out: StreamOutput) { + if (actionExecutionScope is PerAlertActionScope) { + out.writeEnum(ActionExecutionScope.Type.PER_ALERT) + } else { + out.writeEnum(ActionExecutionScope.Type.PER_EXECUTION) + } + actionExecutionScope.writeTo(out) + } + + companion object { + const val ACTION_EXECUTION_SCOPE = "action_execution_scope" + + @JvmStatic + @Throws(IOException::class) + fun parse(xcp: XContentParser): ActionExecutionPolicy { + lateinit var actionExecutionScope: ActionExecutionScope + + XContentParserUtils.ensureExpectedToken(XContentParser.Token.START_OBJECT, xcp.currentToken(), xcp) + while (xcp.nextToken() != XContentParser.Token.END_OBJECT) { + val fieldName = xcp.currentName() + xcp.nextToken() + + when (fieldName) { + ACTION_EXECUTION_SCOPE -> actionExecutionScope = ActionExecutionScope.parse(xcp) + } + } + + return ActionExecutionPolicy( + requireNotNull(actionExecutionScope) { "Action execution scope is null" } + ) + } + + @JvmStatic + @Throws(IOException::class) + fun readFrom(sin: StreamInput): ActionExecutionPolicy { + return ActionExecutionPolicy(sin) + } + + /** + * The default [ActionExecutionPolicy] configuration for Bucket-Level Monitors. + * + * If Query-Level Monitors integrate the use of [ActionExecutionPolicy] then a separate default configuration + * will need to be made depending on the desired behavior. + */ + fun getDefaultConfigurationForBucketLevelMonitor(): ActionExecutionPolicy { + val defaultActionExecutionScope = PerAlertActionScope( + actionableAlerts = setOf(AlertCategory.DEDUPED, AlertCategory.NEW) + ) + return ActionExecutionPolicy(actionExecutionScope = defaultActionExecutionScope) + } + + /** + * The default [ActionExecutionPolicy] configuration for Document-Level Monitors. + * + * If Query-Level Monitors integrate the use of [ActionExecutionPolicy] then a separate default configuration + * will need to be made depending on the desired behavior. + */ + fun getDefaultConfigurationForDocumentLevelMonitor(): ActionExecutionPolicy { + val defaultActionExecutionScope = PerAlertActionScope( + actionableAlerts = setOf(AlertCategory.DEDUPED, AlertCategory.NEW) + ) + return ActionExecutionPolicy(actionExecutionScope = defaultActionExecutionScope) + } + } +} diff --git a/src/main/kotlin/org/opensearch/commons/alerting/model/action/ActionExecutionScope.kt b/src/main/kotlin/org/opensearch/commons/alerting/model/action/ActionExecutionScope.kt new file mode 100644 index 00000000..2e252475 --- /dev/null +++ b/src/main/kotlin/org/opensearch/commons/alerting/model/action/ActionExecutionScope.kt @@ -0,0 +1,174 @@ +package org.opensearch.commons.alerting.model.action + +import org.opensearch.common.io.stream.StreamInput +import org.opensearch.common.io.stream.StreamOutput +import org.opensearch.common.xcontent.ToXContent +import org.opensearch.common.xcontent.XContentBuilder +import org.opensearch.common.xcontent.XContentParser +import org.opensearch.common.xcontent.XContentParserUtils +import org.opensearch.commons.notifications.model.BaseModel +import java.io.IOException +import java.lang.IllegalArgumentException + +sealed class ActionExecutionScope : BaseModel { + + enum class Type { PER_ALERT, PER_EXECUTION } + + companion object { + const val PER_ALERT_FIELD = "per_alert" + const val PER_EXECUTION_FIELD = "per_execution" + const val ACTIONABLE_ALERTS_FIELD = "actionable_alerts" + + @JvmStatic + @Throws(IOException::class) + fun parse(xcp: XContentParser): ActionExecutionScope { + var type: Type? = null + var actionExecutionScope: ActionExecutionScope? = null + val alertFilter = mutableSetOf() + + XContentParserUtils.ensureExpectedToken(XContentParser.Token.START_OBJECT, xcp.currentToken(), xcp) + while (xcp.nextToken() != XContentParser.Token.END_OBJECT) { + val fieldName = xcp.currentName() + xcp.nextToken() + + // If the type field has already been set, the user has provided more than one type of schedule + if (type != null) { + throw IllegalArgumentException("You can only specify one type of action execution scope.") + } + + when (fieldName) { + PER_ALERT_FIELD -> { + type = Type.PER_ALERT + while (xcp.nextToken() != XContentParser.Token.END_OBJECT) { + val perAlertFieldName = xcp.currentName() + xcp.nextToken() + when (perAlertFieldName) { + ACTIONABLE_ALERTS_FIELD -> { + XContentParserUtils.ensureExpectedToken( + XContentParser.Token.START_ARRAY, + xcp.currentToken(), + xcp + ) + val allowedCategories = AlertCategory.values().map { it.toString() } + while (xcp.nextToken() != XContentParser.Token.END_ARRAY) { + val alertCategory = xcp.text() + if (!allowedCategories.contains(alertCategory)) { + throw IllegalStateException("Actionable alerts should be one of $allowedCategories") + } + alertFilter.add(AlertCategory.valueOf(alertCategory)) + } + } + else -> throw IllegalArgumentException( + "Invalid field [$perAlertFieldName] found in per alert action execution scope." + ) + } + } + } + PER_EXECUTION_FIELD -> { + type = Type.PER_EXECUTION + while (xcp.nextToken() != XContentParser.Token.END_OBJECT) { + // empty while block + } + } + else -> throw IllegalArgumentException("Invalid field [$fieldName] found in action execution scope.") + } + } + + if (type == Type.PER_ALERT) { + actionExecutionScope = PerAlertActionScope(alertFilter) + } else if (type == Type.PER_EXECUTION) { + actionExecutionScope = PerExecutionActionScope() + } + + return requireNotNull(actionExecutionScope) { "Action execution scope is null." } + } + + @JvmStatic + @Throws(IOException::class) + fun readFrom(sin: StreamInput): ActionExecutionScope { + val type = sin.readEnum(ActionExecutionScope.Type::class.java) + return if (type == Type.PER_ALERT) { + PerAlertActionScope(sin) + } else { + PerExecutionActionScope(sin) + } + } + } + + abstract fun getExecutionScope(): Type +} + +data class PerAlertActionScope( + val actionableAlerts: Set +) : ActionExecutionScope() { + + @Throws(IOException::class) + constructor(sin: StreamInput) : this( + sin.readSet { si -> si.readEnum(AlertCategory::class.java) } // alertFilter + ) + + override fun getExecutionScope(): Type = Type.PER_ALERT + + override fun toXContent(builder: XContentBuilder, params: ToXContent.Params): XContentBuilder { + builder.startObject() + .startObject(PER_ALERT_FIELD) + .field(ACTIONABLE_ALERTS_FIELD, actionableAlerts.toTypedArray()) + .endObject() + return builder.endObject() + } + + @Throws(IOException::class) + override fun writeTo(out: StreamOutput) { + out.writeCollection(actionableAlerts) { o, v -> o.writeEnum(v) } + } + + companion object { + @JvmStatic + @Throws(IOException::class) + fun readFrom(sin: StreamInput): PerAlertActionScope { + return PerAlertActionScope(sin) + } + } +} + +class PerExecutionActionScope() : ActionExecutionScope() { + + @Throws(IOException::class) + constructor(sin: StreamInput) : this() + + override fun hashCode(): Int { + return javaClass.hashCode() + } + + // Creating an equals method that just checks class type rather than reference since this is currently stateless. + // Otherwise, it would have been a dataclass which would have handled this. + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other?.javaClass != javaClass) return false + return true + } + + override fun getExecutionScope(): Type = Type.PER_EXECUTION + + override fun toXContent(builder: XContentBuilder, params: ToXContent.Params): XContentBuilder { + builder.startObject() + .startObject(PER_EXECUTION_FIELD) + .endObject() + return builder.endObject() + } + + @Throws(IOException::class) + override fun writeTo(out: StreamOutput) { + // body empty + } + + companion object { + @JvmStatic + @Throws(IOException::class) + fun readFrom(sin: StreamInput): PerExecutionActionScope { + return PerExecutionActionScope(sin) + } + } +} + +enum class AlertCategory { DEDUPED, NEW, COMPLETED } diff --git a/src/main/kotlin/org/opensearch/commons/alerting/model/action/Throttle.kt b/src/main/kotlin/org/opensearch/commons/alerting/model/action/Throttle.kt new file mode 100644 index 00000000..c7defeee --- /dev/null +++ b/src/main/kotlin/org/opensearch/commons/alerting/model/action/Throttle.kt @@ -0,0 +1,87 @@ +package org.opensearch.commons.alerting.model.action + +import org.apache.commons.codec.binary.StringUtils +import org.opensearch.common.io.stream.StreamInput +import org.opensearch.common.io.stream.StreamOutput +import org.opensearch.common.xcontent.ToXContent +import org.opensearch.common.xcontent.XContentBuilder +import org.opensearch.common.xcontent.XContentParser +import org.opensearch.common.xcontent.XContentParserUtils +import org.opensearch.commons.notifications.model.BaseModel +import java.io.IOException +import java.time.temporal.ChronoUnit +import java.util.Locale + +data class Throttle( + val value: Int, + val unit: ChronoUnit +) : BaseModel { + + @Throws(IOException::class) + constructor(sin: StreamInput) : this ( + sin.readInt(), // value + sin.readEnum(ChronoUnit::class.java) // unit + ) + + override fun toXContent(builder: XContentBuilder, params: ToXContent.Params): XContentBuilder { + return builder.startObject() + .field(VALUE_FIELD, value) + .field(UNIT_FIELD, unit.name) + .endObject() + } + + @Throws(IOException::class) + override fun writeTo(out: StreamOutput) { + out.writeInt(value) + out.writeEnum(unit) + } + + companion object { + const val VALUE_FIELD = "value" + const val UNIT_FIELD = "unit" + + @JvmStatic + @Throws(IOException::class) + fun parse(xcp: XContentParser): Throttle { + var value: Int = 0 + var unit: ChronoUnit = ChronoUnit.MINUTES // only support MINUTES throttle unit currently + + XContentParserUtils.ensureExpectedToken(XContentParser.Token.START_OBJECT, xcp.currentToken(), xcp) + while (xcp.nextToken() != XContentParser.Token.END_OBJECT) { + val fieldName = xcp.currentName() + xcp.nextToken() + when (fieldName) { + UNIT_FIELD -> { + val unitString = xcp.text().uppercase(Locale.ROOT) + require(StringUtils.equals(unitString, ChronoUnit.MINUTES.name), { "Only support MINUTES throttle unit currently" }) + unit = ChronoUnit.valueOf(unitString) + } + VALUE_FIELD -> { + val currentToken = xcp.currentToken() + require(currentToken != XContentParser.Token.VALUE_NULL, { "Throttle value can't be null" }) + when { + currentToken.isValue -> { + value = xcp.intValue() + require(value > 0, { "Can only set positive throttle period" }) + } + else -> { + XContentParserUtils.throwUnknownToken(currentToken, xcp.tokenLocation) + } + } + } + + else -> { + throw IllegalStateException("Unexpected field: $fieldName, while parsing action") + } + } + } + return Throttle(value = value, unit = requireNotNull(unit)) + } + + @JvmStatic + @Throws(IOException::class) + fun readFrom(sin: StreamInput): Throttle { + return Throttle(sin) + } + } +} diff --git a/src/main/kotlin/org/opensearch/commons/alerting/settings/SupportedClusterMetricsSettings.kt b/src/main/kotlin/org/opensearch/commons/alerting/settings/SupportedClusterMetricsSettings.kt new file mode 100644 index 00000000..e414e3ee --- /dev/null +++ b/src/main/kotlin/org/opensearch/commons/alerting/settings/SupportedClusterMetricsSettings.kt @@ -0,0 +1,7 @@ +package org.opensearch.commons.alerting.settings + +import org.opensearch.commons.alerting.model.ClusterMetricsInput + +interface SupportedClusterMetricsSettings { + fun validateApiType(clusterMetricsInput: ClusterMetricsInput) +} diff --git a/src/main/kotlin/org/opensearch/commons/alerting/util/IndexUtils.kt b/src/main/kotlin/org/opensearch/commons/alerting/util/IndexUtils.kt new file mode 100644 index 00000000..eef89a0a --- /dev/null +++ b/src/main/kotlin/org/opensearch/commons/alerting/util/IndexUtils.kt @@ -0,0 +1,61 @@ +package org.opensearch.commons.alerting.util + +import org.opensearch.common.bytes.BytesReference +import org.opensearch.common.xcontent.XContentBuilder +import org.opensearch.common.xcontent.XContentParser +import org.opensearch.common.xcontent.XContentParserUtils +import org.opensearch.commons.alerting.model.Monitor +import org.opensearch.commons.alerting.settings.SupportedClusterMetricsSettings +import org.opensearch.commons.authuser.User +import java.time.Instant + +class IndexUtils { + companion object { + const val NO_SCHEMA_VERSION = 0 + + const val MONITOR_MAX_INPUTS = 1 + + const val MONITOR_MAX_TRIGGERS = 10 + + const val _ID = "_id" + const val _VERSION = "_version" + + const val _SEQ_NO = "_seq_no" + const val _PRIMARY_TERM = "_primary_term" + + var supportedClusterMetricsSettings: SupportedClusterMetricsSettings? = null + } +} + +fun Monitor.isBucketLevelMonitor(): Boolean = this.monitorType == Monitor.MonitorType.BUCKET_LEVEL_MONITOR + +fun XContentBuilder.optionalUserField(name: String, user: User?): XContentBuilder { + if (user == null) { + return nullField(name) + } + return this.field(name, user) +} + +fun XContentBuilder.optionalTimeField(name: String, instant: Instant?): XContentBuilder { + if (instant == null) { + return nullField(name) + } + // second name as readableName should be different than first name + return this.timeField(name, "${name}_in_millis", instant.toEpochMilli()) +} + +fun XContentParser.instant(): Instant? { + return when { + currentToken() == XContentParser.Token.VALUE_NULL -> null + currentToken().isValue -> Instant.ofEpochMilli(longValue()) + else -> { + XContentParserUtils.throwUnknownToken(currentToken(), tokenLocation) + null // unreachable + } + } +} + +/** + * Extension function for ES 6.3 and above that duplicates the ES 6.2 XContentBuilder.string() method. + */ +fun XContentBuilder.string(): String = BytesReference.bytes(this).utf8ToString() diff --git a/src/test/kotlin/org/opensearch/commons/alerting/AlertingPluginInterfaceTests.kt b/src/test/kotlin/org/opensearch/commons/alerting/AlertingPluginInterfaceTests.kt new file mode 100644 index 00000000..2c2f5038 --- /dev/null +++ b/src/test/kotlin/org/opensearch/commons/alerting/AlertingPluginInterfaceTests.kt @@ -0,0 +1,44 @@ +package org.opensearch.commons.alerting + +import com.nhaarman.mockitokotlin2.whenever +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Answers +import org.mockito.ArgumentMatchers +import org.mockito.Mock +import org.mockito.Mockito +import org.mockito.Mockito.mock +import org.mockito.junit.jupiter.MockitoExtension +import org.opensearch.action.ActionListener +import org.opensearch.action.ActionType +import org.opensearch.client.node.NodeClient +import org.opensearch.commons.alerting.action.IndexMonitorRequest +import org.opensearch.commons.alerting.action.IndexMonitorResponse +import org.opensearch.commons.alerting.model.Monitor +import org.opensearch.index.seqno.SequenceNumbers + +@Suppress("UNCHECKED_CAST") +@ExtendWith(MockitoExtension::class) +internal class AlertingPluginInterfaceTests { + + @Mock(answer = Answers.RETURNS_DEEP_STUBS) + private lateinit var client: NodeClient + + @Test + fun indexMonitor() { + val monitor = randomQueryLevelMonitor() + + val request = mock(IndexMonitorRequest::class.java) + val response = IndexMonitorResponse(Monitor.NO_ID, Monitor.NO_VERSION, SequenceNumbers.UNASSIGNED_SEQ_NO, SequenceNumbers.UNASSIGNED_PRIMARY_TERM, monitor) + val listener: ActionListener = + mock(ActionListener::class.java) as ActionListener + + Mockito.doAnswer { + (it.getArgument(2) as ActionListener) + .onResponse(response) + }.whenever(client).execute(Mockito.any(ActionType::class.java), Mockito.any(), Mockito.any()) + + AlertingPluginInterface.indexMonitor(client, request, listener) + Mockito.verify(listener, Mockito.times(1)).onResponse(ArgumentMatchers.eq(response)) + } +} diff --git a/src/test/kotlin/org/opensearch/commons/alerting/MonitorTests.kt b/src/test/kotlin/org/opensearch/commons/alerting/MonitorTests.kt new file mode 100644 index 00000000..bc34bf94 --- /dev/null +++ b/src/test/kotlin/org/opensearch/commons/alerting/MonitorTests.kt @@ -0,0 +1,46 @@ +package org.opensearch.commons.alerting + +import org.junit.jupiter.api.Test +import org.opensearch.commons.alerting.model.Trigger +import org.opensearch.test.OpenSearchTestCase +import java.lang.IllegalArgumentException +import java.time.Instant + +internal class MonitorTests { + @Test + fun `test enabled time`() { + val monitor = randomQueryLevelMonitor() + val enabledMonitor = monitor.copy(enabled = true, enabledTime = Instant.now()) + try { + enabledMonitor.copy(enabled = false) + OpenSearchTestCase.fail("Disabling monitor with enabled time set should fail.") + } catch (e: IllegalArgumentException) { + } + + val disabledMonitor = monitor.copy(enabled = false, enabledTime = null) + + try { + disabledMonitor.copy(enabled = true) + OpenSearchTestCase.fail("Enabling monitor without enabled time should fail") + } catch (e: IllegalArgumentException) { + } + } + + @Test + fun `test max triggers`() { + val monitor = randomQueryLevelMonitor() + + val tooManyTriggers = mutableListOf() + var i = 0 + while (i <= 10) { + tooManyTriggers.add(randomQueryLevelTrigger()) + ++i + } + + try { + monitor.copy(triggers = tooManyTriggers) + OpenSearchTestCase.fail("Monitor with too many triggers should be rejected.") + } catch (e: IllegalArgumentException) { + } + } +} diff --git a/src/test/kotlin/org/opensearch/commons/alerting/TestHelpers.kt b/src/test/kotlin/org/opensearch/commons/alerting/TestHelpers.kt new file mode 100644 index 00000000..68b860d4 --- /dev/null +++ b/src/test/kotlin/org/opensearch/commons/alerting/TestHelpers.kt @@ -0,0 +1,406 @@ +package org.opensearch.commons.alerting + +import com.carrotsearch.randomizedtesting.generators.RandomNumbers +import com.carrotsearch.randomizedtesting.generators.RandomStrings +import junit.framework.TestCase.assertNull +import org.apache.http.Header +import org.apache.http.HttpEntity +import org.opensearch.client.Request +import org.opensearch.client.RequestOptions +import org.opensearch.client.Response +import org.opensearch.client.RestClient +import org.opensearch.client.WarningsHandler +import org.opensearch.common.UUIDs +import org.opensearch.common.settings.Settings +import org.opensearch.common.xcontent.LoggingDeprecationHandler +import org.opensearch.common.xcontent.NamedXContentRegistry +import org.opensearch.common.xcontent.ToXContent +import org.opensearch.common.xcontent.XContentBuilder +import org.opensearch.common.xcontent.XContentFactory +import org.opensearch.common.xcontent.XContentParser +import org.opensearch.common.xcontent.XContentType +import org.opensearch.commons.alerting.aggregation.bucketselectorext.BucketSelectorExtAggregationBuilder +import org.opensearch.commons.alerting.aggregation.bucketselectorext.BucketSelectorExtFilter +import org.opensearch.commons.alerting.model.BucketLevelTrigger +import org.opensearch.commons.alerting.model.ClusterMetricsInput +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.Input +import org.opensearch.commons.alerting.model.IntervalSchedule +import org.opensearch.commons.alerting.model.Monitor +import org.opensearch.commons.alerting.model.QueryLevelTrigger +import org.opensearch.commons.alerting.model.Schedule +import org.opensearch.commons.alerting.model.SearchInput +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.model.action.AlertCategory +import org.opensearch.commons.alerting.model.action.PerAlertActionScope +import org.opensearch.commons.alerting.model.action.PerExecutionActionScope +import org.opensearch.commons.alerting.model.action.Throttle +import org.opensearch.commons.alerting.util.string +import org.opensearch.commons.authuser.User +import org.opensearch.index.query.QueryBuilders +import org.opensearch.script.Script +import org.opensearch.script.ScriptType +import org.opensearch.search.SearchModule +import org.opensearch.search.aggregations.bucket.terms.IncludeExclude +import org.opensearch.search.aggregations.bucket.terms.TermsAggregationBuilder +import org.opensearch.search.builder.SearchSourceBuilder +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.Random + +const val ALL_ACCESS_ROLE = "all_access" + +fun randomQueryLevelMonitor( + name: String = RandomStrings.randomAsciiLettersOfLength(Random(), 10), + user: User = randomUser(), + inputs: List = listOf(SearchInput(emptyList(), SearchSourceBuilder().query(QueryBuilders.matchAllQuery()))), + schedule: Schedule = IntervalSchedule(interval = 5, unit = ChronoUnit.MINUTES), + enabled: Boolean = Random().nextBoolean(), + triggers: List = (1..RandomNumbers.randomIntBetween(Random(), 0, 10)).map { randomQueryLevelTrigger() }, + enabledTime: Instant? = if (enabled) Instant.now().truncatedTo(ChronoUnit.MILLIS) else null, + lastUpdateTime: Instant = Instant.now().truncatedTo(ChronoUnit.MILLIS), + withMetadata: Boolean = false +): Monitor { + return Monitor( + name = name, monitorType = Monitor.MonitorType.QUERY_LEVEL_MONITOR, enabled = enabled, inputs = inputs, + schedule = schedule, triggers = triggers, enabledTime = enabledTime, lastUpdateTime = lastUpdateTime, user = user, + uiMetadata = if (withMetadata) mapOf("foo" to "bar") else mapOf() + ) +} + +// Monitor of older versions without security. +fun randomQueryLevelMonitorWithoutUser( + name: String = RandomStrings.randomAsciiLettersOfLength(Random(), 10), + inputs: List = listOf(SearchInput(emptyList(), SearchSourceBuilder().query(QueryBuilders.matchAllQuery()))), + schedule: Schedule = IntervalSchedule(interval = 5, unit = ChronoUnit.MINUTES), + enabled: Boolean = Random().nextBoolean(), + triggers: List = (1..RandomNumbers.randomIntBetween(Random(), 0, 10)).map { randomQueryLevelTrigger() }, + enabledTime: Instant? = if (enabled) Instant.now().truncatedTo(ChronoUnit.MILLIS) else null, + lastUpdateTime: Instant = Instant.now().truncatedTo(ChronoUnit.MILLIS), + withMetadata: Boolean = false +): Monitor { + return Monitor( + name = name, monitorType = Monitor.MonitorType.QUERY_LEVEL_MONITOR, enabled = enabled, inputs = inputs, + schedule = schedule, triggers = triggers, enabledTime = enabledTime, lastUpdateTime = lastUpdateTime, user = null, + uiMetadata = if (withMetadata) mapOf("foo" to "bar") else mapOf() + ) +} + +fun randomBucketLevelMonitor( + name: String = RandomStrings.randomAsciiLettersOfLength(Random(), 10), + user: User = randomUser(), + inputs: List = listOf( + SearchInput( + emptyList(), + SearchSourceBuilder().query(QueryBuilders.matchAllQuery()) + .aggregation(TermsAggregationBuilder("test_agg").field("test_field")) + ) + ), + schedule: Schedule = IntervalSchedule(interval = 5, unit = ChronoUnit.MINUTES), + enabled: Boolean = Random().nextBoolean(), + triggers: List = (1..RandomNumbers.randomIntBetween(Random(), 0, 10)).map { randomBucketLevelTrigger() }, + enabledTime: Instant? = if (enabled) Instant.now().truncatedTo(ChronoUnit.MILLIS) else null, + lastUpdateTime: Instant = Instant.now().truncatedTo(ChronoUnit.MILLIS), + withMetadata: Boolean = false +): Monitor { + return Monitor( + name = name, monitorType = Monitor.MonitorType.BUCKET_LEVEL_MONITOR, enabled = enabled, inputs = inputs, + schedule = schedule, triggers = triggers, enabledTime = enabledTime, lastUpdateTime = lastUpdateTime, user = user, + uiMetadata = if (withMetadata) mapOf("foo" to "bar") else mapOf() + ) +} + +fun randomClusterMetricsMonitor( + name: String = RandomStrings.randomAsciiLettersOfLength(Random(), 10), + user: User = randomUser(), + inputs: List = listOf(randomClusterMetricsInput()), + schedule: Schedule = IntervalSchedule(interval = 5, unit = ChronoUnit.MINUTES), + enabled: Boolean = Random().nextBoolean(), + triggers: List = (1..RandomNumbers.randomIntBetween(Random(), 0, 10)).map { randomQueryLevelTrigger() }, + enabledTime: Instant? = if (enabled) Instant.now().truncatedTo(ChronoUnit.MILLIS) else null, + lastUpdateTime: Instant = Instant.now().truncatedTo(ChronoUnit.MILLIS), + withMetadata: Boolean = false +): Monitor { + return Monitor( + name = name, monitorType = Monitor.MonitorType.CLUSTER_METRICS_MONITOR, enabled = enabled, inputs = inputs, + schedule = schedule, triggers = triggers, enabledTime = enabledTime, lastUpdateTime = lastUpdateTime, user = user, + uiMetadata = if (withMetadata) mapOf("foo" to "bar") else mapOf() + ) +} + +fun randomDocumentLevelMonitor( + name: String = RandomStrings.randomAsciiLettersOfLength(Random(), 10), + user: User? = randomUser(), + inputs: List = listOf(DocLevelMonitorInput("description", listOf("index"), emptyList())), + schedule: Schedule = IntervalSchedule(interval = 5, unit = ChronoUnit.MINUTES), + enabled: Boolean = Random().nextBoolean(), + triggers: List = (1..RandomNumbers.randomIntBetween(Random(), 0, 10)).map { randomQueryLevelTrigger() }, + enabledTime: Instant? = if (enabled) Instant.now().truncatedTo(ChronoUnit.MILLIS) else null, + lastUpdateTime: Instant = Instant.now().truncatedTo(ChronoUnit.MILLIS), + withMetadata: Boolean = false +): Monitor { + return Monitor( + name = name, monitorType = Monitor.MonitorType.DOC_LEVEL_MONITOR, enabled = enabled, inputs = inputs, + schedule = schedule, triggers = triggers, enabledTime = enabledTime, lastUpdateTime = lastUpdateTime, user = user, + uiMetadata = if (withMetadata) mapOf("foo" to "bar") else mapOf() + ) +} + +fun randomQueryLevelTrigger( + id: String = UUIDs.base64UUID(), + name: String = RandomStrings.randomAsciiLettersOfLength(Random(), 10), + severity: String = "1", + condition: Script = randomScript(), + actions: List = mutableListOf(), + destinationId: String = "" +): QueryLevelTrigger { + return QueryLevelTrigger( + id = id, + name = name, + severity = severity, + condition = condition, + actions = if (actions.isEmpty()) (0..RandomNumbers.randomIntBetween(Random(), 0, 10)).map { randomAction(destinationId = destinationId) } else actions + ) +} + +fun randomBucketLevelTrigger( + id: String = UUIDs.base64UUID(), + name: String = RandomStrings.randomAsciiLettersOfLength(Random(), 10), + severity: String = "1", + bucketSelector: BucketSelectorExtAggregationBuilder = randomBucketSelectorExtAggregationBuilder(name = id), + actions: List = mutableListOf(), + destinationId: String = "" +): BucketLevelTrigger { + return BucketLevelTrigger( + id = id, + name = name, + severity = severity, + bucketSelector = bucketSelector, + actions = if (actions.isEmpty()) randomActionsForBucketLevelTrigger(destinationId = destinationId) else actions + ) +} + +fun randomActionsForBucketLevelTrigger(min: Int = 0, max: Int = 10, destinationId: String = ""): List = + (min..RandomNumbers.randomIntBetween(Random(), 0, max)).map { randomActionWithPolicy(destinationId = destinationId) } + +fun randomDocumentLevelTrigger( + id: String = UUIDs.base64UUID(), + name: String = RandomStrings.randomAsciiLettersOfLength(Random(), 10), + severity: String = "1", + condition: Script = randomScript(), + actions: List = mutableListOf(), + destinationId: String = "" +): DocumentLevelTrigger { + return DocumentLevelTrigger( + id = id, + name = name, + severity = severity, + condition = condition, + actions = if (actions.isEmpty() && destinationId.isNotBlank()) + (0..RandomNumbers.randomIntBetween(Random(), 0, 10)).map { randomAction(destinationId = destinationId) } + else actions + ) +} + +fun randomBucketSelectorExtAggregationBuilder( + name: String = RandomStrings.randomAsciiLettersOfLength(Random(), 10), + bucketsPathsMap: MutableMap = mutableMapOf("avg" to "10"), + script: Script = randomBucketSelectorScript(params = bucketsPathsMap), + parentBucketPath: String = "testPath", + filter: BucketSelectorExtFilter = BucketSelectorExtFilter(IncludeExclude("foo*", "bar*")) +): BucketSelectorExtAggregationBuilder { + return BucketSelectorExtAggregationBuilder(name, bucketsPathsMap, script, parentBucketPath, filter) +} + +fun randomBucketSelectorScript( + idOrCode: String = "params.avg >= 0", + params: Map = mutableMapOf("avg" to "10") +): Script { + return Script(Script.DEFAULT_SCRIPT_TYPE, Script.DEFAULT_SCRIPT_LANG, idOrCode, emptyMap(), params) +} + +fun randomScript(source: String = "return " + Random().nextBoolean().toString()): Script = Script(source) + +fun randomTemplateScript( + source: String, + params: Map = emptyMap() +): Script = Script(ScriptType.INLINE, Script.DEFAULT_TEMPLATE_LANG, source, params) + +fun randomAction( + name: String = RandomStrings.randomAsciiLettersOfLength(Random(), 10), + template: Script = randomTemplateScript("Hello World"), + destinationId: String = "", + throttleEnabled: Boolean = false, + throttle: Throttle = randomThrottle() +) = Action(name, destinationId, template, template, throttleEnabled, throttle, actionExecutionPolicy = null) + +fun randomActionWithPolicy( + name: String = RandomStrings.randomAsciiLettersOfLength(Random(), 10), + template: Script = randomTemplateScript("Hello World"), + destinationId: String = "", + throttleEnabled: Boolean = false, + throttle: Throttle = randomThrottle(), + actionExecutionPolicy: ActionExecutionPolicy? = randomActionExecutionPolicy() +): Action { + return if (actionExecutionPolicy?.actionExecutionScope is PerExecutionActionScope) { + // Return null for throttle when using PerExecutionActionScope since throttling is currently not supported for it + Action(name, destinationId, template, template, throttleEnabled, null, actionExecutionPolicy = actionExecutionPolicy) + } else { + Action(name, destinationId, template, template, throttleEnabled, throttle, actionExecutionPolicy = actionExecutionPolicy) + } +} + +fun randomThrottle( + value: Int = RandomNumbers.randomIntBetween(Random(), 60, 120), + unit: ChronoUnit = ChronoUnit.MINUTES +) = Throttle(value, unit) + +fun randomActionExecutionPolicy( + actionExecutionScope: ActionExecutionScope = randomActionExecutionScope() +) = ActionExecutionPolicy(actionExecutionScope) + +fun randomActionExecutionScope(): ActionExecutionScope { + return if (Random().nextBoolean()) { + val alertCategories = AlertCategory.values() + PerAlertActionScope(actionableAlerts = (1..RandomNumbers.randomIntBetween(Random(), 0, alertCategories.size)).map { alertCategories[it - 1] }.toSet()) + } else { + PerExecutionActionScope() + } +} + +fun randomDocLevelQuery( + id: String = RandomStrings.randomAsciiLettersOfLength(Random(), 10), + query: String = RandomStrings.randomAsciiLettersOfLength(Random(), 10), + name: String = "${RandomNumbers.randomIntBetween(Random(), 0, 5)}", + tags: List = mutableListOf(0..RandomNumbers.randomIntBetween(Random(), 0, 10)).map { RandomStrings.randomAsciiLettersOfLength(Random(), 10) } +): DocLevelQuery { + return DocLevelQuery(id = id, query = query, name = name, tags = tags) +} + +fun randomDocLevelMonitorInput( + description: String = RandomStrings.randomAsciiLettersOfLength(Random(), RandomNumbers.randomIntBetween(Random(), 0, 10)), + indices: List = listOf(1..RandomNumbers.randomIntBetween(Random(), 0, 10)).map { RandomStrings.randomAsciiLettersOfLength(Random(), 10) }, + queries: List = listOf(1..RandomNumbers.randomIntBetween(Random(), 0, 10)).map { randomDocLevelQuery() } +): DocLevelMonitorInput { + return DocLevelMonitorInput(description = description, indices = indices, queries = queries) +} + +fun randomClusterMetricsInput( + path: String = ClusterMetricsInput.ClusterMetricType.CLUSTER_HEALTH.defaultPath, + pathParams: String = "", + url: String = "" +): ClusterMetricsInput { + return ClusterMetricsInput(path, pathParams, url) +} + +fun Monitor.toJsonString(): String { + val builder = XContentFactory.jsonBuilder() + return this.toXContent(builder, ToXContent.EMPTY_PARAMS).string() +} + +fun Monitor.toJsonStringWithUser(): String { + val builder = XContentFactory.jsonBuilder() + return this.toXContentWithUser(builder, ToXContent.EMPTY_PARAMS).string() +} + +fun randomUser(): User { + return User( + RandomStrings.randomAsciiLettersOfLength(Random(), 10), + listOf( + RandomStrings.randomAsciiLettersOfLength(Random(), 10), + RandomStrings.randomAsciiLettersOfLength(Random(), 10) + ), + listOf(RandomStrings.randomAsciiLettersOfLength(Random(), 10), ALL_ACCESS_ROLE), + listOf("test_attr=test") + ) +} + +fun randomUserEmpty(): User { + return User("", listOf(), listOf(), listOf()) +} + +/** + * Wrapper for [RestClient.performRequest] which was deprecated in ES 6.5 and is used in tests. This provides + * a single place to suppress deprecation warnings. This will probably need further work when the API is removed entirely + * but that's an exercise for another day. + */ +@Suppress("DEPRECATION") +fun RestClient.makeRequest( + method: String, + endpoint: String, + params: Map = emptyMap(), + entity: HttpEntity? = null, + vararg headers: Header +): Response { + val request = Request(method, endpoint) + // TODO: remove PERMISSIVE option after moving system index access to REST API call + val options = RequestOptions.DEFAULT.toBuilder() + options.setWarningsHandler(WarningsHandler.PERMISSIVE) + headers.forEach { options.addHeader(it.name, it.value) } + request.options = options.build() + params.forEach { request.addParameter(it.key, it.value) } + if (entity != null) { + request.entity = entity + } + return performRequest(request) +} + +/** + * Wrapper for [RestClient.performRequest] which was deprecated in ES 6.5 and is used in tests. This provides + * a single place to suppress deprecation warnings. This will probably need further work when the API is removed entirely + * but that's an exercise for another day. + */ +@Suppress("DEPRECATION") +fun RestClient.makeRequest( + method: String, + endpoint: String, + entity: HttpEntity? = null, + vararg headers: Header +): Response { + val request = Request(method, endpoint) + val options = RequestOptions.DEFAULT.toBuilder() + // TODO: remove PERMISSIVE option after moving system index access to REST API call + options.setWarningsHandler(WarningsHandler.PERMISSIVE) + headers.forEach { options.addHeader(it.name, it.value) } + request.options = options.build() + if (entity != null) { + request.entity = entity + } + return performRequest(request) +} + +fun builder(): XContentBuilder { + return XContentBuilder.builder(XContentType.JSON.xContent()) +} + +fun parser(xc: String): XContentParser { + val parser = XContentType.JSON.xContent().createParser(xContentRegistry(), LoggingDeprecationHandler.INSTANCE, xc) + parser.nextToken() + return parser +} + +fun xContentRegistry(): NamedXContentRegistry { + return NamedXContentRegistry( + listOf( + SearchInput.XCONTENT_REGISTRY, + DocLevelMonitorInput.XCONTENT_REGISTRY, + QueryLevelTrigger.XCONTENT_REGISTRY, + BucketLevelTrigger.XCONTENT_REGISTRY, + DocumentLevelTrigger.XCONTENT_REGISTRY + ) + SearchModule(Settings.EMPTY, emptyList()).namedXContents + ) +} + +fun assertUserNull(map: Map) { + val user = map["user"] + assertNull("User is not null", user) +} + +fun assertUserNull(monitor: Monitor) { + assertNull("User is not null", monitor.user) +} diff --git a/src/test/kotlin/org/opensearch/commons/alerting/action/IndexMonitorRequestTests.kt b/src/test/kotlin/org/opensearch/commons/alerting/action/IndexMonitorRequestTests.kt new file mode 100644 index 00000000..ce26c7d1 --- /dev/null +++ b/src/test/kotlin/org/opensearch/commons/alerting/action/IndexMonitorRequestTests.kt @@ -0,0 +1,54 @@ +package org.opensearch.commons.alerting.action + +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test +import org.opensearch.action.support.WriteRequest +import org.opensearch.common.io.stream.BytesStreamOutput +import org.opensearch.common.io.stream.StreamInput +import org.opensearch.commons.alerting.model.SearchInput +import org.opensearch.commons.alerting.randomQueryLevelMonitor +import org.opensearch.rest.RestRequest +import org.opensearch.search.builder.SearchSourceBuilder + +class IndexMonitorRequestTests { + + @Test + fun `test index monitor post request`() { + + val req = IndexMonitorRequest( + "1234", 1L, 2L, WriteRequest.RefreshPolicy.IMMEDIATE, RestRequest.Method.POST, + randomQueryLevelMonitor().copy(inputs = listOf(SearchInput(emptyList(), SearchSourceBuilder()))) + ) + Assertions.assertNotNull(req) + + val out = BytesStreamOutput() + req.writeTo(out) + val sin = StreamInput.wrap(out.bytes().toBytesRef().bytes) + val newReq = IndexMonitorRequest(sin) + Assertions.assertEquals("1234", newReq.monitorId) + Assertions.assertEquals(1L, newReq.seqNo) + Assertions.assertEquals(2L, newReq.primaryTerm) + Assertions.assertEquals(RestRequest.Method.POST, newReq.method) + Assertions.assertNotNull(newReq.monitor) + } + + @Test + fun `test index monitor put request`() { + + val req = IndexMonitorRequest( + "1234", 1L, 2L, WriteRequest.RefreshPolicy.IMMEDIATE, RestRequest.Method.PUT, + randomQueryLevelMonitor().copy(inputs = listOf(SearchInput(emptyList(), SearchSourceBuilder()))) + ) + Assertions.assertNotNull(req) + + val out = BytesStreamOutput() + req.writeTo(out) + val sin = StreamInput.wrap(out.bytes().toBytesRef().bytes) + val newReq = IndexMonitorRequest(sin) + Assertions.assertEquals("1234", newReq.monitorId) + Assertions.assertEquals(1L, newReq.seqNo) + Assertions.assertEquals(2L, newReq.primaryTerm) + Assertions.assertEquals(RestRequest.Method.PUT, newReq.method) + Assertions.assertNotNull(newReq.monitor) + } +} diff --git a/src/test/kotlin/org/opensearch/commons/alerting/action/IndexMonitorResponseTests.kt b/src/test/kotlin/org/opensearch/commons/alerting/action/IndexMonitorResponseTests.kt new file mode 100644 index 00000000..f9332649 --- /dev/null +++ b/src/test/kotlin/org/opensearch/commons/alerting/action/IndexMonitorResponseTests.kt @@ -0,0 +1,47 @@ +package org.opensearch.commons.alerting.action + +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test +import org.opensearch.common.io.stream.BytesStreamOutput +import org.opensearch.common.io.stream.StreamInput +import org.opensearch.commons.alerting.model.CronSchedule +import org.opensearch.commons.alerting.model.Monitor +import org.opensearch.commons.alerting.randomUser +import java.time.Instant +import java.time.ZoneId + +class IndexMonitorResponseTests { + + @Test + fun `test index monitor response with monitor`() { + val cronExpression = "31 * * * *" // Run at minute 31. + val testInstance = Instant.ofEpochSecond(1538164858L) + + val cronSchedule = CronSchedule(cronExpression, ZoneId.of("Asia/Kolkata"), testInstance) + val monitor = Monitor( + id = "123", + version = 0L, + name = "test-monitor", + enabled = true, + schedule = cronSchedule, + lastUpdateTime = Instant.now(), + enabledTime = Instant.now(), + monitorType = Monitor.MonitorType.QUERY_LEVEL_MONITOR, + user = randomUser(), + schemaVersion = 0, + inputs = mutableListOf(), + triggers = mutableListOf(), + uiMetadata = mutableMapOf() + ) + val req = IndexMonitorResponse("1234", 1L, 2L, 0L, monitor) + Assertions.assertNotNull(req) + + val out = BytesStreamOutput() + req.writeTo(out) + val sin = StreamInput.wrap(out.bytes().toBytesRef().bytes) + val newReq = IndexMonitorResponse(sin) + Assertions.assertEquals("1234", newReq.id) + Assertions.assertEquals(1L, newReq.version) + Assertions.assertNotNull(newReq.monitor) + } +} diff --git a/src/test/kotlin/org/opensearch/commons/alerting/model/ClusterMetricsInputTests.kt b/src/test/kotlin/org/opensearch/commons/alerting/model/ClusterMetricsInputTests.kt new file mode 100644 index 00000000..d9dcd1f3 --- /dev/null +++ b/src/test/kotlin/org/opensearch/commons/alerting/model/ClusterMetricsInputTests.kt @@ -0,0 +1,442 @@ +package org.opensearch.commons.alerting.model + +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class ClusterMetricsInputTests { + private var path = "/_cluster/health" + private var pathParams = "" + private var url = "" + + @Test + fun `test valid ClusterMetricsInput creation using HTTP URI component fields`() { + // GIVEN + val testUrl = "http://localhost:9200/_cluster/health" + + // WHEN + val clusterMetricsInput = ClusterMetricsInput(path, pathParams, url) + + // THEN + assertEquals(path, clusterMetricsInput.path) + assertEquals(pathParams, clusterMetricsInput.pathParams) + assertEquals(testUrl, clusterMetricsInput.url) + } + + @Test + fun `test valid ClusterMetricsInput creation using HTTP url field`() { + // GIVEN + path = "" + url = "http://localhost:9200/_cluster/health" + + // WHEN + val clusterMetricsInput = ClusterMetricsInput(path, pathParams, url) + + // THEN + assertEquals(url, clusterMetricsInput.url) + } + + @Test + fun `test valid ClusterMetricsInput creation using HTTPS url field`() { + // GIVEN + path = "" + url = "https://localhost:9200/_cluster/health" + + // WHEN + val clusterMetricsInput = ClusterMetricsInput(path, pathParams, url) + + // THEN + assertEquals(url, clusterMetricsInput.url) + } + + @Test + fun `test invalid path`() { + // GIVEN + path = "///" + + // WHEN + THEN + assertFailsWith("Invalid URL.") { + ClusterMetricsInput(path, pathParams, url) + } + } + + @Test + fun `test invalid url`() { + // GIVEN + url = "///" + + // WHEN + THEN + assertFailsWith("Invalid URL.") { + ClusterMetricsInput(path, pathParams, url) + } + } + + @Test + fun `test url field and URI component fields create equal URI`() { + // GIVEN + url = "http://localhost:9200/_cluster/health" + + // WHEN + val clusterMetricsInput = ClusterMetricsInput(path, pathParams, url) + + // THEN + assertEquals(path, clusterMetricsInput.path) + assertEquals(pathParams, clusterMetricsInput.pathParams) + assertEquals(url, clusterMetricsInput.url) + assertEquals(url, clusterMetricsInput.constructedUri.toString()) + } + + @Test + fun `test url field and URI component fields with path params create equal URI`() { + // GIVEN + path = "/_cluster/health/" + pathParams = "index1,index2,index3,index4,index5" + url = "http://localhost:9200/_cluster/health/index1,index2,index3,index4,index5" + + // WHEN + val clusterMetricsInput = ClusterMetricsInput(path, pathParams, url) + + // THEN + assertEquals(path, clusterMetricsInput.path) + assertEquals(pathParams, clusterMetricsInput.pathParams) + assertEquals(url, clusterMetricsInput.url) + assertEquals(url, clusterMetricsInput.constructedUri.toString()) + } + + @Test + fun `test url field and URI component fields create different URI`() { + // GIVEN + url = "http://localhost:9200/_cluster/stats" + + // WHEN + THEN + assertFailsWith("The provided URL and URI fields form different URLs.") { + ClusterMetricsInput(path, pathParams, url) + } + } + + @Test + fun `test url field and URI component fields with path params create different URI`() { + // GIVEN + pathParams = "index1,index2,index3,index4,index5" + url = "http://localhost:9200/_cluster/stats/index1,index2,index3,index4,index5" + + // WHEN + THEN + assertFailsWith("The provided URL and URI fields form different URLs.") { + ClusterMetricsInput(path, pathParams, url) + } + } + + @Test + fun `test ClusterMetricsInput creation when all inputs are empty`() { + // GIVEN + path = "" + pathParams = "" + url = "" + + // WHEN + THEN + assertFailsWith("The uri.api_type field, uri.path field, or uri.uri field must be defined.") { + ClusterMetricsInput(path, pathParams, url) + } + } + + @Test + fun `test ClusterMetricsInput creation when all inputs but path params are empty`() { + // GIVEN + path = "" + pathParams = "index1,index2,index3,index4,index5" + url = "" + + // WHEN + THEN + assertFailsWith("The uri.api_type field, uri.path field, or uri.uri field must be defined.") { + ClusterMetricsInput(path, pathParams, url) + } + } + + @Test + fun `test invalid scheme in url field`() { + // GIVEN + path = "" + url = "invalidScheme://localhost:9200/_cluster/health" + + // WHEN + THEN + assertFailsWith("Invalid URL.") { + ClusterMetricsInput(path, pathParams, url) + } + } + + @Test + fun `test invalid host in url field`() { + // GIVEN + path = "" + url = "http://127.0.0.1:9200/_cluster/health" + + // WHEN + THEN + assertFailsWith("Only host '${ClusterMetricsInput.SUPPORTED_HOST}' is supported.") { + ClusterMetricsInput(path, pathParams, url) + } + } + + @Test + fun `test invalid port in url field`() { + // GIVEN + path = "" + url = "http://localhost:${ClusterMetricsInput.SUPPORTED_PORT + 1}/_cluster/health" + + // WHEN + THEN + assertFailsWith("Only port '${ClusterMetricsInput.SUPPORTED_PORT}' is supported.") { + ClusterMetricsInput(path, pathParams, url) + } + } + + @Test + fun `test parsePathParams with no path params`() { + // GIVEN + val testUrl = "http://localhost:9200/_cluster/health" + val clusterMetricsInput = ClusterMetricsInput(path, pathParams, url) + + // WHEN + val params = clusterMetricsInput.parsePathParams() + + // THEN + assertEquals(pathParams, params) + assertEquals(testUrl, clusterMetricsInput.constructedUri.toString()) + } + + @Test + fun `test parsePathParams with path params as URI field`() { + // GIVEN + path = "/_cluster/health/" + pathParams = "index1,index2,index3,index4,index5" + val testUrl = "http://localhost:9200/_cluster/health/index1,index2,index3,index4,index5" + val clusterMetricsInput = ClusterMetricsInput(path, pathParams, url) + + // WHEN + val params = clusterMetricsInput.parsePathParams() + + // THEN + assertEquals(pathParams, params) + assertEquals(testUrl, clusterMetricsInput.constructedUri.toString()) + } + + @Test + fun `test parsePathParams with path params in url`() { + // GIVEN + path = "" + val testParams = "index1,index2,index3,index4,index5" + url = "http://localhost:9200/_cluster/health/index1,index2,index3,index4,index5" + val clusterMetricsInput = ClusterMetricsInput(path, pathParams, url) + + // WHEN + val params = clusterMetricsInput.parsePathParams() + + // THEN + assertEquals(testParams, params) + assertEquals(url, clusterMetricsInput.constructedUri.toString()) + } + + @Test + fun `test parsePathParams with no path params for ApiType that requires path params`() { + // GIVEN + path = "/_cat/snapshots" + + // WHEN + THEN + assertFailsWith("The API requires path parameters.") { + ClusterMetricsInput(path, pathParams, url) + } + } + + @Test + fun `test parsePathParams with path params for ApiType that doesn't support path params`() { + // GIVEN + path = "/_cluster/settings" + pathParams = "index1,index2,index3,index4,index5" + val clusterMetricsInput = ClusterMetricsInput(path, pathParams, url) + + // WHEN + THEN + assertFailsWith("The API does not use path parameters.") { + clusterMetricsInput.parsePathParams() + } + } + + @Test + fun `test parsePathParams with path params containing illegal characters`() { + var testCount = 0 // Start off with count of 1 to account for ApiType.BLANK + ILLEGAL_PATH_PARAMETER_CHARACTERS.forEach { character -> + // GIVEN + pathParams = "index1,index2,$character,index4,index5" + val clusterMetricsInput = ClusterMetricsInput(path, pathParams, url) + + // WHEN + THEN + assertFailsWith( + "The provided path parameters contain invalid characters or spaces. Please omit: " + "${ILLEGAL_PATH_PARAMETER_CHARACTERS.joinToString(" ")}" + ) { + clusterMetricsInput.parsePathParams() + } + testCount++ + } + assertEquals(ILLEGAL_PATH_PARAMETER_CHARACTERS.size, testCount) + } + + @Test + fun `test ClusterMetricsInput correctly determines ApiType when path is provided as URI component`() { + var testCount = 1 // Start off with count of 1 to account for ApiType.BLANK + ClusterMetricsInput.ClusterMetricType.values() + .filter { enum -> enum != ClusterMetricsInput.ClusterMetricType.BLANK } + .forEach { testApiType -> + // GIVEN + path = testApiType.defaultPath + pathParams = if (testApiType.supportsPathParams) "index1,index2,index3,index4,index5" else "" + + // WHEN + val clusterMetricsInput = ClusterMetricsInput(path, pathParams, url) + + // THEN + assertEquals(testApiType, clusterMetricsInput.clusterMetricType) + testCount++ + } + assertEquals(ClusterMetricsInput.ClusterMetricType.values().size, testCount) + } + + @Test + fun `test ClusterMetricsInput correctly determines ApiType when path and path params are provided as URI components`() { + var testCount = 1 // Start off with count of 1 to account for ApiType.BLANK + ClusterMetricsInput.ClusterMetricType.values() + .filter { enum -> enum != ClusterMetricsInput.ClusterMetricType.BLANK } + .forEach { testApiType -> + // GIVEN + path = testApiType.defaultPath + pathParams = "index1,index2,index3,index4,index5" + + // WHEN + val clusterMetricsInput = ClusterMetricsInput(path, pathParams, url) + + // THEN + assertEquals(testApiType, clusterMetricsInput.clusterMetricType) + testCount++ + } + assertEquals(ClusterMetricsInput.ClusterMetricType.values().size, testCount) + } + + @Test + fun `test ClusterMetricsInput correctly determines ApiType when path is provided in URL field`() { + var testCount = 1 // Start off with count of 1 to account for ApiType.BLANK + ClusterMetricsInput.ClusterMetricType.values() + .filter { enum -> enum != ClusterMetricsInput.ClusterMetricType.BLANK } + .forEach { testApiType -> + // GIVEN + path = "" + pathParams = if (testApiType.supportsPathParams) "index1,index2,index3,index4,index5" else "" + url = "http://localhost:9200${testApiType.defaultPath}" + + // WHEN + val clusterMetricsInput = ClusterMetricsInput(path, pathParams, url) + + // THEN + assertEquals(testApiType, clusterMetricsInput.clusterMetricType) + testCount++ + } + assertEquals(ClusterMetricsInput.ClusterMetricType.values().size, testCount) + } + + @Test + fun `test ClusterMetricsInput correctly determines ApiType when path and path params are provided in URL field`() { + var testCount = 1 // Start off with count of 1 to account for ApiType.BLANK + ClusterMetricsInput.ClusterMetricType.values() + .filter { enum -> enum != ClusterMetricsInput.ClusterMetricType.BLANK } + .forEach { testApiType -> + // GIVEN + path = "" + pathParams = if (testApiType.supportsPathParams) "/index1,index2,index3,index4,index5" else "" + url = "http://localhost:9200${testApiType.defaultPath}$pathParams" + + // WHEN + val clusterMetricsInput = ClusterMetricsInput(path, pathParams, url) + + // THEN + assertEquals(testApiType, clusterMetricsInput.clusterMetricType) + testCount++ + } + assertEquals(ClusterMetricsInput.ClusterMetricType.values().size, testCount) + } + + @Test + fun `test ClusterMetricsInput cannot determine ApiType when invalid path is provided as URI component`() { + // GIVEN + path = "/_cat/paws" + + // WHEN + THEN + assertFailsWith("The API could not be determined from the provided URI.") { + ClusterMetricsInput(path, pathParams, url) + } + } + + @Test + fun `test ClusterMetricsInput cannot determine ApiType when invalid path and path params are provided as URI components`() { + // GIVEN + path = "/_cat/paws" + pathParams = "index1,index2,index3,index4,index5" + + // WHEN + THEN + assertFailsWith("The API could not be determined from the provided URI.") { + ClusterMetricsInput(path, pathParams, url) + } + } + + @Test + fun `test ClusterMetricsInput cannot determine ApiType when invaid path is provided in URL`() { + // GIVEN + path = "" + url = "http://localhost:9200/_cat/paws" + + // WHEN + THEN + assertFailsWith("The API could not be determined from the provided URI.") { + ClusterMetricsInput(path, pathParams, url) + } + } + + @Test + fun `test ClusterMetricsInput cannot determine ApiType when invaid path and path params are provided in URL`() { + // GIVEN + path = "" + url = "http://localhost:9200/_cat/paws/index1,index2,index3,index4,index5" + + // WHEN + THEN + assertFailsWith("The API could not be determined from the provided URI.") { + ClusterMetricsInput(path, pathParams, url) + } + } + + @Test + fun `test parseEmptyFields populates empty path and path_params when url is provided`() { + // GIVEN + path = "" + pathParams = "" + val testPath = "/_cluster/health" + val testPathParams = "index1,index2,index3,index4,index5" + url = "http://localhost:9200$testPath$testPathParams" + + // WHEN + val clusterMetricsInput = ClusterMetricsInput(path, pathParams, url) + + // THEN + assertEquals(testPath, clusterMetricsInput.path) + assertEquals(testPathParams, clusterMetricsInput.pathParams) + assertEquals(url, clusterMetricsInput.url) + } + + @Test + fun `test parseEmptyFields populates empty url field when path and path_params are provided`() { + // GIVEN + path = "/_cluster/health/" + pathParams = "index1,index2,index3,index4,index5" + val testUrl = "http://localhost:9200$path$pathParams" + + // WHEN + val clusterMetricsInput = ClusterMetricsInput(path, pathParams, url) + + // THEN + assertEquals(path, clusterMetricsInput.path) + assertEquals(pathParams, clusterMetricsInput.pathParams) + assertEquals(testUrl, clusterMetricsInput.url) + } +} diff --git a/src/test/kotlin/org/opensearch/commons/alerting/model/DocLevelMonitorInputTests.kt b/src/test/kotlin/org/opensearch/commons/alerting/model/DocLevelMonitorInputTests.kt new file mode 100644 index 00000000..fb0ae8e3 --- /dev/null +++ b/src/test/kotlin/org/opensearch/commons/alerting/model/DocLevelMonitorInputTests.kt @@ -0,0 +1,109 @@ +package org.opensearch.commons.alerting.model + +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test +import org.opensearch.common.xcontent.ToXContent +import org.opensearch.common.xcontent.XContentBuilder +import org.opensearch.common.xcontent.XContentType +import org.opensearch.commons.alerting.randomDocLevelMonitorInput +import org.opensearch.commons.alerting.randomDocLevelQuery +import org.opensearch.commons.alerting.util.string +import java.lang.IllegalArgumentException + +class DocLevelMonitorInputTests { + @Test + fun `test DocLevelQuery asTemplateArgs`() { + // GIVEN + val query = randomDocLevelQuery() + + // WHEN + val templateArgs = query.asTemplateArg() + + // THEN + Assertions.assertEquals( + templateArgs[DocLevelQuery.QUERY_ID_FIELD], + query.id, + "Template args 'id' field does not match:" + ) + Assertions.assertEquals( + templateArgs[DocLevelQuery.QUERY_FIELD], + query.query, + "Template args 'query' field does not match:" + ) + Assertions.assertEquals( + templateArgs[DocLevelQuery.NAME_FIELD], + query.name, + "Template args 'name' field does not match:" + ) + Assertions.assertEquals( + templateArgs[DocLevelQuery.TAGS_FIELD], + query.tags, + "Template args 'tags' field does not match:" + ) + } + + @Test + fun `test create Doc Level Query with invalid characters for name`() { + val badString = "query with space" + try { + randomDocLevelQuery(name = badString) + Assertions.fail("Expecting an illegal argument exception") + } catch (e: IllegalArgumentException) { + Assertions.assertEquals( + "They query name or tag, $badString, contains an invalid character: [' ','[',']','{','}','(',')']", + e.message + ) + } + } + + @Test + @Throws(IllegalArgumentException::class) + fun `test create Doc Level Query with invalid characters for tags`() { + val badString = "[(){}]" + try { + randomDocLevelQuery(tags = listOf(badString)) + Assertions.fail("Expecting an illegal argument exception") + } catch (e: IllegalArgumentException) { + Assertions.assertEquals( + "They query name or tag, $badString, contains an invalid character: [' ','[',']','{','}','(',')']", + e.message + ) + } + } + + @Test + fun `test DocLevelMonitorInput asTemplateArgs`() { + // GIVEN + val input = randomDocLevelMonitorInput() + + // test + input.toXContent(XContentBuilder.builder(XContentType.JSON.xContent()), ToXContent.EMPTY_PARAMS).string() + // assertEquals("test", inputString) + // test end + // WHEN + val templateArgs = input.asTemplateArg() + + // THEN + Assertions.assertEquals( + templateArgs[DocLevelMonitorInput.DESCRIPTION_FIELD], + input.description, + "Template args 'description' field does not match:" + ) + Assertions.assertEquals( + templateArgs[DocLevelMonitorInput.INDICES_FIELD], + input.indices, + "Template args 'indices' field does not match:" + ) + Assertions.assertEquals( + input.queries.size, + (templateArgs[DocLevelMonitorInput.QUERIES_FIELD] as List<*>).size, + "Template args 'queries' field does not contain the expected number of queries:" + ) + input.queries.forEach { + Assertions.assertTrue( + (templateArgs[DocLevelMonitorInput.QUERIES_FIELD] as List<*>).contains(it.asTemplateArg()), + "Template args 'queries' field does not match:" + ) + } + } +} diff --git a/src/test/kotlin/org/opensearch/commons/alerting/model/MockScheduledJob.kt b/src/test/kotlin/org/opensearch/commons/alerting/model/MockScheduledJob.kt new file mode 100644 index 00000000..178c6ba8 --- /dev/null +++ b/src/test/kotlin/org/opensearch/commons/alerting/model/MockScheduledJob.kt @@ -0,0 +1,31 @@ +package org.opensearch.commons.alerting.model + +import org.opensearch.common.io.stream.StreamOutput +import org.opensearch.common.xcontent.ToXContent +import org.opensearch.common.xcontent.XContentBuilder +import java.io.IOException +import java.time.Instant + +class MockScheduledJob( + override val id: String, + override val version: Long, + override val name: String, + override val type: String, + override val enabled: Boolean, + override val schedule: Schedule, + override var lastUpdateTime: Instant, + override val enabledTime: Instant? +) : ScheduledJob { + override fun fromDocument(id: String, version: Long): ScheduledJob { + TODO("not implemented") + } + + override fun toXContent(builder: XContentBuilder?, params: ToXContent.Params?): XContentBuilder { + TODO("not implemented") + } + + @Throws(IOException::class) + override fun writeTo(out: StreamOutput) { + TODO("not implemented") + } +} diff --git a/src/test/kotlin/org/opensearch/commons/alerting/model/ScheduleTest.kt b/src/test/kotlin/org/opensearch/commons/alerting/model/ScheduleTest.kt new file mode 100644 index 00000000..6eee00c8 --- /dev/null +++ b/src/test/kotlin/org/opensearch/commons/alerting/model/ScheduleTest.kt @@ -0,0 +1,334 @@ +package org.opensearch.commons.alerting.model + +import org.junit.jupiter.api.Test +import org.opensearch.common.xcontent.ToXContent +import org.opensearch.commons.alerting.util.string +import java.time.Instant +import java.time.ZoneId +import java.time.ZonedDateTime +import java.time.temporal.ChronoUnit +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class ScheduleTest : XContentTestBase { + @Test + fun `test time zone conversion`() { + val cronExpression = "31 * * * *" // Run at minute 31. + // This is 2018-09-27 20:00:58 GMT which will in conversion lead to 30min 58 seconds IST + val testInstance = Instant.ofEpochSecond(1538164858L) + + val cronSchedule = CronSchedule(cronExpression, ZoneId.of("Asia/Kolkata"), testInstance) + val nextTimeToExecute = cronSchedule.nextTimeToExecute(Instant.now()) + assertNotNull(nextTimeToExecute, "There should be next execute time.") + assertEquals(2L, nextTimeToExecute.seconds, "Execute time should be 2 seconds") + } + + @Test + fun `test time zone`() { + val cronExpression = "0 11 * * 3" // Run at 11:00 on Wednesday. + // This is 2018-09-26 01:59:58 GMT which will in conversion lead to Wednesday 10:59:58 JST + val testInstance = Instant.ofEpochSecond(1537927198L) + + val cronSchedule = CronSchedule(cronExpression, ZoneId.of("Asia/Tokyo"), testInstance) + val nextTimeToExecute = cronSchedule.nextTimeToExecute(Instant.now()) + assertNotNull(nextTimeToExecute, "There should be next execute time.") + assertEquals(2L, nextTimeToExecute.seconds, "Execute time should be 2 seconds") + } + + @Test + fun `test cron calculates next time to execute after restart`() { + val cronExpression = "* * * * *" + // This is 2018-09-26 01:59:58 GMT + val testInstance = Instant.ofEpochSecond(1537927198L) + // This enabled time represents GMT: Wednesday, September 19, 2018 3:19:51 AM + val enabledTimeInstance = Instant.ofEpochSecond(1537327191) + + val cronSchedule = CronSchedule(cronExpression, ZoneId.of("America/Los_Angeles"), testInstance) + // The nextTimeToExecute should be the minute after the test instance, not enabledTimeInstance, replicating a cluster restart + val nextTimeToExecute = cronSchedule.getExpectedNextExecutionTime(enabledTimeInstance, null) + assertNotNull(nextTimeToExecute, "There should be next execute time") + assertEquals( + testInstance.plusSeconds(2L), nextTimeToExecute, + "nextTimeToExecute should be 2 seconds after test instance" + ) + } + + @Test + fun `test cron calculates next time to execute using cached previous time`() { + val cronExpression = "* * * * *" + // This is 2018-09-26 01:59:58 GMT + val previousExecutionTimeInstance = Instant.ofEpochSecond(1537927198L) + // This enabled time represents GMT: Wednesday, September 19, 2018 3:19:51 AM + val enabledTimeInstance = Instant.ofEpochSecond(1537327191) + + val cronSchedule = CronSchedule(cronExpression, ZoneId.of("America/Los_Angeles")) + // The nextTimeToExecute should be the minute after the previous execution time instance, not enabledTimeInstance + val nextTimeToExecute = cronSchedule.getExpectedNextExecutionTime(enabledTimeInstance, previousExecutionTimeInstance) + assertNotNull(nextTimeToExecute, "There should be next execute time") + assertEquals( + previousExecutionTimeInstance.plusSeconds(2L), nextTimeToExecute, + "nextTimeToExecute should be 2 seconds after test instance" + ) + } + + @Test + fun `test interval calculates next time to execute using enabled time`() { + // This enabled time represents 2018-09-26 01:59:58 GMT + val enabledTimeInstance = Instant.ofEpochSecond(1537927138L) + // This is 2018-09-26 01:59:59 GMT, which is 61 seconds after enabledTime + val testInstance = Instant.ofEpochSecond(1537927199L) + + val intervalSchedule = IntervalSchedule(1, ChronoUnit.MINUTES, testInstance) + + // The nextTimeToExecute should be 120 seconds after the enabled time + val nextTimeToExecute = intervalSchedule.getExpectedNextExecutionTime(enabledTimeInstance, null) + assertNotNull(nextTimeToExecute, "There should be next execute time") + assertEquals( + enabledTimeInstance.plusSeconds(120L), nextTimeToExecute, + "nextTimeToExecute should be 120 seconds seconds after enabled time" + ) + } + + @Test + fun `test interval calculates next time to execute using cached previous time`() { + // This is 2018-09-26 01:59:58 GMT + val previousExecutionTimeInstance = Instant.ofEpochSecond(1537927198L) + // This is 2018-09-26 02:00:00 GMT + val testInstance = Instant.ofEpochSecond(1537927200L) + // This enabled time represents 2018-09-26 01:58:58 GMT + val enabledTimeInstance = Instant.ofEpochSecond(1537927138L) + + val intervalSchedule = IntervalSchedule(1, ChronoUnit.MINUTES, testInstance) + + // The nextTimeToExecute should be the minute after the previous execution time instance + val nextTimeToExecute = intervalSchedule.getExpectedNextExecutionTime(enabledTimeInstance, previousExecutionTimeInstance) + assertNotNull(nextTimeToExecute, "There should be next execute time") + assertEquals( + previousExecutionTimeInstance.plusSeconds(60L), nextTimeToExecute, + "nextTimeToExecute should be 60 seconds after previous execution time" + ) + } + + @Test + fun `test cron schedule round trip`() { + val cronExpression = "0 * * * *" + val cronSchedule = CronSchedule(cronExpression, ZoneId.of("Asia/Tokyo")) + + val scheduleString = cronSchedule.toXContent(builder(), ToXContent.EMPTY_PARAMS).string() + val parsedSchedule = Schedule.parse(parser(scheduleString)) + + assertTrue(parsedSchedule is CronSchedule, "Parsed scheduled is not Cron Scheduled Type.") + assertEquals(cronSchedule, parsedSchedule, "Round tripping Cron Schedule doesn't work") + } + + @Test + fun `test interval schedule round trip`() { + val intervalSchedule = IntervalSchedule(1, ChronoUnit.MINUTES) + + val scheduleString = intervalSchedule.toXContent(builder(), ToXContent.EMPTY_PARAMS).string() + val parsedSchedule = Schedule.parse(parser(scheduleString)) + assertTrue(parsedSchedule is IntervalSchedule, "Parsed scheduled is not Interval Scheduled Type.") + assertEquals(intervalSchedule, parsedSchedule, "Round tripping Interval Schedule doesn't work") + } + + @Test + fun `test cron invalid missing timezone`() { + val scheduleString = "{\"cron\":{\"expression\":\"0 * * * *\"}}" + assertFailsWith(IllegalArgumentException::class, "Expected IllegalArgumentException") { + Schedule.parse(parser(scheduleString)) + } + } + + @Test + fun `test cron invalid timezone rule`() { + val scheduleString = "{\"cron\":{\"expression\":\"0 * * * *\",\"timezone\":\"Going/Nowhere\"}}" + assertFailsWith(IllegalArgumentException::class, "Expected IllegalArgumentException") { + Schedule.parse(parser(scheduleString)) + } + } + + @Test + fun `test cron invalid timezone offset`() { + val scheduleString = "{\"cron\":{\"expression\":\"0 * * * *\",\"timezone\":\"+++9\"}}" + assertFailsWith(IllegalArgumentException::class, "Expected IllegalArgumentException") { + Schedule.parse(parser(scheduleString)) + } + } + + @Test + fun `test invalid type`() { + val scheduleString = "{\"foobarzzz\":{\"expression\":\"0 * * * *\",\"timezone\":\"+++9\"}}" + assertFailsWith(IllegalArgumentException::class, "Expected IllegalArgumentException") { Schedule.parse(parser(scheduleString)) } + } + + @Test + fun `test two types`() { + val scheduleString = "{\"cron\":{\"expression\":\"0 * * * *\",\"timezone\":\"Asia/Tokyo\"}, \"period\":{\"interval\":\"1\",\"unit\":\"Minutes\"}}" + assertFailsWith(IllegalArgumentException::class, "Expected IllegalArgumentException") { + Schedule.parse(parser(scheduleString)) + } + } + + @Test + fun `test invalid cron expression`() { + val scheduleString = "{\"cron\":{\"expression\":\"5 * 1 * * *\",\"timezone\":\"Asia/Tokyo\"}}" + assertFailsWith(IllegalArgumentException::class, "Expected IllegalArgumentException") { + Schedule.parse(parser(scheduleString)) + } + } + + @Test + fun `test interval period starting at`() { + val intervalSchedule = IntervalSchedule(1, ChronoUnit.MINUTES) + + val (periodStartTime, periodEndTime) = intervalSchedule.getPeriodStartingAt(null) + + assertEquals(periodStartTime, periodEndTime.minus(1, ChronoUnit.MINUTES), "Period didn't match interval") + + val startTime = Instant.now() + // Kotlin has destructuring declarations but no destructuring assignments? Gee, thanks... + val (periodStartTime2, _) = intervalSchedule.getPeriodStartingAt(startTime) + assertEquals(startTime, periodStartTime2, "Periods doesn't start at provided start time") + } + + @Test + fun `test interval period ending at`() { + val intervalSchedule = IntervalSchedule(1, ChronoUnit.MINUTES) + + val (periodStartTime, periodEndTime) = intervalSchedule.getPeriodEndingAt(null) + + assertEquals(periodStartTime, periodEndTime.minus(1, ChronoUnit.MINUTES), "Period didn't match interval") + + val endTime = Instant.now() + // destructuring declarations but no destructuring assignments? Gee, thanks... https://youtrack.jetbrains.com/issue/KT-11362 + val (_, periodEndTime2) = intervalSchedule.getPeriodEndingAt(endTime) + assertEquals(endTime, periodEndTime2, "Periods doesn't end at provided end time") + } + + @Test + fun `test cron period starting at`() { + val cronSchedule = CronSchedule("0 * * * *", ZoneId.of("Asia/Tokyo")) + + val (startTime1, endTime) = cronSchedule.getPeriodStartingAt(null) + assertTrue(startTime1 <= Instant.now(), "startTime is in future; should be the last execution time") + assertTrue(cronSchedule.executionTime.isMatch(ZonedDateTime.ofInstant(endTime, ZoneId.of("Asia/Tokyo")))) + + val (startTime, _) = cronSchedule.getPeriodStartingAt(endTime) + assertEquals(startTime, endTime, "Subsequent period doesn't start at provided end time") + } + + @Test + fun `test cron period ending at`() { + val cronSchedule = CronSchedule("0 * * * *", ZoneId.of("Asia/Tokyo")) + + val (startTime, endTime1) = cronSchedule.getPeriodEndingAt(null) + assertTrue(endTime1 >= Instant.now(), "endTime is in past; should be the next execution time") + assertTrue(cronSchedule.executionTime.isMatch(ZonedDateTime.ofInstant(startTime, ZoneId.of("Asia/Tokyo")))) + + val (_, endTime2) = cronSchedule.getPeriodEndingAt(startTime) + assertEquals(endTime2, startTime, "Previous period doesn't end at provided start time") + } + + @Test + fun `cron job not running on time`() { + val cronSchedule = createTestCronSchedule() + + val lastExecutionTime = 1539715560L + assertFalse(cronSchedule.runningOnTime(Instant.ofEpochSecond(lastExecutionTime))) + } + + @Test + fun `cron job running on time`() { + val cronSchedule = createTestCronSchedule() + + val lastExecutionTime = 1539715620L + assertTrue(cronSchedule.runningOnTime(Instant.ofEpochSecond(lastExecutionTime))) + } + + @Test + fun `period job running exactly at interval`() { + val testInstance = Instant.ofEpochSecond(1539715678L) + val enabledTime = Instant.ofEpochSecond(1539615178L) + val intervalSchedule = IntervalSchedule(1, ChronoUnit.MINUTES, testInstance) + + val nextTimeToExecute = intervalSchedule.nextTimeToExecute(enabledTime) + assertNotNull(nextTimeToExecute, "There should be next execute time.") + assertEquals(60L, nextTimeToExecute.seconds, "Excepted 60 seconds but was ${nextTimeToExecute.seconds}") + } + + @Test + fun `period job 3 minutes`() { + val testInstance = Instant.ofEpochSecond(1539615226L) + val enabledTime = Instant.ofEpochSecond(1539615144L) + val intervalSchedule = IntervalSchedule(3, ChronoUnit.MINUTES, testInstance) + + val nextTimeToExecute = intervalSchedule.nextTimeToExecute(enabledTime) + assertNotNull(nextTimeToExecute, "There should be next execute time.") + assertEquals(98L, nextTimeToExecute.seconds, "Excepted 98 seconds but was ${nextTimeToExecute.seconds}") + } + + @Test + fun `period job running on time`() { + val intervalSchedule = createTestIntervalSchedule() + + val lastExecutionTime = 1539715620L + assertTrue(intervalSchedule.runningOnTime(Instant.ofEpochSecond(lastExecutionTime))) + } + + @Test + fun `period job not running on time`() { + val intervalSchedule = createTestIntervalSchedule() + + val lastExecutionTime = 1539715560L + assertFalse(intervalSchedule.runningOnTime(Instant.ofEpochSecond(lastExecutionTime))) + } + + @Test + fun `period job test null last execution time`() { + val intervalSchedule = createTestIntervalSchedule() + + assertTrue(intervalSchedule.runningOnTime(null)) + } + + private fun createTestIntervalSchedule(): IntervalSchedule { + val testInstance = Instant.ofEpochSecond(1539715678L) + val enabledTime = Instant.ofEpochSecond(1539615146L) + val intervalSchedule = IntervalSchedule(1, ChronoUnit.MINUTES, testInstance) + + val nextTimeToExecute = intervalSchedule.nextTimeToExecute(enabledTime) + assertNotNull(nextTimeToExecute, "There should be next execute time.") + assertEquals(28L, nextTimeToExecute.seconds, "Excepted 28 seconds but was ${nextTimeToExecute.seconds}") + + return intervalSchedule + } + + private fun createTestCronSchedule(): CronSchedule { + val cronExpression = "* * * * *" + val testInstance = Instant.ofEpochSecond(1539715678L) + + val cronSchedule = CronSchedule(cronExpression, ZoneId.of("UTC"), testInstance) + val nextTimeToExecute = cronSchedule.nextTimeToExecute(Instant.now()) + assertNotNull(nextTimeToExecute, "There should be next execute time.") + assertEquals(2L, nextTimeToExecute.seconds, "Execute time should be 2 seconds") + + return cronSchedule + } + + @Test + fun `test invalid interval units`() { + assertFailsWith(IllegalArgumentException::class, "Expected IllegalArgumentException") { + IntervalSchedule(1, ChronoUnit.SECONDS) + } + + assertFailsWith(IllegalArgumentException::class, "Expected IllegalArgumentException") { + IntervalSchedule(1, ChronoUnit.MONTHS) + } + + assertFailsWith(IllegalArgumentException::class, "Expected IllegalArgumentException") { + IntervalSchedule(-1, ChronoUnit.MINUTES) + } + } +} diff --git a/src/test/kotlin/org/opensearch/commons/alerting/model/WriteableTests.kt b/src/test/kotlin/org/opensearch/commons/alerting/model/WriteableTests.kt new file mode 100644 index 00000000..9f5e26b9 --- /dev/null +++ b/src/test/kotlin/org/opensearch/commons/alerting/model/WriteableTests.kt @@ -0,0 +1,157 @@ +package org.opensearch.commons.alerting.model + +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test +import org.opensearch.common.io.stream.BytesStreamOutput +import org.opensearch.common.io.stream.StreamInput +import org.opensearch.commons.alerting.model.action.Action +import org.opensearch.commons.alerting.model.action.ActionExecutionPolicy +import org.opensearch.commons.alerting.model.action.Throttle +import org.opensearch.commons.alerting.randomAction +import org.opensearch.commons.alerting.randomActionExecutionPolicy +import org.opensearch.commons.alerting.randomBucketLevelTrigger +import org.opensearch.commons.alerting.randomDocumentLevelTrigger +import org.opensearch.commons.alerting.randomQueryLevelMonitor +import org.opensearch.commons.alerting.randomQueryLevelTrigger +import org.opensearch.commons.alerting.randomThrottle +import org.opensearch.commons.alerting.randomUser +import org.opensearch.commons.alerting.randomUserEmpty +import org.opensearch.commons.authuser.User +import org.opensearch.search.builder.SearchSourceBuilder + +class WriteableTests { + + @Test + fun `test throttle as stream`() { + val throttle = randomThrottle() + val out = BytesStreamOutput() + throttle.writeTo(out) + val sin = StreamInput.wrap(out.bytes().toBytesRef().bytes) + val newThrottle = Throttle(sin) + Assertions.assertEquals(throttle, newThrottle, "Round tripping Throttle doesn't work") + } + + @Test + fun `test action as stream`() { + val action = randomAction() + val out = BytesStreamOutput() + action.writeTo(out) + val sin = StreamInput.wrap(out.bytes().toBytesRef().bytes) + val newAction = Action(sin) + Assertions.assertEquals(action, newAction, "Round tripping Action doesn't work") + } + + @Test + fun `test action as stream with null subject template`() { + val action = randomAction().copy(subjectTemplate = null) + val out = BytesStreamOutput() + action.writeTo(out) + val sin = StreamInput.wrap(out.bytes().toBytesRef().bytes) + val newAction = Action(sin) + Assertions.assertEquals(action, newAction, "Round tripping Action doesn't work") + } + + @Test + fun `test action as stream with null throttle`() { + val action = randomAction().copy(throttle = null) + val out = BytesStreamOutput() + action.writeTo(out) + val sin = StreamInput.wrap(out.bytes().toBytesRef().bytes) + val newAction = Action(sin) + Assertions.assertEquals(action, newAction, "Round tripping Action doesn't work") + } + + @Test + fun `test action as stream with throttled enabled and null throttle`() { + val action = randomAction().copy(throttle = null).copy(throttleEnabled = true) + val out = BytesStreamOutput() + action.writeTo(out) + val sin = StreamInput.wrap(out.bytes().toBytesRef().bytes) + val newAction = Action(sin) + Assertions.assertEquals(action, newAction, "Round tripping Action doesn't work") + } + + @Test + fun `test query-level monitor as stream`() { + val monitor = randomQueryLevelMonitor().copy(inputs = listOf(SearchInput(emptyList(), SearchSourceBuilder()))) + val out = BytesStreamOutput() + monitor.writeTo(out) + val sin = StreamInput.wrap(out.bytes().toBytesRef().bytes) + val newMonitor = Monitor(sin) + Assertions.assertEquals(monitor, newMonitor, "Round tripping QueryLevelMonitor doesn't work") + } + + @Test + fun `test query-level trigger as stream`() { + val trigger = randomQueryLevelTrigger() + val out = BytesStreamOutput() + trigger.writeTo(out) + val sin = StreamInput.wrap(out.bytes().toBytesRef().bytes) + val newTrigger = QueryLevelTrigger.readFrom(sin) + Assertions.assertEquals(trigger, newTrigger, "Round tripping QueryLevelTrigger doesn't work") + } + + @Test + fun `test bucket-level trigger as stream`() { + val trigger = randomBucketLevelTrigger() + val out = BytesStreamOutput() + trigger.writeTo(out) + val sin = StreamInput.wrap(out.bytes().toBytesRef().bytes) + val newTrigger = BucketLevelTrigger.readFrom(sin) + Assertions.assertEquals(trigger, newTrigger, "Round tripping BucketLevelTrigger doesn't work") + } + + @Test + fun `test doc-level trigger as stream`() { + val trigger = randomDocumentLevelTrigger() + val out = BytesStreamOutput() + trigger.writeTo(out) + val sin = StreamInput.wrap(out.bytes().toBytesRef().bytes) + val newTrigger = DocumentLevelTrigger.readFrom(sin) + Assertions.assertEquals(trigger, newTrigger, "Round tripping DocumentLevelTrigger doesn't work") + } + + @Test + fun `test searchinput as stream`() { + val input = SearchInput(emptyList(), SearchSourceBuilder()) + val out = BytesStreamOutput() + input.writeTo(out) + val sin = StreamInput.wrap(out.bytes().toBytesRef().bytes) + val newInput = SearchInput(sin) + Assertions.assertEquals(input, newInput, "Round tripping MonitorRunResult doesn't work") + } + + @Test + fun `test user as stream`() { + val user = randomUser() + val out = BytesStreamOutput() + user.writeTo(out) + val sin = StreamInput.wrap(out.bytes().toBytesRef().bytes) + val newUser = User(sin) + Assertions.assertEquals(user, newUser, "Round tripping User doesn't work") + } + + @Test + fun `test empty user as stream`() { + val user = randomUserEmpty() + val out = BytesStreamOutput() + user.writeTo(out) + val sin = StreamInput.wrap(out.bytes().toBytesRef().bytes) + val newUser = User(sin) + Assertions.assertEquals(user, newUser, "Round tripping User doesn't work") + } + + @Test + fun `test action execution policy as stream`() { + val actionExecutionPolicy = randomActionExecutionPolicy() + val out = BytesStreamOutput() + actionExecutionPolicy.writeTo(out) + val sin = StreamInput.wrap(out.bytes().toBytesRef().bytes) + val newActionExecutionPolicy = ActionExecutionPolicy.readFrom(sin) + Assertions.assertEquals( + actionExecutionPolicy, + newActionExecutionPolicy, + "Round tripping ActionExecutionPolicy doesn't work" + ) + } +} diff --git a/src/test/kotlin/org/opensearch/commons/alerting/model/XContentTestBase.kt b/src/test/kotlin/org/opensearch/commons/alerting/model/XContentTestBase.kt new file mode 100644 index 00000000..45ef0d52 --- /dev/null +++ b/src/test/kotlin/org/opensearch/commons/alerting/model/XContentTestBase.kt @@ -0,0 +1,27 @@ +package org.opensearch.commons.alerting.model + +import org.opensearch.common.settings.Settings +import org.opensearch.common.xcontent.LoggingDeprecationHandler +import org.opensearch.common.xcontent.NamedXContentRegistry +import org.opensearch.common.xcontent.XContentBuilder +import org.opensearch.common.xcontent.XContentParser +import org.opensearch.common.xcontent.XContentType +import org.opensearch.search.SearchModule + +interface XContentTestBase { + fun builder(): XContentBuilder { + return XContentBuilder.builder(XContentType.JSON.xContent()) + } + + fun parser(xc: String): XContentParser { + val parser = XContentType.JSON.xContent().createParser(xContentRegistry(), LoggingDeprecationHandler.INSTANCE, xc) + parser.nextToken() + return parser + } + + fun xContentRegistry(): NamedXContentRegistry { + return NamedXContentRegistry( + listOf(SearchInput.XCONTENT_REGISTRY) + SearchModule(Settings.EMPTY, emptyList()).namedXContents + ) + } +} diff --git a/src/test/kotlin/org/opensearch/commons/alerting/model/XContentTests.kt b/src/test/kotlin/org/opensearch/commons/alerting/model/XContentTests.kt new file mode 100644 index 00000000..520a9d3c --- /dev/null +++ b/src/test/kotlin/org/opensearch/commons/alerting/model/XContentTests.kt @@ -0,0 +1,346 @@ +package org.opensearch.commons.alerting.model + +import org.junit.Assert.assertEquals +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test +import org.opensearch.common.xcontent.ToXContent +import org.opensearch.commons.alerting.builder +import org.opensearch.commons.alerting.model.action.Action +import org.opensearch.commons.alerting.model.action.ActionExecutionPolicy +import org.opensearch.commons.alerting.model.action.PerExecutionActionScope +import org.opensearch.commons.alerting.model.action.Throttle +import org.opensearch.commons.alerting.parser +import org.opensearch.commons.alerting.randomAction +import org.opensearch.commons.alerting.randomActionExecutionPolicy +import org.opensearch.commons.alerting.randomActionWithPolicy +import org.opensearch.commons.alerting.randomBucketLevelMonitor +import org.opensearch.commons.alerting.randomBucketLevelTrigger +import org.opensearch.commons.alerting.randomQueryLevelMonitor +import org.opensearch.commons.alerting.randomQueryLevelMonitorWithoutUser +import org.opensearch.commons.alerting.randomQueryLevelTrigger +import org.opensearch.commons.alerting.randomThrottle +import org.opensearch.commons.alerting.randomUser +import org.opensearch.commons.alerting.randomUserEmpty +import org.opensearch.commons.alerting.toJsonString +import org.opensearch.commons.alerting.toJsonStringWithUser +import org.opensearch.commons.alerting.util.string +import org.opensearch.commons.authuser.User +import org.opensearch.index.query.QueryBuilders +import org.opensearch.search.builder.SearchSourceBuilder +import java.time.temporal.ChronoUnit +import kotlin.test.assertFailsWith + +class XContentTests { + + @Test + fun `test action parsing`() { + val action = randomAction() + val actionString = action.toXContent(builder(), ToXContent.EMPTY_PARAMS).string() + val parsedAction = Action.parse(parser(actionString)) + Assertions.assertEquals(action, parsedAction, "Round tripping Action doesn't work") + } + + @Test + fun `test action parsing with null subject template`() { + val action = randomAction().copy(subjectTemplate = null) + val actionString = action.toXContent(builder(), ToXContent.EMPTY_PARAMS).string() + val parsedAction = Action.parse(parser(actionString)) + Assertions.assertEquals(action, parsedAction, "Round tripping Action doesn't work") + } + + @Test + fun `test action parsing with null throttle`() { + val action = randomAction().copy(throttle = null) + val actionString = action.toXContent(builder(), ToXContent.EMPTY_PARAMS).string() + val parsedAction = Action.parse(parser(actionString)) + Assertions.assertEquals(action, parsedAction, "Round tripping Action doesn't work") + } + + fun `test action parsing with throttled enabled and null throttle`() { + val action = randomAction().copy(throttle = null).copy(throttleEnabled = true) + val actionString = action.toXContent(builder(), ToXContent.EMPTY_PARAMS).string() + assertFailsWith("Action throttle enabled but not set throttle value") { + Action.parse(parser(actionString)) + } + } + + @Test + fun `test action with per execution scope does not support throttling`() { + try { + randomActionWithPolicy().copy( + throttleEnabled = true, + throttle = Throttle(value = 5, unit = ChronoUnit.MINUTES), + actionExecutionPolicy = ActionExecutionPolicy(PerExecutionActionScope()) + ) + Assertions.fail("Creating an action with per execution scope and throttle enabled did not fail.") + } catch (ignored: IllegalArgumentException) { + } + } + + @Test + fun `test throttle parsing`() { + val throttle = randomThrottle() + val throttleString = throttle.toXContent(builder(), ToXContent.EMPTY_PARAMS).string() + val parsedThrottle = Throttle.parse(parser(throttleString)) + Assertions.assertEquals(throttle, parsedThrottle, "Round tripping Monitor doesn't work") + } + + @Test + fun `test throttle parsing with wrong unit`() { + val throttle = randomThrottle() + val throttleString = throttle.toXContent(builder(), ToXContent.EMPTY_PARAMS).string() + val wrongThrottleString = throttleString.replace("MINUTES", "wrongunit") + + assertFailsWith("Only support MINUTES throttle unit") { Throttle.parse(parser(wrongThrottleString)) } + } + + @Test + fun `test throttle parsing with negative value`() { + val throttle = randomThrottle().copy(value = -1) + val throttleString = throttle.toXContent(builder(), ToXContent.EMPTY_PARAMS).string() + + assertFailsWith("Can only set positive throttle period") { Throttle.parse(parser(throttleString)) } + } + + fun `test query-level monitor parsing`() { + val monitor = randomQueryLevelMonitor() + + val monitorString = monitor.toJsonStringWithUser() + val parsedMonitor = Monitor.parse(parser(monitorString)) + assertEquals("Round tripping QueryLevelMonitor doesn't work", monitor, parsedMonitor) + } + + @Test + fun `test monitor parsing with no name`() { + val monitorStringWithoutName = """ + { + "type": "monitor", + "enabled": false, + "schedule": { + "period": { + "interval": 1, + "unit": "MINUTES" + } + }, + "inputs": [], + "triggers": [] + } + """.trimIndent() + + assertFailsWith("Monitor name is null") { Monitor.parse(parser(monitorStringWithoutName)) } + } + + @Test + fun `test monitor parsing with no schedule`() { + val monitorStringWithoutSchedule = """ + { + "type": "monitor", + "name": "asdf", + "enabled": false, + "inputs": [], + "triggers": [] + } + """.trimIndent() + + assertFailsWith("Monitor schedule is null") { + Monitor.parse(parser(monitorStringWithoutSchedule)) + } + } + + @Test + fun `test bucket-level monitor parsing`() { + val monitor = randomBucketLevelMonitor() + + val monitorString = monitor.toJsonStringWithUser() + val parsedMonitor = Monitor.parse(parser(monitorString)) + Assertions.assertEquals(monitor, parsedMonitor, "Round tripping BucketLevelMonitor doesn't work") + } + + @Test + fun `test query-level trigger parsing`() { + val trigger = randomQueryLevelTrigger() + + val triggerString = trigger.toXContent(builder(), ToXContent.EMPTY_PARAMS).string() + val parsedTrigger = Trigger.parse(parser(triggerString)) + + Assertions.assertEquals(trigger, parsedTrigger, "Round tripping QueryLevelTrigger doesn't work") + } + + @Test + fun `test bucket-level trigger parsing`() { + val trigger = randomBucketLevelTrigger() + + val triggerString = trigger.toXContent(builder(), ToXContent.EMPTY_PARAMS).string() + val parsedTrigger = Trigger.parse(parser(triggerString)) + + Assertions.assertEquals(trigger, parsedTrigger, "Round tripping BucketLevelTrigger doesn't work") + } + + @Test + fun `test creating a monitor with duplicate trigger ids fails`() { + try { + val repeatedTrigger = randomQueryLevelTrigger() + randomQueryLevelMonitor().copy(triggers = listOf(repeatedTrigger, repeatedTrigger)) + Assertions.fail("Creating a monitor with duplicate triggers did not fail.") + } catch (ignored: IllegalArgumentException) { + } + } + + @Test + fun `test user parsing`() { + val user = randomUser() + val userString = user.toXContent(builder(), ToXContent.EMPTY_PARAMS).string() + val parsedUser = User.parse(parser(userString)) + Assertions.assertEquals(user, parsedUser, "Round tripping user doesn't work") + } + + @Test + fun `test empty user parsing`() { + val user = randomUserEmpty() + val userString = user.toXContent(builder(), ToXContent.EMPTY_PARAMS).string() + + val parsedUser = User.parse(parser(userString)) + Assertions.assertEquals(user, parsedUser, "Round tripping user doesn't work") + Assertions.assertEquals("", parsedUser.name) + Assertions.assertEquals(0, parsedUser.roles.size) + } + + @Test + fun `test query-level monitor parsing without user`() { + val monitor = randomQueryLevelMonitorWithoutUser() + + val monitorString = monitor.toJsonString() + val parsedMonitor = Monitor.parse(parser(monitorString)) + Assertions.assertEquals(monitor, parsedMonitor, "Round tripping QueryLevelMonitor doesn't work") + Assertions.assertNull(parsedMonitor.user) + } + + @Test + fun `test old monitor format parsing`() { + val monitorString = """ + { + "type": "monitor", + "schema_version": 3, + "name": "asdf", + "user": { + "name": "admin123", + "backend_roles": [], + "roles": [ + "all_access", + "security_manager" + ], + "custom_attribute_names": [], + "user_requested_tenant": null + }, + "enabled": true, + "enabled_time": 1613530078244, + "schedule": { + "period": { + "interval": 1, + "unit": "MINUTES" + } + }, + "inputs": [ + { + "search": { + "indices": [ + "test_index" + ], + "query": { + "size": 0, + "query": { + "bool": { + "filter": [ + { + "range": { + "order_date": { + "from": "{{period_end}}||-1h", + "to": "{{period_end}}", + "include_lower": true, + "include_upper": true, + "format": "epoch_millis", + "boost": 1.0 + } + } + } + ], + "adjust_pure_negative": true, + "boost": 1.0 + } + }, + "aggregations": {} + } + } + } + ], + "triggers": [ + { + "id": "e_sc0XcB98Q42rHjTh4K", + "name": "abc", + "severity": "1", + "condition": { + "script": { + "source": "ctx.results[0].hits.total.value > 100000", + "lang": "painless" + } + }, + "actions": [] + } + ], + "last_update_time": 1614121489719 + } + """.trimIndent() + val parsedMonitor = Monitor.parse(parser(monitorString)) + Assertions.assertEquals( + Monitor.MonitorType.QUERY_LEVEL_MONITOR, + parsedMonitor.monitorType, + "Incorrect monitor type" + ) + Assertions.assertEquals(1, parsedMonitor.triggers.size, "Incorrect trigger count") + val trigger = parsedMonitor.triggers.first() + Assertions.assertTrue(trigger is QueryLevelTrigger, "Incorrect trigger type") + Assertions.assertEquals("abc", trigger.name, "Incorrect name for parsed trigger") + } + + @Test + fun `test creating an query-level monitor with invalid trigger type fails`() { + try { + val bucketLevelTrigger = randomBucketLevelTrigger() + randomQueryLevelMonitor().copy(triggers = listOf(bucketLevelTrigger)) + Assertions.fail("Creating a query-level monitor with bucket-level triggers did not fail.") + } catch (ignored: IllegalArgumentException) { + } + } + + @Test + fun `test creating an bucket-level monitor with invalid trigger type fails`() { + try { + val queryLevelTrigger = randomQueryLevelTrigger() + randomBucketLevelMonitor().copy(triggers = listOf(queryLevelTrigger)) + Assertions.fail("Creating a bucket-level monitor with query-level triggers did not fail.") + } catch (ignored: IllegalArgumentException) { + } + } + + @Test + fun `test creating an bucket-level monitor with invalid input fails`() { + try { + val invalidInput = SearchInput(emptyList(), SearchSourceBuilder().query(QueryBuilders.matchAllQuery())) + randomBucketLevelMonitor().copy(inputs = listOf(invalidInput)) + Assertions.fail("Creating an bucket-level monitor with an invalid input did not fail.") + } catch (ignored: IllegalArgumentException) { + } + } + + @Test + fun `test action execution policy`() { + val actionExecutionPolicy = randomActionExecutionPolicy() + val actionExecutionPolicyString = actionExecutionPolicy.toXContent(builder(), ToXContent.EMPTY_PARAMS).string() + val parsedActionExecutionPolicy = ActionExecutionPolicy.parse(parser(actionExecutionPolicyString)) + Assertions.assertEquals( + actionExecutionPolicy, + parsedActionExecutionPolicy, + "Round tripping ActionExecutionPolicy doesn't work" + ) + } +}