diff --git a/.github/workflows/notifications-test-and-build-workflow.yml b/.github/workflows/notifications-test-and-build-workflow.yml index e06126dd..3b3f1a0c 100644 --- a/.github/workflows/notifications-test-and-build-workflow.yml +++ b/.github/workflows/notifications-test-and-build-workflow.yml @@ -26,8 +26,9 @@ name: Test and Build Notifications on: [push, pull_request] env: - OPENSEARCH_VERSION: '1.0' - COMMON_UTILS_VERSION: '1.0' + OPENSEARCH_VERSION: '1.1.0-SNAPSHOT' + OPENSEARCH_BRANCH: '1.x' + COMMON_UTILS_BRANCH: 'main' jobs: build: @@ -45,30 +46,31 @@ jobs: with: repository: 'opensearch-project/OpenSearch' path: OpenSearch - ref: ${{ env.OPENSEARCH_VERSION }} + ref: ${{ env.OPENSEARCH_BRANCH }} - name: Build OpenSearch working-directory: ./OpenSearch - run: ./gradlew publishToMavenLocal -Dbuild.snapshot=false + run: ./gradlew publishToMavenLocal # dependencies: common-utils - name: Checkout common-utils uses: actions/checkout@v2 with: repository: 'opensearch-project/common-utils' - ref: ${{ env.COMMON_UTILS_VERSION }} + ref: ${{ env.COMMON_UTILS_BRANCH }} path: common-utils - name: Build common-utils working-directory: ./common-utils - run: ./gradlew publishToMavenLocal -Dopensearch.version=${{ env.OPENSEARCH_VERSION }}.0 + run: ./gradlew publishToMavenLocal -Dopensearch.version=${{ env.OPENSEARCH_VERSION }} # notifications - name: Checkout Notifications uses: actions/checkout@v2 + # Temporarily exclude tests which causing CI to fail. Tracking in #251 - name: Build with Gradle run: | cd notifications - ./gradlew build -PexcludeTests="**/SesChannelIT*" -Dopensearch.version=${{ env.OPENSEARCH_VERSION }}.0 + ./gradlew build -PexcludeTests="**/SesChannelIT*" -Dopensearch.version=${{ env.OPENSEARCH_VERSION }} - name: Upload coverage uses: codecov/codecov-action@v1 diff --git a/dashboards-notifications/models/interfaces.ts b/dashboards-notifications/models/interfaces.ts index c78f1539..2a057bd1 100644 --- a/dashboards-notifications/models/interfaces.ts +++ b/dashboards-notifications/models/interfaces.ts @@ -88,6 +88,10 @@ export interface ChannelItemType extends ConfigType { [id: string]: string; }; }; + sns?: { + topic_arn: string; + role_arn?: string; + } } interface ConfigType { diff --git a/dashboards-notifications/public/pages/Channels/__tests__/__snapshots__/DetailsListModal.test.tsx.snap b/dashboards-notifications/public/pages/Channels/__tests__/__snapshots__/DetailsListModal.test.tsx.snap index d26df11d..e20292c3 100644 --- a/dashboards-notifications/public/pages/Channels/__tests__/__snapshots__/DetailsListModal.test.tsx.snap +++ b/dashboards-notifications/public/pages/Channels/__tests__/__snapshots__/DetailsListModal.test.tsx.snap @@ -28,7 +28,6 @@ exports[` spec renders the component 1`] = ` "notificationService": NotificationService { "createConfig": [Function], "deleteConfigs": [Function], - "getAvailableFeatures": [Function], "getChannel": [Function], "getChannels": [Function], "getConfig": [Function], @@ -38,6 +37,7 @@ exports[` spec renders the component 1`] = ` "getRecipientGroups": [Function], "getSender": [Function], "getSenders": [Function], + "getServerFeatures": [Function], "httpClient": [MockFunction], "updateConfig": [Function], }, diff --git a/dashboards-notifications/public/pages/Channels/__tests__/__snapshots__/DetailsTableModal.test.tsx.snap b/dashboards-notifications/public/pages/Channels/__tests__/__snapshots__/DetailsTableModal.test.tsx.snap index a7c19c56..41c4c804 100644 --- a/dashboards-notifications/public/pages/Channels/__tests__/__snapshots__/DetailsTableModal.test.tsx.snap +++ b/dashboards-notifications/public/pages/Channels/__tests__/__snapshots__/DetailsTableModal.test.tsx.snap @@ -29,7 +29,6 @@ exports[` spec renders headers 1`] = ` "notificationService": NotificationService { "createConfig": [Function], "deleteConfigs": [Function], - "getAvailableFeatures": [Function], "getChannel": [Function], "getChannels": [Function], "getConfig": [Function], @@ -39,6 +38,7 @@ exports[` spec renders headers 1`] = ` "getRecipientGroups": [Function], "getSender": [Function], "getSenders": [Function], + "getServerFeatures": [Function], "httpClient": [MockFunction], "updateConfig": [Function], }, @@ -1862,7 +1862,6 @@ exports[` spec renders parameters 1`] = ` "notificationService": NotificationService { "createConfig": [Function], "deleteConfigs": [Function], - "getAvailableFeatures": [Function], "getChannel": [Function], "getChannels": [Function], "getConfig": [Function], @@ -1872,6 +1871,7 @@ exports[` spec renders parameters 1`] = ` "getRecipientGroups": [Function], "getSender": [Function], "getSenders": [Function], + "getServerFeatures": [Function], "httpClient": [MockFunction], "updateConfig": [Function], }, diff --git a/dashboards-notifications/public/pages/Channels/components/details/ChannelSettingsDetails.tsx b/dashboards-notifications/public/pages/Channels/components/details/ChannelSettingsDetails.tsx index 81c6bd9f..a89ff49a 100644 --- a/dashboards-notifications/public/pages/Channels/components/details/ChannelSettingsDetails.tsx +++ b/dashboards-notifications/public/pages/Channels/components/details/ChannelSettingsDetails.tsx @@ -143,19 +143,6 @@ export function ChannelSettingsDetails(props: ChannelSettingsDetailsProps) { title: 'Default recipients', description: recipientsDescription, }, - // TODO remove when removing header/footer functionality - // { - // title: 'Email header', - // description: props.channel.destination.email.header - // ? 'Enabled' - // : 'Disabled', - // }, - // { - // title: 'Email footer', - // description: props.channel.destination.email.footer - // ? 'Enabled' - // : 'Disabled', - // }, ] ); } else if (type === BACKEND_CHANNEL_TYPE.CUSTOM_WEBHOOK) { @@ -203,22 +190,22 @@ export function ChannelSettingsDetails(props: ChannelSettingsDetailsProps) { ] ); } else if (type === BACKEND_CHANNEL_TYPE.SNS) { - // settingsList.push( - // ...[ - // { - // title: 'Channel type', - // description: CHANNEL_TYPE.SNS, - // }, - // { - // title: 'SNS topic ARN', - // description: props.channel.destination.sns.topic_arn || '-', - // }, - // { - // title: 'IAM role ARN', - // description: props.channel.destination.sns.role_arn || '-', - // }, - // ] - // ); + settingsList.push( + ...[ + { + title: 'Channel type', + description: CHANNEL_TYPE.sns, + }, + { + title: 'SNS topic ARN', + description: props.channel.sns?.topic_arn || '-', + }, + { + title: 'IAM role ARN', + description: props.channel.sns?.role_arn || '-', + }, + ] + ); } else if (type === BACKEND_CHANNEL_TYPE.SES) { // TODO } diff --git a/dashboards-notifications/public/pages/CreateChannel/CreateChannel.tsx b/dashboards-notifications/public/pages/CreateChannel/CreateChannel.tsx index 7130e1e6..d39c6027 100644 --- a/dashboards-notifications/public/pages/CreateChannel/CreateChannel.tsx +++ b/dashboards-notifications/public/pages/CreateChannel/CreateChannel.tsx @@ -90,8 +90,6 @@ export const CreateChannelContext = createContext<{ } | null>(null); export function CreateChannel(props: CreateChannelsProps) { - const isOdfe = true; - const coreContext = useContext(CoreServicesContext)!; const servicesContext = useContext(ServicesContext)!; const mainStateContext = useContext(MainContext)!; @@ -226,7 +224,8 @@ export function CreateChannel(props: CreateChannelsProps) { } else if (type === BACKEND_CHANNEL_TYPE.SES) { // TODO } else if (type === BACKEND_CHANNEL_TYPE.SNS) { - // TODO + setTopicArn(response.sns?.topic_arn || ''); + setRoleArn(response.sns?.role_arn || ''); } } catch (error) { coreContext.notifications.toasts.addDanger( @@ -265,7 +264,7 @@ export function CreateChannel(props: CreateChannelsProps) { } } else if (channelType === BACKEND_CHANNEL_TYPE.SNS) { errors.topicArn = validateArn(topicArn); - if (!isOdfe) errors.roleArn = validateArn(roleArn); + if (!mainStateContext.tooltipSupport) errors.roleArn = validateArn(roleArn); } setInputErrors(errors); return !Object.values(errors).reduce( @@ -303,6 +302,13 @@ export function CreateChannel(props: CreateChannelsProps) { selectedSenderOptions, selectedRecipientGroupOptions ); + } else if (channelType === BACKEND_CHANNEL_TYPE.SES) { + // TODO + } else if (channelType === BACKEND_CHANNEL_TYPE.SNS) { + config.sns = { + topic_arn: topicArn, + ...(roleArn && { role_arn: roleArn }), + }; } return config; }; @@ -450,7 +456,6 @@ export function CreateChannel(props: CreateChannelsProps) { /> ) : channelType === BACKEND_CHANNEL_TYPE.SNS ? ( { it('validates webhook', () => { const pass = validateWebhookURL('https://test-webhook'); + const httpTest = validateWebhookURL('http://test-webhook'); const emptyInput = validateWebhookURL(''); const invalidURL = validateWebhookURL('hxxp://test-webhook'); expect(pass).toHaveLength(0); + expect(httpTest).toHaveLength(0); expect(emptyInput).toHaveLength(1); expect(invalidURL).toHaveLength(1); }); @@ -69,11 +71,13 @@ describe('test create channel validation helpers', () => { it('validates custom url host', () => { const pass = validateCustomURLHost('test-webhook'); + const httpTest = validateCustomURLHost('http://test-webhook'); + const httpsTest = validateCustomURLHost('https://test-webhook'); const emptyInput = validateCustomURLHost(''); - const invalidURL = validateCustomURLHost('http://test-webhook'); // only https is allowed expect(pass).toHaveLength(0); + expect(httpTest).toHaveLength(0); + expect(httpsTest).toHaveLength(0); expect(emptyInput).toHaveLength(1); - expect(invalidURL).toHaveLength(1); }); it('validates custom url port', () => { diff --git a/dashboards-notifications/public/pages/CreateChannel/components/EmailSettings.tsx b/dashboards-notifications/public/pages/CreateChannel/components/EmailSettings.tsx index e66e0d97..d5a63886 100644 --- a/dashboards-notifications/public/pages/CreateChannel/components/EmailSettings.tsx +++ b/dashboards-notifications/public/pages/CreateChannel/components/EmailSettings.tsx @@ -212,6 +212,10 @@ export function EmailSettings(props: EmailSettingsProps) { ) => { setSenderOptions([...senderOptions, newOption]); props.setSelectedSenderOptions([newOption]); + context.setInputErrors({ + ...context.inputErrors, + sender: validateEmailSender([newOption]), + }); }, }) } @@ -273,6 +277,10 @@ export function EmailSettings(props: EmailSettingsProps) { ...props.selectedRecipientGroupOptions, newOption, ]); + context.setInputErrors({ + ...context.inputErrors, + recipients: validateRecipients([newOption]), + }); }, }) } diff --git a/dashboards-notifications/public/pages/CreateChannel/components/SNSSettings.tsx b/dashboards-notifications/public/pages/CreateChannel/components/SNSSettings.tsx index 1c542bed..c9726b05 100644 --- a/dashboards-notifications/public/pages/CreateChannel/components/SNSSettings.tsx +++ b/dashboards-notifications/public/pages/CreateChannel/components/SNSSettings.tsx @@ -34,11 +34,11 @@ import { } from '@elastic/eui'; import React, { useContext } from 'react'; import { DOCUMENTATION_LINK } from '../../../utils/constants'; +import { MainContext } from '../../Main/Main'; import { CreateChannelContext } from '../CreateChannel'; import { validateArn } from '../utils/validationHelper'; interface SNSSettingsProps { - isOdfe: boolean; topicArn: string; setTopicArn: (topicArn: string) => void; roleArn: string; @@ -47,6 +47,7 @@ interface SNSSettingsProps { export function SNSSettings(props: SNSSettingsProps) { const context = useContext(CreateChannelContext)!; + const mainStateContext = useContext(MainContext)!; return ( <> @@ -69,7 +70,7 @@ export function SNSSettings(props: SNSSettingsProps) { /> - {props.isOdfe ? ( + {mainStateContext.tooltipSupport ? ( <>
- If your cluster is not running on AWS, you must add your access - key, secret key, and optional session token to the OpenSearch - keystore.{' '} + If your cluster is not running on AWS, you must configure aws + credentials on your OpenSearch cluster.{' '} Learn more diff --git a/dashboards-notifications/public/pages/CreateChannel/utils/validationHelper.ts b/dashboards-notifications/public/pages/CreateChannel/utils/validationHelper.ts index 2b6fb5f9..e10c4570 100644 --- a/dashboards-notifications/public/pages/CreateChannel/utils/validationHelper.ts +++ b/dashboards-notifications/public/pages/CreateChannel/utils/validationHelper.ts @@ -36,7 +36,7 @@ export const validateChannelName = (name: string) => { export const validateWebhookURL = (url: string) => { const errors = []; if (url.length === 0) errors.push('Webhook URL cannot be empty.'); - else if (!url.match(/^https:\/\/.+/)) errors.push('Invalid webhook URL.'); + else if (!url.match(/^https?:\/\/.+/)) errors.push('Invalid webhook URL.'); return errors; }; @@ -55,7 +55,6 @@ export const validateWebhookValue = (value: string) => { export const validateCustomURLHost = (host: string) => { const errors = []; if (host.length === 0) errors.push('Host cannot be empty.'); - else if (host.match(/^http:\/\//)) errors.push('Invalid webhook URL.'); return errors; }; diff --git a/dashboards-notifications/public/pages/Main/Main.tsx b/dashboards-notifications/public/pages/Main/Main.tsx index 885de089..fe801405 100644 --- a/dashboards-notifications/public/pages/Main/Main.tsx +++ b/dashboards-notifications/public/pages/Main/Main.tsx @@ -57,6 +57,7 @@ interface MainProps extends RouteComponentProps {} export interface MainState { availableFeatures: Partial; + tooltipSupport: boolean; // if true, IAM role for SNS is optional and helper text should be available } export const MainContext = createContext(null); @@ -68,13 +69,17 @@ export default class Main extends Component { super(props); this.state = { availableFeatures: CHANNEL_TYPE, + tooltipSupport: false, }; } async componentDidMount() { - const availableFeatures = - await this.context.notificationService.getAvailableFeatures(); - if (availableFeatures != null) this.setState({ availableFeatures }); + const serverFeatures = await this.context.notificationService.getServerFeatures(); + if (serverFeatures != null) + this.setState({ + availableFeatures: serverFeatures.availableFeatures, + tooltipSupport: serverFeatures.tooltipSupport, + }); } render() { diff --git a/dashboards-notifications/public/pages/Notifications/containers/Notifications.tsx b/dashboards-notifications/public/pages/Notifications/containers/Notifications.tsx index 963595c7..9dbf3293 100644 --- a/dashboards-notifications/public/pages/Notifications/containers/Notifications.tsx +++ b/dashboards-notifications/public/pages/Notifications/containers/Notifications.tsx @@ -198,13 +198,13 @@ export default class Notifications extends Component< const getNotificationsResponse = await services.eventService.getNotifications( queryObject ); - const getHistogramResponse = await services.eventService.getHistogram( - queryObject - ); + // const getHistogramResponse = await services.eventService.getHistogram( + // queryObject + // ); this.setState({ items: getNotificationsResponse.items, total: getNotificationsResponse.total, - histogramData: getHistogramResponse, + // histogramData: getHistogramResponse, }); } catch (err) { this.context.notifications.toasts.addDanger( diff --git a/dashboards-notifications/public/services/EventService.ts b/dashboards-notifications/public/services/EventService.ts index 8bcfe998..2b858ba1 100644 --- a/dashboards-notifications/public/services/EventService.ts +++ b/dashboards-notifications/public/services/EventService.ts @@ -27,7 +27,6 @@ import { HttpFetchQuery, HttpSetup } from '../../../../src/core/public'; import { NODE_API } from '../../common'; import { NOTIFICATION_SOURCE } from '../utils/constants'; -import { MOCK_GET_HISTOGRAM } from './mockData'; import { eventListToNotifications, eventToNotification } from './utils/helper'; interface EventsResponse { @@ -43,7 +42,8 @@ export default class EventService { } getHistogram = async (queryObject: object) => { - return MOCK_GET_HISTOGRAM(); + // TODO needs backend support + // return MOCK_GET_HISTOGRAM(); }; getNotifications = async (queryObject: HttpFetchQuery) => { diff --git a/dashboards-notifications/public/services/NotificationService.ts b/dashboards-notifications/public/services/NotificationService.ts index 8a4755dd..c0711089 100644 --- a/dashboards-notifications/public/services/NotificationService.ts +++ b/dashboards-notifications/public/services/NotificationService.ts @@ -25,6 +25,7 @@ */ import { SortDirection } from '@elastic/eui'; +import _ from 'lodash'; import { HttpFetchQuery, HttpSetup } from '../../../../src/core/public'; import { NODE_API } from '../../common'; import { @@ -167,20 +168,28 @@ export default class NotificationService { return configToRecipientGroup(response.config_list[0]); }; - getAvailableFeatures = async () => { + getServerFeatures = async () => { try { - const channels = (await this.httpClient - .get(NODE_API.GET_AVAILABLE_FEATURES) - .then((response) => response.config_type_list)) as Array< + const response = await this.httpClient.get( + NODE_API.GET_AVAILABLE_FEATURES + ); + const config_type_list = response.config_type_list as Array< keyof typeof CHANNEL_TYPE >; const channelTypes: Partial = {}; - for (let i = 0; i < channels.length; i++) { - const channel = channels[i]; + for (let i = 0; i < config_type_list.length; i++) { + const channel = config_type_list[i]; if (!CHANNEL_TYPE[channel]) continue; channelTypes[channel] = CHANNEL_TYPE[channel]; } - return channelTypes; + return { + availableFeatures: channelTypes, + tooltipSupport: + _.get(response, [ + 'plugin_features', + 'opensearch.notifications.spi.tooltip_support', + ]) === 'true', + }; } catch (error) { console.error('error fetching available features', error); return null; diff --git a/dashboards-notifications/public/services/mockData.ts b/dashboards-notifications/public/services/mockData.ts deleted file mode 100644 index f7c46860..00000000 --- a/dashboards-notifications/public/services/mockData.ts +++ /dev/null @@ -1,335 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - * - * Modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -import { DataGenerator } from '@elastic/charts'; - -export const MOCK_GET_HISTOGRAM = () => { - const dg = new DataGenerator(); - const n = 10; - const data = dg.generateGroupedSeries(26, n, 'Channel-'); - data[18].y = 20; - data[18 + 26 * 2].y = 30; - for (let channel = 0; channel < n; channel++) { - for (let index = 0; index < data.length / n; index++) { - const i = index + (channel * data.length) / n; - const element = data[i]; - element.y = Math.round(element.y); - element.x = 1618951331 + index * 1000 * 60 * 60; - } - } - return data; -}; - -export const MOCK_RECIPIENT_GROUPS = [ - { - id: '0', - name: 'admin_list', - email: Array.from({ length: 8 }, (v, i) => ({ - email: 'no-reply@company.com', - })), - description: 'Description about this group', - }, - { - id: '1', - name: 'on_call_list', - email: Array.from({ length: 2 }, (v, i) => ({ - email: 'no-reply@company.com', - })), - description: 'Description about this group', - }, - { - id: '2', - name: 'Team2', - email: Array.from({ length: 10 }, (v, i) => ({ - email: 'no-reply@company.com', - })), - description: 'Description about this group', - }, - { - id: '3', - name: 'Security_alerts', - email: Array.from({ length: 5 }, (v, i) => ({ - email: 'no-reply@company.com', - })), - description: 'Description about this group', - }, -]; - -export const MOCK_SENDERS = [ - { - id: '0', - name: 'Main', - from: 'no-reply@company.com', - host: 'smtp.company.com', - port: '80', - method: 'SSL', - }, - { - id: '1', - name: 'Reports', - from: 'reports@company.com', - host: 'smtp.company.com', - port: '80', - method: 'SSL', - }, - { - id: '2', - name: 'Admin bot', - from: 'admin_bot@company.com', - host: 'smtp-internal.company.com', - port: '80', - method: 'SSL', - }, - { - id: '3', - name: 'Alerting bot', - from: 'alerts@company.com', - host: 'smtp.company.com', - port: '80', - method: 'SSL', - }, -]; - -export const MOCK_CHANNELS = [ - { - id: '0', - name: 'Ops_channel', - enabled: true, - type: 'SLACK', - allowedFeatures: ['ALERTING'], - description: 'Notifies all full-time operational team members.', - lastUpdatedTime: 1618951331, - destination: { - slack: { - url: - 'https://hooks.slack.com/services/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', - }, - }, - }, - { - id: '1', - name: 'Team2', - enabled: true, - type: 'CHIME', - allowedFeatures: ['ALERTING', 'REPORTING'], - description: 'Notifies all full-time operational team members.', - lastUpdatedTime: 1618951331, - destination: { - chime: { - url: 'https://hooks.chime.com/example/url', - }, - }, - }, - { - id: '2', - name: 'Security_alerts', - enabled: true, - type: 'CUSTOM_WEBHOOK', - allowedFeatures: ['ISM', 'REPORTING'], - description: 'Notifies all full-time operational team members.', - lastUpdatedTime: 1618951331, - destination: { - custom_webhook: { - host: 'https://hooks.myhost.com', - port: 21, - path: 'custompath', - parameters: { - Parameter1: 'value1', - Parameter2: 'value2', - Parameter3: 'value3', - Parameter4: 'value4', - Parameter5: 'value5', - Parameter6: 'value6', - Parameter7: 'value7', - Parameter8: 'value8', - }, - headers: { - 'Content-Type': 'application/JSON', - 'WWW-Authenticate': - 'Basic realm="Access to the staging site", charset="UTF-8"', - 'Access-Control-Allow-Headers': - 'X-Custom-Header, Upgrade-Insecure-Requests, Accept, Cross-Origin-Embedder-Policy: require-corp Cross-Origin-Opener-Policy: same-origin', - Header1: 'value1', - Header2: 'value2', - Header3: 'value3', - Header4: 'value4', - Header5: 'value5', - Header6: 'value6', - Header7: 'value7', - Header8: 'value8', - }, - }, - }, - }, - { - id: '3', - name: 'Reporting_bot', - enabled: false, - type: 'EMAIL', - allowedFeatures: ['REPORTING'], - description: 'Notifies all full-time operational team members.', - lastUpdatedTime: 1618951331, - destination: { - email: { - email_account_id: 'robot@gmail.com', - header: '# sample header', - recipients: [ - 'Team 2', - 'cyberadmin@company.com', - 'Ops_team_weekly', - 'security_pos@company.com', - 'Team 5', - 'bot@company.com', - 'Team 7', - ], - }, - }, - }, - { - id: '4', - name: 'SNS channel test', - enabled: false, - type: 'SNS', - allowedFeatures: ['ISM'], - description: 'Notifies all full-time operational team members.', - lastUpdatedTime: 1618951331, - destination: { - sns: { - topic_arn: 'arn:aws:sns:us-east-1:24586493349034:es-alerting-test', // sns arn - role_arn: 'arn:aws:sns:us-east-1:24586493349034:es-alerting-test', // iam arn - }, - }, - }, - { - id: '5', - name: 'SES channel test', - enabled: false, - type: 'SES', - allowedFeatures: ['ALERTING', 'REPORTING'], - description: 'Notifies all full-time operational team members.', - lastUpdatedTime: 1618951331, - destination: { - ses: { - email_account_id: 'robot@gmail.com', - header: '# sample header', - recipients: [ - 'Team 2', - 'cyberadmin@company.com', - 'Ops_team_weekly', - 'Team 5', - 'bot@company.com', - 'Team 7', - 'security_pos@company.com', - ], - }, - }, - }, -]; - -export const MOCK_NOTIFICATIONS = { - notifications: [ - { - id: '1', - title: 'Alert notification on high error rate', - referenceId: 'alert_id_1', - source: 'ALERTING', - severity: 'High', - lastUpdatedTime: 1612229000, - tags: ['optional string list'], - status: 'Error', - statusList: [ - { - configId: '1', - configName: 'dev_email_channel', - configType: 'Email', - emailRecipientStatus: [ - { - recipient: 'dd@amazon.com', - deliveryStatus: { - statusCode: '500', - statusText: 'Some error', - }, - }, - { - recipient: 'cc@amazon.com', - deliveryStatus: { - statusCode: '404', - statusText: 'invalid', - }, - }, - ], - deliveryStatus: { - // check this on each channel is enough - statusCode: '500', - statusText: - 'Unavailable to send message. Invalid SMTP configuration.', - }, - }, - { - configId: '2', - configName: 'manage_slack_channel', - configType: 'Slack', - deliveryStatus: { - statusCode: '200', - statusText: 'Success', - }, - }, - ], - }, - { - id: '2', - title: 'another notification', - referenceId: 'alert_id_2', - source: 'ALERTING', - severity: 'High', - lastUpdatedTime: 1612229000, - tags: ['optional string list'], - status: 'Success', - statusList: [ - { - configId: '1', - configName: 'dev_email_channel', - configType: 'Email', - emailRecipientStatus: [ - { - recipient: 'dd@amazon.com', - deliveryStatus: { - statusCode: '500', - statusText: 'Some error', - }, - }, - { - recipient: 'zhongnan@amazon.com', - deliveryStatus: { - statusCode: '404', - statusText: 'invalid', - }, - }, - ], - deliveryStatus: { - statusCode: '500', - statusText: 'Error', - }, - }, - { - configId: '2', - configName: 'manage_slack_channel', - configType: 'Slack', - deliveryStatus: { - statusCode: '200', - statusText: 'Success', - }, - }, - ], - }, - ], - totalNotifications: 6, -}; diff --git a/dashboards-notifications/server/clusters/notificationsPlugin.ts b/dashboards-notifications/server/clusters/notificationsPlugin.ts index 6c29702b..1b8446f8 100644 --- a/dashboards-notifications/server/clusters/notificationsPlugin.ts +++ b/dashboards-notifications/server/clusters/notificationsPlugin.ts @@ -120,7 +120,7 @@ export function NotificationsPlugin(Client: any, config: any, components: any) { method: 'GET', }); - notifications.getAvailableFeatures = clientAction({ + notifications.getServerFeatures = clientAction({ url: { fmt: OPENSEARCH_API.FEATURES, }, diff --git a/dashboards-notifications/server/routes/configRoutes.ts b/dashboards-notifications/server/routes/configRoutes.ts index b786ec02..8379e18e 100644 --- a/dashboards-notifications/server/routes/configRoutes.ts +++ b/dashboards-notifications/server/routes/configRoutes.ts @@ -210,7 +210,7 @@ export function configRoutes(router: IRouter) { ); try { const resp = await client.callAsCurrentUser( - 'notifications.getAvailableFeatures' + 'notifications.getServerFeatures' ); return response.ok({ body: resp }); } catch (error) { diff --git a/dashboards-notifications/test/mocks/serviceMock.ts b/dashboards-notifications/test/mocks/serviceMock.ts index cef25647..da6c9ecc 100644 --- a/dashboards-notifications/test/mocks/serviceMock.ts +++ b/dashboards-notifications/test/mocks/serviceMock.ts @@ -55,6 +55,7 @@ const notificationServiceMock = { const mainStateMock: MainState = { availableFeatures: CHANNEL_TYPE, + tooltipSupport: true, }; export { notificationServiceMock, coreServicesMock, mainStateMock }; diff --git a/dashboards-notifications/yarn.lock b/dashboards-notifications/yarn.lock index 685c9327..1d8bf990 100644 --- a/dashboards-notifications/yarn.lock +++ b/dashboards-notifications/yarn.lock @@ -1038,15 +1038,15 @@ browser-process-hrtime@^1.0.0: integrity sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow== browserslist@^4.14.5: - version "4.16.3" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.16.3.tgz#340aa46940d7db878748567c5dea24a48ddf3717" - integrity sha512-vIyhWmIkULaq04Gt93txdh+j02yX/JzlyhLYbV3YQCn/zvES3JnY7TifHHvvr1w5hTDluNKMkV05cs4vy8Q7sw== + version "4.16.8" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.16.8.tgz#cb868b0b554f137ba6e33de0ecff2eda403c4fb0" + integrity sha512-sc2m9ohR/49sWEbPj14ZSSZqp+kbi16aLao42Hmn3Z8FpjuMaq2xCA2l4zl9ITfyzvnvyE0hcg62YkIGKxgaNQ== dependencies: - caniuse-lite "^1.0.30001181" - colorette "^1.2.1" - electron-to-chromium "^1.3.649" + caniuse-lite "^1.0.30001251" + colorette "^1.3.0" + electron-to-chromium "^1.3.811" escalade "^3.1.1" - node-releases "^1.1.70" + node-releases "^1.1.75" bser@2.1.1: version "2.1.1" @@ -1108,10 +1108,10 @@ camelcase@^6.0.0: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.2.0.tgz#924af881c9d525ac9d87f40d964e5cea982a1809" integrity sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg== -caniuse-lite@^1.0.30001181: - version "1.0.30001199" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001199.tgz#062afccaad21023e2e647d767bac4274b8b8fd7f" - integrity sha512-ifbK2eChUCFUwGhlEzIoVwzFt1+iriSjyKKFYNfv6hN34483wyWpLLavYQXhnR036LhkdUYaSDpHg1El++VgHQ== +caniuse-lite@^1.0.30001251: + version "1.0.30001251" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001251.tgz#6853a606ec50893115db660f82c094d18f096d85" + integrity sha512-HOe1r+9VkU4TFmnU70z+r7OLmtR+/chB1rdcJUeQlAinjEeb0cKL20tlAtOagNZhbrtLnCvV19B4FmF1rgzl6A== capture-exit@^2.0.0: version "2.0.0" @@ -1279,10 +1279,10 @@ color-name@~1.1.4: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== -colorette@^1.2.1: - version "1.2.2" - resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.2.2.tgz#cbcc79d5e99caea2dbf10eb3a26fd8b3e6acfa94" - integrity sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w== +colorette@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.3.0.tgz#ff45d2f0edb244069d3b772adeb04fed38d0a0af" + integrity sha512-ecORCqbSFP7Wm8Y6lyqMJjexBQqXSF7SSeaTyGGphogUjBlFP9m9o08wy86HL2uB7fMTxtOUzLMk7ogKcxMg1w== colors@^1.1.2: version "1.4.0" @@ -1555,10 +1555,10 @@ ecc-jsbn@~0.1.1: jsbn "~0.1.0" safer-buffer "^2.1.0" -electron-to-chromium@^1.3.649: - version "1.3.687" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.687.tgz#c336184b7ab70427ffe2ee79eaeaedbc1ad8c374" - integrity sha512-IpzksdQNl3wdgkzf7dnA7/v10w0Utf1dF2L+B4+gKrloBrxCut+au+kky3PYvle3RMdSxZP+UiCZtLbcYRxSNQ== +electron-to-chromium@^1.3.811: + version "1.3.812" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.812.tgz#4c4fb407e0e1335056097f172e9f2c0a09efe77d" + integrity sha512-7KiUHsKAWtSrjVoTSzxQ0nPLr/a+qoxNZwkwd9LkylTOgOXSVXkQbpIVT0WAUQcI5gXq3SwOTCrK+WfINHOXQg== elegant-spinner@^1.0.1: version "1.0.1" @@ -2137,9 +2137,9 @@ has@^1.0.3: function-bind "^1.1.1" hosted-git-info@^2.1.4: - version "2.8.8" - resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.8.tgz#7539bd4bc1e0e0a895815a2e0262420b12858488" - integrity sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg== + version "2.8.9" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9" + integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw== html-encoding-sniffer@^2.0.1: version "2.0.1" @@ -3331,10 +3331,10 @@ node-notifier@^8.0.0: uuid "^8.3.0" which "^2.0.2" -node-releases@^1.1.70: - version "1.1.71" - resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.71.tgz#cb1334b179896b1c89ecfdd4b725fb7bbdfc7dbb" - integrity sha512-zR6HoT6LrLCRBwukmrVbHv0EpEQjksO6GmFcZQQuCAy139BEsoVKPYnf3jongYW83fAa1torLGYwxxky/p28sg== +node-releases@^1.1.75: + version "1.1.75" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.75.tgz#6dd8c876b9897a1b8e5a02de26afa79bb54ebbfe" + integrity sha512-Qe5OUajvqrqDSy6wrWFmMwfJ0jVgwiw4T3KqmbTcZ62qW0gQkheXYhcFM1+lOVcGUoRxcEcfyvFMAnDgaF1VWw== normalize-package-data@^2.5.0: version "2.5.0" @@ -3591,9 +3591,9 @@ path-key@^3.0.0, path-key@^3.1.0: integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== path-parse@^1.0.6: - version "1.0.6" - resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c" - integrity sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw== + version "1.0.7" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" + integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== pend@~1.2.0: version "1.2.0" @@ -4717,9 +4717,9 @@ write-file-atomic@^3.0.0: typedarray-to-buffer "^3.1.5" ws@^7.4.4: - version "7.4.4" - resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.4.tgz#383bc9742cb202292c9077ceab6f6047b17f2d59" - integrity sha512-Qm8k8ojNQIMx7S+Zp8u/uHOx7Qazv3Yv4q68MiWWWOJhiwG5W3x7iqmRtJo8xxrciZUY4vRxUTJCKuRnF28ZZw== + version "7.5.3" + resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.3.tgz#160835b63c7d97bfab418fc1b8a9fced2ac01a74" + integrity sha512-kQ/dHIzuLrS6Je9+uv81ueZomEwH0qVYstcAQ4/Z93K8zeko9gtAbttJWzoC5ukqXY1PpoouV3+VSOqEAFt5wg== xml-name-validator@^3.0.0: version "3.0.0" diff --git a/notifications/build.gradle b/notifications/build.gradle index f0979a7f..b72992d8 100644 --- a/notifications/build.gradle +++ b/notifications/build.gradle @@ -28,9 +28,13 @@ buildscript { ext { - opensearch_version = System.getProperty("opensearch.version", "1.0.0") + opensearch_version = System.getProperty("opensearch.version", "1.1.0-SNAPSHOT") + // 1.0.0 -> 1.0.0.0, and 1.0.0-SNAPSHOT -> 1.0.0.0-SNAPSHOT + opensearch_build = opensearch_version.replaceAll(/(\.\d)([^\d]*)$/, '$1.0$2') + common_utils_version = System.getProperty("common_utils.version", opensearch_build) kotlin_version = System.getProperty("kotlin.version", "1.4.32") junit_version = System.getProperty("junit.version", "5.7.2") + aws_version = System.getProperty("aws.version", "1.12.48") } repositories { @@ -54,7 +58,16 @@ apply plugin: 'jacoco' apply plugin: 'io.gitlab.arturbosch.detekt' apply from: 'build-tools/merged-coverage.gradle' +ext { + isSnapshot = "true" == System.getProperty("build.snapshot", "true") +} + allprojects { + version = "${opensearch_version}" - "-SNAPSHOT" + ".0" + if (isSnapshot) { + version += "-SNAPSHOT" + } + repositories { mavenLocal() mavenCentral() diff --git a/notifications/gradle.properties b/notifications/gradle.properties deleted file mode 100644 index 9fe7a004..00000000 --- a/notifications/gradle.properties +++ /dev/null @@ -1,28 +0,0 @@ -# -# SPDX-License-Identifier: Apache-2.0 -# -# The OpenSearch Contributors require contributions made to -# this file be licensed under the Apache-2.0 license or a -# compatible open source license. -# -# Modifications Copyright OpenSearch Contributors. See -# GitHub history for details. -# - -# -# Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"). -# You may not use this file except in compliance with the License. -# A copy of the License is located at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# or in the "license" file accompanying this file. This file is distributed -# on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either -# express or implied. See the License for the specific language governing -# permissions and limitations under the License. -# -# - -version = 1.0.0 diff --git a/notifications/notifications/build.gradle b/notifications/notifications/build.gradle index 888f419a..bec0f8e2 100644 --- a/notifications/notifications/build.gradle +++ b/notifications/notifications/build.gradle @@ -57,7 +57,6 @@ allOpen { ext { projectSubstitutions = [:] - isSnapshot = "true" == System.getProperty("build.snapshot", "true") licenseFile = rootProject.file('LICENSE') noticeFile = rootProject.file('NOTICE') } @@ -71,17 +70,6 @@ configurations.all { resolutionStrategy { force "org.jetbrains.kotlin:kotlin-stdlib:${kotlin_version}" force "org.jetbrains.kotlin:kotlin-stdlib-common:${kotlin_version}" - force "commons-logging:commons-logging:1.2" // resolve for awssdk:ses - force "commons-codec:commons-codec:1.13" // resolve for awssdk:ses - force "io.netty:netty-codec-http:4.1.63.Final" // resolve for awssdk:ses - force "io.netty:netty-handler:4.1.63.Final" // resolve for awssdk:ses - force "org.apache.httpcomponents:httpclient:4.5.10" // resolve for awssdk:ses - force "org.apache.httpcomponents:httpcore:4.4.13" // resolve for awssdk:ses - force "com.fasterxml.jackson.core:jackson-core:2.12.3" // resolve for awssdk:ses - force "com.fasterxml.jackson.dataformat:jackson-dataformat-cbor:2.12.3" // resolve for awssdk:ses - force "com.fasterxml.jackson.dataformat:jackson-dataformat-smile:2.12.3" // resolve for awssdk:ses - force "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.12.3" // resolve for awssdk:ses - force "junit:junit:4.12" // resolve for awssdk:ses } } @@ -90,10 +78,8 @@ dependencies { compile "org.jetbrains.kotlin:kotlin-stdlib:${kotlin_version}" compile "org.jetbrains.kotlin:kotlin-stdlib-common:${kotlin_version}" compile "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.4.3" // ${kotlin_version} does not work for coroutines - compile "${group}:common-utils:${opensearch_version}.0" - compile ("software.amazon.awssdk:ses:2.16.75") { - exclude module: 'annotations' // conflict with org.jetbrains:annotations, integTestRunner fails with error "codebase property already set" - } + compile "${group}:common-utils:${common_utils_version}" + compile "org.json:json:20180813" testImplementation( 'org.assertj:assertj-core:3.19.0', @@ -116,7 +102,7 @@ dependencies { testCompile 'com.google.code.gson:gson:2.8.7' testImplementation 'org.springframework.integration:spring-integration-mail:5.5.0' testImplementation 'org.springframework.integration:spring-integration-test-support:5.5.0' - + compile group: 'com.github.wnameless', name: 'json-flattener', version: '0.1.0' compile project(path: ":${rootProject.name}-spi", configuration: 'shadow') } @@ -146,6 +132,11 @@ afterEvaluate { } test { + if (project.hasProperty('excludeTests')) { + project.properties['excludeTests']?.replaceAll('\\s', '')?.split('[,;]')?.each { + exclude "${it}" + } + } systemProperty 'tests.security.manager', 'false' useJUnitPlatform() } diff --git a/notifications/notifications/src/main/config/notifications.yml b/notifications/notifications/src/main/config/notifications.yml index 0c093145..44759a47 100644 --- a/notifications/notifications/src/main/config/notifications.yml +++ b/notifications/notifications/src/main/config/notifications.yml @@ -31,17 +31,6 @@ opensearch.notifications: general: operationTimeoutMs: 60000 # 60 seconds, Minimum 100ms defaultItemsQueryCount: 100 # default number of items to query - email: - channel: "smtp" # ses or smtp, provide corresponding sections - fromAddress: "from@email.com" - monthlyLimit: 200 - sizeLimit: 10000000 # 10MB Email size limit - ses: # Configuration for Amazon SES email delivery - awsRegion: "us-west-2" - smtp: # Configuration for SMTP email delivery - host: "localhost" - port: 10255 - transportMethod: "starttls" # starttls, ssl or plain access: adminAccess: "All" # adminAccess values: diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/NotificationPlugin.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/NotificationPlugin.kt index 08902e58..84c568f5 100644 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/NotificationPlugin.kt +++ b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/NotificationPlugin.kt @@ -50,7 +50,7 @@ import org.opensearch.notifications.action.GetFeatureChannelListAction import org.opensearch.notifications.action.GetNotificationConfigAction import org.opensearch.notifications.action.GetNotificationEventAction import org.opensearch.notifications.action.GetPluginFeaturesAction -import org.opensearch.notifications.action.SendMessageAction +import org.opensearch.notifications.action.PublishNotificationAction import org.opensearch.notifications.action.SendNotificationAction import org.opensearch.notifications.action.UpdateNotificationConfigAction import org.opensearch.notifications.index.ConfigIndexingActions @@ -61,12 +61,11 @@ import org.opensearch.notifications.resthandler.NotificationConfigRestHandler import org.opensearch.notifications.resthandler.NotificationEventRestHandler import org.opensearch.notifications.resthandler.NotificationFeatureChannelListRestHandler import org.opensearch.notifications.resthandler.NotificationFeaturesRestHandler -import org.opensearch.notifications.resthandler.SendMessageRestHandler +import org.opensearch.notifications.resthandler.NotificationStatsRestHandler import org.opensearch.notifications.resthandler.SendTestMessageRestHandler import org.opensearch.notifications.security.UserAccessManager import org.opensearch.notifications.send.SendMessageActionHelper import org.opensearch.notifications.settings.PluginSettings -import org.opensearch.notifications.throttle.Accountant import org.opensearch.plugins.ActionPlugin import org.opensearch.plugins.Plugin import org.opensearch.repositories.RepositoriesService @@ -125,7 +124,6 @@ internal class NotificationPlugin : ActionPlugin, Plugin() { ConfigIndexingActions.initialize(NotificationConfigIndex, UserAccessManager) SendMessageActionHelper.initialize(NotificationConfigIndex, NotificationEventIndex, UserAccessManager) EventIndexingActions.initialize(NotificationEventIndex, UserAccessManager) - Accountant.initialize(client, clusterService) return listOf() } @@ -135,7 +133,6 @@ internal class NotificationPlugin : ActionPlugin, Plugin() { override fun getActions(): List> { log.debug("$LOG_PREFIX:getActions") return listOf( - ActionPlugin.ActionHandler(SendMessageAction.ACTION_TYPE, SendMessageAction::class.java), ActionPlugin.ActionHandler( NotificationsActions.CREATE_NOTIFICATION_CONFIG_ACTION_TYPE, CreateNotificationConfigAction::class.java @@ -167,6 +164,10 @@ internal class NotificationPlugin : ActionPlugin, Plugin() { ActionPlugin.ActionHandler( NotificationsActions.SEND_NOTIFICATION_ACTION_TYPE, SendNotificationAction::class.java + ), + ActionPlugin.ActionHandler( + NotificationsActions.LEGACY_PUBLISH_NOTIFICATION_ACTION_TYPE, + PublishNotificationAction::class.java ) ) } @@ -185,12 +186,12 @@ internal class NotificationPlugin : ActionPlugin, Plugin() { ): List { log.debug("$LOG_PREFIX:getRestHandlers") return listOf( - SendMessageRestHandler(), NotificationConfigRestHandler(), NotificationEventRestHandler(), NotificationFeaturesRestHandler(), NotificationFeatureChannelListRestHandler(), - SendTestMessageRestHandler() + SendTestMessageRestHandler(), + NotificationStatsRestHandler() ) } } diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/action/GetFeatureChannelListAction.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/action/GetFeatureChannelListAction.kt index f8f9d92a..8376f7ec 100644 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/action/GetFeatureChannelListAction.kt +++ b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/action/GetFeatureChannelListAction.kt @@ -31,18 +31,18 @@ import org.opensearch.action.ActionListener import org.opensearch.action.ActionRequest import org.opensearch.action.support.ActionFilters import org.opensearch.client.Client +import org.opensearch.common.Strings import org.opensearch.common.inject.Inject import org.opensearch.common.xcontent.NamedXContentRegistry import org.opensearch.commons.authuser.User import org.opensearch.commons.notifications.action.GetFeatureChannelListRequest import org.opensearch.commons.notifications.action.GetFeatureChannelListResponse import org.opensearch.commons.notifications.action.NotificationsActions -import org.opensearch.commons.notifications.model.Feature import org.opensearch.commons.utils.recreateObject import org.opensearch.notifications.index.ConfigIndexingActions +import org.opensearch.notifications.metrics.Metrics import org.opensearch.tasks.Task import org.opensearch.transport.TransportService -import java.lang.IllegalArgumentException /** * Get feature channel list transport action @@ -81,9 +81,10 @@ internal class GetFeatureChannelListAction @Inject constructor( request: GetFeatureChannelListRequest, user: User? ): GetFeatureChannelListResponse { - if (request.feature == Feature.NONE) { - throw IllegalArgumentException("Not a valid feature") - } + require(!Strings.isNullOrEmpty(request.feature)) { + Metrics.NOTIFICATIONS_FEATURE_CHANNELS_INFO_USER_ERROR_INVALID_FEATURE_TAG.counter.increment() + "Not a valid feature" + } // TODO: Validate against allowed features return ConfigIndexingActions.getFeatureChannelList(request, user) } } diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/action/PluginBaseAction.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/action/PluginBaseAction.kt index f47a0d10..c6598d5d 100644 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/action/PluginBaseAction.kt +++ b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/action/PluginBaseAction.kt @@ -46,6 +46,7 @@ import org.opensearch.index.IndexNotFoundException import org.opensearch.index.engine.VersionConflictEngineException import org.opensearch.indices.InvalidIndexNameException import org.opensearch.notifications.NotificationPlugin.Companion.LOG_PREFIX +import org.opensearch.notifications.metrics.Metrics import org.opensearch.rest.RestStatus import org.opensearch.tasks.Task import org.opensearch.transport.TransportService @@ -79,9 +80,11 @@ internal abstract class PluginBaseAction( + NotificationsActions.LEGACY_PUBLISH_NOTIFICATION_NAME, + transportService, + client, + actionFilters, + ::LegacyPublishNotificationRequest +) { + + /** + * {@inheritDoc} + * Transform the request and call super.doExecute() to support call from other plugins. + */ + override fun doExecute( + task: Task?, + request: ActionRequest, + listener: ActionListener + ) { + val transformedRequest = request as? LegacyPublishNotificationRequest + ?: recreateObject(request) { LegacyPublishNotificationRequest(it) } + super.doExecute(task, transformedRequest, listener) + } + + /** + * {@inheritDoc} + */ + override fun executeRequest( + request: LegacyPublishNotificationRequest, + user: User? + ): LegacyPublishNotificationResponse { + return SendMessageActionHelper.executeLegacyRequest(request) + } +} diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/action/SendMessageAction.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/action/SendMessageAction.kt deleted file mode 100644 index 6c12820b..00000000 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/action/SendMessageAction.kt +++ /dev/null @@ -1,128 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - * - * Modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -/* - * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - * - */ - -package org.opensearch.notifications.action - -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.runBlocking -import org.opensearch.OpenSearchStatusException -import org.opensearch.action.ActionType -import org.opensearch.action.support.ActionFilters -import org.opensearch.client.Client -import org.opensearch.common.inject.Inject -import org.opensearch.common.xcontent.NamedXContentRegistry -import org.opensearch.commons.authuser.User -import org.opensearch.commons.utils.logger -import org.opensearch.notifications.NotificationPlugin.Companion.LOG_PREFIX -import org.opensearch.notifications.channel.ChannelFactory -import org.opensearch.notifications.model.ChannelMessageResponse -import org.opensearch.notifications.model.SendMessageRequest -import org.opensearch.notifications.model.SendMessageResponse -import org.opensearch.notifications.throttle.Accountant -import org.opensearch.notifications.throttle.Counters -import org.opensearch.rest.RestStatus -import org.opensearch.transport.TransportService - -/** - * Send message action for send notification request. - */ -internal class SendMessageAction @Inject constructor( - transportService: TransportService, - client: Client, - actionFilters: ActionFilters, - val xContentRegistry: NamedXContentRegistry -) : PluginBaseAction( - NAME, - transportService, - client, - actionFilters, - ::SendMessageRequest -) { - companion object { - private const val NAME = "cluster:admin/opensearch/notifications/send" - internal val ACTION_TYPE = ActionType(NAME, ::SendMessageResponse) - private val log by logger(SendMessageAction::class.java) - } - - /** - * {@inheritDoc} - */ - override fun executeRequest(request: SendMessageRequest, user: User?): SendMessageResponse { - log.debug("$LOG_PREFIX:send") - if (!isMessageQuotaAvailable(request)) { - log.info("$LOG_PREFIX:${request.refTag}:Message Sending quota not available") - throw OpenSearchStatusException("Message Sending quota not available", RestStatus.TOO_MANY_REQUESTS) - } - val statusList: List = sendMessagesInParallel(request) - statusList.forEach { - log.info("$LOG_PREFIX:${request.refTag}:statusCode=${it.statusCode}, statusText=${it.statusText}") - } - return SendMessageResponse(request.refTag, statusList) - } - - private fun sendMessagesInParallel(sendMessageRequest: SendMessageRequest): List { - val counters = Counters() - counters.requestCount.incrementAndGet() - val statusList: List - // Fire all the message sending in parallel - runBlocking { - val statusDeferredList = sendMessageRequest.recipients.map { - async(Dispatchers.IO) { sendMessageToChannel(it, sendMessageRequest, counters) } - } - statusList = statusDeferredList.awaitAll() - } - // After all operation are executed, update the counters - Accountant.incrementCounters(counters) - return statusList - } - - private fun sendMessageToChannel( - recipient: String, - sendMessageRequest: SendMessageRequest, - counters: Counters - ): ChannelMessageResponse { - val channel = ChannelFactory.getNotificationChannel(recipient) - return channel.sendMessage( - sendMessageRequest.refTag, - recipient, - sendMessageRequest.title, - sendMessageRequest.channelMessage, - counters - ) - } - - private fun isMessageQuotaAvailable(sendMessageRequest: SendMessageRequest): Boolean { - val counters = Counters() - sendMessageRequest.recipients.forEach { - ChannelFactory.getNotificationChannel(it) - .updateCounter(sendMessageRequest.refTag, it, sendMessageRequest.channelMessage, counters) - } - return Accountant.isMessageQuotaAvailable(counters) - } -} diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/channel/ChannelFactory.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/channel/ChannelFactory.kt deleted file mode 100644 index b2d118b2..00000000 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/channel/ChannelFactory.kt +++ /dev/null @@ -1,56 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - * - * Modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -/* - * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - * - */ - -package org.opensearch.notifications.channel - -import org.opensearch.notifications.channel.email.EmailChannelFactory -import org.opensearch.notifications.channel.email.EmailChannelFactory.EMAIL_PREFIX - -/** - * Factory object for creating and providing channel provider. - */ -internal object ChannelFactory : ChannelProvider { - private val channelMap = mapOf(EMAIL_PREFIX to EmailChannelFactory) - - /** - * {@inheritDoc} - */ - override fun getNotificationChannel(recipient: String): NotificationChannel { - var mappedChannel: NotificationChannel = EmptyChannel - if (!recipient.contains(':')) { // if channel info not present - mappedChannel = EmailChannelFactory.getNotificationChannel(recipient) // Default channel is email - } else { - for (it in channelMap) { - if (recipient.startsWith(it.key, true)) { - mappedChannel = it.value.getNotificationChannel(recipient) - break - } - } - } - return mappedChannel - } -} diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/channel/ChannelProvider.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/channel/ChannelProvider.kt deleted file mode 100644 index bb688847..00000000 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/channel/ChannelProvider.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - * - * Modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -/* - * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - * - */ - -package org.opensearch.notifications.channel - -/** - * Interface for channel provider for specific recipient depending on its type. - */ -internal interface ChannelProvider { - /** - * gets notification channel for specific recipient depending on its type (prefix). - * @param recipient recipient address to send notification to. prefix with channel type e.g. "mailto:email@address.com" - * @return Notification channel for sending notification for given recipient (depending on its type) - */ - fun getNotificationChannel(recipient: String): NotificationChannel -} diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/channel/EmptyChannel.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/channel/EmptyChannel.kt deleted file mode 100644 index cb7ef9fb..00000000 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/channel/EmptyChannel.kt +++ /dev/null @@ -1,62 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - * - * Modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -/* - * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - * - */ - -package org.opensearch.notifications.channel - -import org.opensearch.commons.notifications.model.ChannelMessage -import org.opensearch.notifications.model.ChannelMessageResponse -import org.opensearch.notifications.throttle.Counters -import org.opensearch.rest.RestStatus - -/** - * Empty implementation of the notification channel which responds with error for all requests without any operations. - */ -internal object EmptyChannel : NotificationChannel { - /** - * {@inheritDoc} - */ - override fun sendMessage( - refTag: String, - recipient: String, - title: String, - channelMessage: ChannelMessage, - counter: Counters - ): ChannelMessageResponse { - return ChannelMessageResponse( - recipient, - RestStatus.UNPROCESSABLE_ENTITY, - "No Configured Channel for recipient type:${recipient.substringBefore(':', "empty")}" - ) - } - - /** - * {@inheritDoc} - */ - override fun updateCounter(refTag: String, recipient: String, channelMessage: ChannelMessage, counter: Counters) { - return - } -} diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/channel/NotificationChannel.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/channel/NotificationChannel.kt deleted file mode 100644 index 4ef6b66b..00000000 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/channel/NotificationChannel.kt +++ /dev/null @@ -1,67 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - * - * Modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -/* - * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - * - */ - -package org.opensearch.notifications.channel - -import org.opensearch.commons.notifications.model.ChannelMessage -import org.opensearch.notifications.model.ChannelMessageResponse -import org.opensearch.notifications.throttle.Counters - -/** - * Interface for sending notification message over a implemented channel. - */ -internal interface NotificationChannel { - - /** - * Update the counter if the notification message is over this channel. Do not actually send message. - * Used for checking message quotas. - * - * @param refTag ref tag for logging purpose - * @param recipient recipient address to send notification to - * @param channelMessage The message to send notification - * @param counter The counter object to update the detail for accounting purpose - */ - fun updateCounter(refTag: String, recipient: String, channelMessage: ChannelMessage, counter: Counters) - - /** - * Sending notification message over this channel. - * - * @param refTag ref tag for logging purpose - * @param recipient recipient address to send notification to - * @param title The title to send notification - * @param channelMessage The message to send notification - * @param counter The counter object to update the detail for accounting purpose - * @return Channel message response - */ - fun sendMessage( - refTag: String, - recipient: String, - title: String, - channelMessage: ChannelMessage, - counter: Counters - ): ChannelMessageResponse -} diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/channel/email/BaseEmailChannel.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/channel/email/BaseEmailChannel.kt deleted file mode 100644 index 66e872f7..00000000 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/channel/email/BaseEmailChannel.kt +++ /dev/null @@ -1,167 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - * - * Modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -/* - * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - * - */ - -package org.opensearch.notifications.channel.email - -import org.opensearch.commons.notifications.model.Attachment -import org.opensearch.commons.notifications.model.ChannelMessage -import org.opensearch.notifications.channel.NotificationChannel -import org.opensearch.notifications.model.ChannelMessageResponse -import org.opensearch.notifications.settings.PluginSettings -import org.opensearch.notifications.throttle.Counters -import org.opensearch.rest.RestStatus -import java.io.IOException -import javax.mail.MessagingException -import javax.mail.Session -import javax.mail.internet.AddressException -import javax.mail.internet.MimeMessage - -/** - * Notification channel for sending mail to Email server. - */ -internal abstract class BaseEmailChannel : NotificationChannel { - - companion object { - private const val MINIMUM_EMAIL_HEADER_LENGTH = 160 // minimum value from 100 reference emails - } - - /** - * {@inheritDoc} - */ - override fun updateCounter(refTag: String, recipient: String, channelMessage: ChannelMessage, counter: Counters) { - counter.emailSentSuccessCount.incrementAndGet() - } - - /** - * {@inheritDoc} - */ - override fun sendMessage( - refTag: String, - recipient: String, - title: String, - channelMessage: ChannelMessage, - counter: Counters - ): ChannelMessageResponse { - val retStatus = sendEmail(refTag, recipient, title, channelMessage) - if (retStatus.statusCode == RestStatus.OK) { - counter.emailSentSuccessCount.incrementAndGet() - } else { - counter.emailSentFailureCount.incrementAndGet() - } - return retStatus - } - - /** - * Sending Email message to server. - * @param refTag ref tag for logging purpose - * @param recipient email recipient to send mail to - * @param title email subject to send - * @param channelMessage email message information to compose email - * @return Channel message response - */ - private fun sendEmail( - refTag: String, - recipient: String, - title: String, - channelMessage: ChannelMessage - ): ChannelMessageResponse { - val fromAddress = PluginSettings.emailFromAddress - if (PluginSettings.UNCONFIGURED_EMAIL_ADDRESS == fromAddress) { - return ChannelMessageResponse(recipient, RestStatus.NOT_IMPLEMENTED, "Email from: address not configured") - } - if (isMessageSizeOverLimit(title, channelMessage)) { - return ChannelMessageResponse( - recipient, - RestStatus.REQUEST_ENTITY_TOO_LARGE, - "Email size larger than ${PluginSettings.emailSizeLimit}" - ) - } - val mimeMessage: MimeMessage - return try { - val session = prepareSession(refTag, recipient, channelMessage) - mimeMessage = EmailMimeProvider.prepareMimeMessage(session, fromAddress, recipient, title, channelMessage) - sendMimeMessage(refTag, recipient, mimeMessage) - } catch (addressException: AddressException) { - ChannelMessageResponse( - recipient, - RestStatus.BAD_REQUEST, - "recipient parsing failed with status:${addressException.message}" - ) - } catch (messagingException: MessagingException) { - ChannelMessageResponse( - recipient, - RestStatus.FAILED_DEPENDENCY, - "Email message creation failed with status:${messagingException.message}" - ) - } catch (ioException: IOException) { - ChannelMessageResponse( - recipient, - RestStatus.FAILED_DEPENDENCY, - "Email message creation failed with status:${ioException.message}" - ) - } - } - - private fun isMessageSizeOverLimit(title: String, channelMessage: ChannelMessage): Boolean { - val attachment: Attachment? = channelMessage.attachment - val approxAttachmentLength = if (attachment != null) { - MINIMUM_EMAIL_HEADER_LENGTH + - attachment.fileData.length + - attachment.fileName.length - } else { - 0 - } - val approxEmailLength = MINIMUM_EMAIL_HEADER_LENGTH + - title.length + - channelMessage.textDescription.length + - (channelMessage.htmlDescription?.length ?: 0) + - approxAttachmentLength - return approxEmailLength > PluginSettings.emailSizeLimit - } - - /** - * Prepare Session for creating Email mime message. - * @param refTag ref tag for logging purpose - * @param recipient email recipient to send mail to - * @param channelMessage email message information to compose email - * @return initialized/prepared Session for creating mime message - */ - protected abstract fun prepareSession(refTag: String, recipient: String, channelMessage: ChannelMessage): Session - - /** - * Sending Email mime message to server. - * @param refTag ref tag for logging purpose - * @param recipient email recipient to send mail to - * @param mimeMessage mime message to send to Email server - * @return Channel message response - */ - protected abstract fun sendMimeMessage( - refTag: String, - recipient: String, - mimeMessage: MimeMessage - ): ChannelMessageResponse -} diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/channel/email/EmailChannelFactory.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/channel/email/EmailChannelFactory.kt deleted file mode 100644 index d01ebbd4..00000000 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/channel/email/EmailChannelFactory.kt +++ /dev/null @@ -1,52 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - * - * Modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -/* - * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - * - */ - -package org.opensearch.notifications.channel.email - -import org.opensearch.notifications.channel.ChannelProvider -import org.opensearch.notifications.channel.EmptyChannel -import org.opensearch.notifications.channel.NotificationChannel -import org.opensearch.notifications.settings.EmailChannelType -import org.opensearch.notifications.settings.PluginSettings - -/** - * Factory object for creating and providing email channel provider. - */ -internal object EmailChannelFactory : ChannelProvider { - const val EMAIL_PREFIX = "mailto:" - private val channelMap: Map = mapOf( - EmailChannelType.SMTP.stringValue to SmtpChannel, - EmailChannelType.SES.stringValue to SesChannel - ) - - /** - * {@inheritDoc} - */ - override fun getNotificationChannel(recipient: String): NotificationChannel { - return channelMap.getOrDefault(PluginSettings.emailChannel, EmptyChannel) - } -} diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/channel/email/EmailMimeProvider.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/channel/email/EmailMimeProvider.kt deleted file mode 100644 index ba275d43..00000000 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/channel/email/EmailMimeProvider.kt +++ /dev/null @@ -1,159 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - * - * Modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -/* - * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - * - */ - -package org.opensearch.notifications.channel.email - -import org.opensearch.commons.notifications.model.Attachment -import org.opensearch.commons.notifications.model.ChannelMessage -import java.util.Base64 -import javax.activation.DataHandler -import javax.mail.Message -import javax.mail.Session -import javax.mail.internet.MimeBodyPart -import javax.mail.internet.MimeMessage -import javax.mail.internet.MimeMultipart -import javax.mail.util.ByteArrayDataSource - -/** - * Object for creating mime message from the channel message for sending mail. - */ -internal object EmailMimeProvider { - /** - * Create and prepare mime message to send mail - * @param session The mail session to use to create mime message - * @param fromAddress "From:" address of the email message - * @param recipient "To:" address of the email message - * @param title The title to send notification - * @param channelMessage The message to send notification - * @return The created and prepared mime message object - */ - fun prepareMimeMessage( - session: Session, - fromAddress: String, - recipient: String, - title: String, - channelMessage: ChannelMessage - ): MimeMessage { - // Create a new MimeMessage object - val message = MimeMessage(session) - - // Add from: - message.setFrom(extractEmail(fromAddress)) - - // Add to: - message.setRecipients(Message.RecipientType.TO, extractEmail(recipient)) - - // Add Subject: - message.setSubject(title, "UTF-8") - - // Create a multipart/alternative child container - val msgBody = MimeMultipart("alternative") - - // Create a wrapper for the HTML and text parts - val bodyWrapper = MimeBodyPart() - - // Define the text part (if html part does not exists then use "-" string - val textPart = MimeBodyPart() - textPart.setContent(channelMessage.textDescription, "text/plain; charset=UTF-8") - // Add the text part to the child container - msgBody.addBodyPart(textPart) - - // Define the HTML part - if (channelMessage.htmlDescription != null) { - val htmlPart = MimeBodyPart() - htmlPart.setContent(channelMessage.htmlDescription, "text/html; charset=UTF-8") - // Add the HTML part to the child container - msgBody.addBodyPart(htmlPart) - } - // Add the child container to the wrapper object - bodyWrapper.setContent(msgBody) - - // Create a multipart/mixed parent container - val msg = MimeMultipart("mixed") - - // Add the parent container to the message - message.setContent(msg) - - // Add the multipart/alternative part to the message - msg.addBodyPart(bodyWrapper) - - val attachment: Attachment? = channelMessage.attachment - if (attachment != null) { - // Add the attachment to the message - var attachmentMime: MimeBodyPart? = null - when (attachment.fileEncoding) { - "text" -> attachmentMime = createTextAttachmentPart(attachment) - "base64" -> attachmentMime = createBinaryAttachmentPart(attachment) - } - if (attachmentMime != null) { - msg.addBodyPart(attachmentMime) - } - } - return message - } - - /** - * Extract email address from "mailto:email@address.com" format - * @param recipient input email address - * @return extracted email address - */ - private fun extractEmail(recipient: String): String { - if (recipient.startsWith(EmailChannelFactory.EMAIL_PREFIX)) { - return recipient.drop(EmailChannelFactory.EMAIL_PREFIX.length) - } - return recipient - } - - /** - * Create a binary attachment part from channel attachment message - * @param attachment channel attachment message - * @return created mime body part for binary attachment - */ - private fun createBinaryAttachmentPart(attachment: Attachment): MimeBodyPart { - val attachmentMime = MimeBodyPart() - val fds = ByteArrayDataSource( - Base64.getMimeDecoder().decode(attachment.fileData), - attachment.fileContentType ?: "application/octet-stream" - ) - attachmentMime.dataHandler = DataHandler(fds) - attachmentMime.fileName = attachment.fileName - return attachmentMime - } - - /** - * Create a text attachment part from channel attachment message - * @param attachment channel attachment message - * @return created mime body part for text attachment - */ - private fun createTextAttachmentPart(attachment: Attachment): MimeBodyPart { - val attachmentMime = MimeBodyPart() - val subContentType = attachment.fileContentType?.substringAfterLast('/') ?: "plain" - attachmentMime.setText(attachment.fileData, "UTF-8", subContentType) - attachmentMime.fileName = attachment.fileName - return attachmentMime - } -} diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/channel/email/SesChannel.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/channel/email/SesChannel.kt deleted file mode 100644 index 2bf66978..00000000 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/channel/email/SesChannel.kt +++ /dev/null @@ -1,138 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - * - * Modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -/* - * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - * - */ - -package org.opensearch.notifications.channel.email - -import org.opensearch.commons.notifications.model.ChannelMessage -import org.opensearch.commons.utils.logger -import org.opensearch.notifications.NotificationPlugin.Companion.LOG_PREFIX -import org.opensearch.notifications.model.ChannelMessageResponse -import org.opensearch.notifications.settings.PluginSettings -import org.opensearch.notifications.spi.utils.SecurityAccess -import org.opensearch.rest.RestStatus -import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider -import software.amazon.awssdk.core.SdkBytes -import software.amazon.awssdk.core.exception.SdkException -import software.amazon.awssdk.regions.Region -import software.amazon.awssdk.services.ses.SesClient -import software.amazon.awssdk.services.ses.model.AccountSendingPausedException -import software.amazon.awssdk.services.ses.model.ConfigurationSetDoesNotExistException -import software.amazon.awssdk.services.ses.model.ConfigurationSetSendingPausedException -import software.amazon.awssdk.services.ses.model.MailFromDomainNotVerifiedException -import software.amazon.awssdk.services.ses.model.MessageRejectedException -import software.amazon.awssdk.services.ses.model.RawMessage -import software.amazon.awssdk.services.ses.model.SendRawEmailRequest -import software.amazon.awssdk.services.ses.model.SesException -import java.io.ByteArrayOutputStream -import java.util.Properties -import javax.mail.Session -import javax.mail.internet.MimeMessage - -/** - * Notification channel for sending mail over Amazon SES. - */ -internal object SesChannel : BaseEmailChannel() { - private val log by logger(javaClass) - - /** - * {@inheritDoc} - */ - override fun prepareSession(refTag: String, recipient: String, channelMessage: ChannelMessage): Session { - val prop = Properties() - prop["mail.transport.protocol"] = "smtp" - return Session.getInstance(prop) - } - - /** - * {@inheritDoc} - */ - override fun sendMimeMessage(refTag: String, recipient: String, mimeMessage: MimeMessage): ChannelMessageResponse { - return try { - log.debug("$LOG_PREFIX:Sending Email-SES:$refTag") - val region = Region.of(PluginSettings.sesAwsRegion) - val client = SecurityAccess.doPrivileged { - SesClient.builder().region(region).credentialsProvider(DefaultCredentialsProvider.create()).build() - } - val outputStream = ByteArrayOutputStream() - SecurityAccess.doPrivileged { mimeMessage.writeTo(outputStream) } - val emailSize = outputStream.size() - if (emailSize <= PluginSettings.emailSizeLimit) { - val data = SdkBytes.fromByteArray(outputStream.toByteArray()) - val rawMessage = RawMessage.builder() - .data(data) - .build() - val rawEmailRequest = SendRawEmailRequest.builder() - .rawMessage(rawMessage) - .build() - val response = SecurityAccess.doPrivileged { client.sendRawEmail(rawEmailRequest) } - log.info("$LOG_PREFIX:Email-SES:$refTag status:$response") - ChannelMessageResponse(recipient, RestStatus.OK, "Success") - } else { - ChannelMessageResponse( - recipient, - RestStatus.REQUEST_ENTITY_TOO_LARGE, - "Email size($emailSize) larger than ${PluginSettings.emailSizeLimit}" - ) - } - } catch (exception: MessageRejectedException) { - ChannelMessageResponse(recipient, RestStatus.SERVICE_UNAVAILABLE, getSesExceptionText(exception)) - } catch (exception: MailFromDomainNotVerifiedException) { - ChannelMessageResponse(recipient, RestStatus.FORBIDDEN, getSesExceptionText(exception)) - } catch (exception: ConfigurationSetDoesNotExistException) { - ChannelMessageResponse(recipient, RestStatus.NOT_IMPLEMENTED, getSesExceptionText(exception)) - } catch (exception: ConfigurationSetSendingPausedException) { - ChannelMessageResponse(recipient, RestStatus.SERVICE_UNAVAILABLE, getSesExceptionText(exception)) - } catch (exception: AccountSendingPausedException) { - ChannelMessageResponse(recipient, RestStatus.INSUFFICIENT_STORAGE, getSesExceptionText(exception)) - } catch (exception: SesException) { - ChannelMessageResponse(recipient, RestStatus.FAILED_DEPENDENCY, getSesExceptionText(exception)) - } catch (exception: SdkException) { - ChannelMessageResponse(recipient, RestStatus.FAILED_DEPENDENCY, getSdkExceptionText(exception)) - } - } - - /** - * Create error string from Amazon SES Exceptions - * @param exception SES Exception - * @return generated error string - */ - private fun getSesExceptionText(exception: SesException): String { - val httpResponse = exception.awsErrorDetails().sdkHttpResponse() - log.info("$LOG_PREFIX:SesException $exception") - return "sendEmail Error, SES status:${httpResponse.statusCode()}:${httpResponse.statusText()}" - } - - /** - * Create error string from Amazon SDK Exceptions - * @param exception SDK Exception - * @return generated error string - */ - private fun getSdkExceptionText(exception: SdkException): String { - log.info("$LOG_PREFIX:SdkException $exception") - return "sendEmail Error, SDK status:${exception.message}" - } -} diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/channel/email/SmtpChannel.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/channel/email/SmtpChannel.kt deleted file mode 100644 index 4662c2aa..00000000 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/channel/email/SmtpChannel.kt +++ /dev/null @@ -1,93 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - * - * Modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -/* - * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - * - */ - -package org.opensearch.notifications.channel.email - -import com.sun.mail.util.MailConnectException -import org.opensearch.commons.notifications.model.ChannelMessage -import org.opensearch.commons.utils.logger -import org.opensearch.notifications.NotificationPlugin.Companion.LOG_PREFIX -import org.opensearch.notifications.model.ChannelMessageResponse -import org.opensearch.notifications.settings.PluginSettings -import org.opensearch.notifications.spi.utils.SecurityAccess -import org.opensearch.rest.RestStatus -import java.util.Properties -import javax.mail.MessagingException -import javax.mail.SendFailedException -import javax.mail.Session -import javax.mail.Transport -import javax.mail.internet.MimeMessage - -/** - * Notification channel for sending mail over SMTP server. - */ -internal object SmtpChannel : BaseEmailChannel() { - private val log by logger(javaClass) - - /** - * {@inheritDoc} - */ - override fun prepareSession(refTag: String, recipient: String, channelMessage: ChannelMessage): Session { - val prop = Properties() - prop["mail.transport.protocol"] = "smtp" - prop["mail.smtp.host"] = PluginSettings.smtpHost - prop["mail.smtp.port"] = PluginSettings.smtpPort - when (PluginSettings.smtpTransportMethod) { - "ssl" -> prop["mail.smtp.ssl.enable"] = true - "starttls" -> prop["mail.smtp.starttls.enable"] = true - } - return Session.getInstance(prop) - } - - /** - * {@inheritDoc} - */ - override fun sendMimeMessage(refTag: String, recipient: String, mimeMessage: MimeMessage): ChannelMessageResponse { - return try { - log.debug("$LOG_PREFIX:Sending Email-SMTP:$refTag") - SecurityAccess.doPrivileged { Transport.send(mimeMessage) } - log.info("$LOG_PREFIX:Email-SMTP:$refTag sent") - ChannelMessageResponse(recipient, RestStatus.OK, "Success") - } catch (exception: SendFailedException) { - ChannelMessageResponse(recipient, RestStatus.BAD_GATEWAY, getMessagingExceptionText(exception)) - } catch (exception: MailConnectException) { - ChannelMessageResponse(recipient, RestStatus.SERVICE_UNAVAILABLE, getMessagingExceptionText(exception)) - } catch (exception: MessagingException) { - ChannelMessageResponse(recipient, RestStatus.FAILED_DEPENDENCY, getMessagingExceptionText(exception)) - } - } - - /** - * Create error string from MessagingException - * @param exception Messaging Exception - * @return generated error string - */ - private fun getMessagingExceptionText(exception: MessagingException): String { - log.info("$LOG_PREFIX:EmailException $exception") - return "sendEmail Error, status:${exception.message}" - } -} diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/index/ConfigIndexingActions.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/index/ConfigIndexingActions.kt index 51ac6ebf..e8435e16 100644 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/index/ConfigIndexingActions.kt +++ b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/index/ConfigIndexingActions.kt @@ -43,23 +43,24 @@ import org.opensearch.commons.notifications.model.Chime import org.opensearch.commons.notifications.model.ConfigType import org.opensearch.commons.notifications.model.Email import org.opensearch.commons.notifications.model.EmailGroup -import org.opensearch.commons.notifications.model.Feature import org.opensearch.commons.notifications.model.FeatureChannel import org.opensearch.commons.notifications.model.FeatureChannelList import org.opensearch.commons.notifications.model.NotificationConfig import org.opensearch.commons.notifications.model.NotificationConfigInfo import org.opensearch.commons.notifications.model.NotificationConfigSearchResult +import org.opensearch.commons.notifications.model.SesAccount import org.opensearch.commons.notifications.model.Slack import org.opensearch.commons.notifications.model.SmtpAccount +import org.opensearch.commons.notifications.model.Sns import org.opensearch.commons.notifications.model.Webhook import org.opensearch.commons.utils.logger import org.opensearch.notifications.NotificationPlugin.Companion.LOG_PREFIX +import org.opensearch.notifications.metrics.Metrics import org.opensearch.notifications.model.DocMetadata import org.opensearch.notifications.model.NotificationConfigDoc import org.opensearch.notifications.security.UserAccess import org.opensearch.rest.RestStatus import java.time.Instant -import java.util.EnumSet /** * NotificationConfig indexing operation actions. @@ -91,7 +92,12 @@ object ConfigIndexingActions { // TODO: URL validation with rules } - private fun validateEmailConfig(email: Email, features: EnumSet, user: User?) { + @Suppress("UnusedPrivateMember") + private fun validateSnsConfig(sns: Sns, user: User?) { + // TODO: URL validation with rules + } + + private fun validateEmailConfig(email: Email, features: Set, user: User?) { if (email.emailGroupIds.contains(email.emailAccountID)) { throw OpenSearchStatusException( "Config IDs ${email.emailAccountID} is in both emailAccountID and emailGroupIds", @@ -112,6 +118,7 @@ object ConfigIndexingActions { when (it.configDoc.config.configType) { ConfigType.EMAIL_GROUP -> if (it.docInfo.id == email.emailAccountID) { // Email Group ID is specified as Email Account ID + Metrics.NOTIFICATIONS_CONFIG_USER_ERROR_INVALID_EMAIL_ACCOUNT_ID.counter.increment() throw OpenSearchStatusException( "configId ${it.docInfo.id} is not a valid email account ID", RestStatus.NOT_ACCEPTABLE @@ -119,6 +126,15 @@ object ConfigIndexingActions { } ConfigType.SMTP_ACCOUNT -> if (it.docInfo.id != email.emailAccountID) { // Email Account ID is specified as Email Group ID + Metrics.NOTIFICATIONS_CONFIG_USER_ERROR_INVALID_EMAIL_GROUP_ID.counter.increment() + throw OpenSearchStatusException( + "configId ${it.docInfo.id} is not a valid email group ID", + RestStatus.NOT_ACCEPTABLE + ) + } + ConfigType.SES_ACCOUNT -> if (it.docInfo.id != email.emailAccountID) { + // Email Account ID is specified as Email Group ID + Metrics.NOTIFICATIONS_CONFIG_USER_ERROR_INVALID_EMAIL_GROUP_ID.counter.increment() throw OpenSearchStatusException( "configId ${it.docInfo.id} is not a valid email group ID", RestStatus.NOT_ACCEPTABLE @@ -126,6 +142,7 @@ object ConfigIndexingActions { } else -> { // Config ID is neither Email Group ID or valid Email Account ID + Metrics.NOTIFICATIONS_CONFIG_USER_ERROR_NEITHER_EMAIL_NOR_GROUP.counter.increment() throw OpenSearchStatusException( "configId ${it.docInfo.id} is not a valid email group ID or email account ID", RestStatus.NOT_ACCEPTABLE @@ -135,6 +152,7 @@ object ConfigIndexingActions { // Validate that the user has access to underlying configurations as well. val currentMetadata = it.configDoc.metadata if (!userAccess.doesUserHasAccess(user, currentMetadata.tenant, currentMetadata.access)) { + Metrics.NOTIFICATIONS_PERMISSION_USER_ERROR.counter.increment() throw OpenSearchStatusException( "Permission denied for NotificationConfig ${it.docInfo.id}", RestStatus.FORBIDDEN @@ -146,6 +164,7 @@ object ConfigIndexingActions { val missingFeatures = features.filterNot { item -> it.configDoc.config.features.contains(item) } + Metrics.NOTIFICATIONS_SECURITY_USER_ERROR.counter.increment() throw OpenSearchStatusException( "Some Features not available in NotificationConfig ${it.docInfo.id}:$missingFeatures", RestStatus.FORBIDDEN @@ -159,6 +178,11 @@ object ConfigIndexingActions { // TODO: host validation with rules } + @Suppress("UnusedPrivateMember") + private fun validateSesAccountConfig(sesAccount: SesAccount, user: User?) { + // TODO: host validation with rules + } + @Suppress("UnusedPrivateMember") private fun validateEmailGroupConfig(emailGroup: EmailGroup, user: User?) { // No extra validation required. All email IDs are validated as part of model validation. @@ -175,7 +199,9 @@ object ConfigIndexingActions { ConfigType.WEBHOOK -> validateWebhookConfig(config.configData as Webhook, user) ConfigType.EMAIL -> validateEmailConfig(config.configData as Email, config.features, user) ConfigType.SMTP_ACCOUNT -> validateSmtpAccountConfig(config.configData as SmtpAccount, user) + ConfigType.SES_ACCOUNT -> validateSesAccountConfig(config.configData as SesAccount, user) ConfigType.EMAIL_GROUP -> validateEmailGroupConfig(config.configData as EmailGroup, user) + ConfigType.SNS -> validateSnsConfig(config.configData as Sns, user) } } @@ -198,10 +224,13 @@ object ConfigIndexingActions { ) val configDoc = NotificationConfigDoc(metadata, request.notificationConfig) val docId = operations.createNotificationConfig(configDoc, request.configId) - docId ?: throw OpenSearchStatusException( - "NotificationConfig Creation failed", - RestStatus.INTERNAL_SERVER_ERROR - ) + docId ?: run { + Metrics.NOTIFICATIONS_CONFIG_CREATE_SYSTEM_ERROR.counter.increment() + throw OpenSearchStatusException( + "NotificationConfig Creation failed", + RestStatus.INTERNAL_SERVER_ERROR + ) + } return CreateNotificationConfigResponse(docId) } @@ -218,6 +247,7 @@ object ConfigIndexingActions { val currentConfigDoc = operations.getNotificationConfig(request.configId) currentConfigDoc ?: run { + Metrics.NOTIFICATIONS_CONFIG_UPDATE_USER_ERROR_INVALID_CONFIG_ID.counter.increment() throw OpenSearchStatusException( "NotificationConfig ${request.configId} not found", RestStatus.NOT_FOUND @@ -226,14 +256,20 @@ object ConfigIndexingActions { val currentMetadata = currentConfigDoc.configDoc.metadata if (!userAccess.doesUserHasAccess(user, currentMetadata.tenant, currentMetadata.access)) { + Metrics.NOTIFICATIONS_PERMISSION_USER_ERROR.counter.increment() throw OpenSearchStatusException( "Permission denied for NotificationConfig ${request.configId}", RestStatus.FORBIDDEN ) } + if (currentConfigDoc.configDoc.config.configType != request.notificationConfig.configType) { + throw OpenSearchStatusException("Config type cannot be changed after creation", RestStatus.CONFLICT) + } + val newMetadata = currentMetadata.copy(lastUpdateTime = Instant.now()) val newConfigData = NotificationConfigDoc(newMetadata, request.notificationConfig) if (!operations.updateNotificationConfig(request.configId, newConfigData)) { + Metrics.NOTIFICATIONS_CONFIG_UPDATE_SYSTEM_ERROR.counter.increment() throw OpenSearchStatusException("NotificationConfig Update failed", RestStatus.INTERNAL_SERVER_ERROR) } return UpdateNotificationConfigResponse(request.configId) @@ -266,10 +302,12 @@ object ConfigIndexingActions { val configDoc = operations.getNotificationConfig(configId) configDoc ?: run { + Metrics.NOTIFICATIONS_CONFIG_INFO_USER_ERROR_INVALID_CONFIG_ID.counter.increment() throw OpenSearchStatusException("NotificationConfig $configId not found", RestStatus.NOT_FOUND) } val metadata = configDoc.configDoc.metadata if (!userAccess.doesUserHasAccess(user, metadata.tenant, metadata.access)) { + Metrics.NOTIFICATIONS_PERMISSION_USER_ERROR.counter.increment() throw OpenSearchStatusException("Permission denied for NotificationConfig $configId", RestStatus.FORBIDDEN) } val configInfo = NotificationConfigInfo( @@ -294,6 +332,7 @@ object ConfigIndexingActions { if (configDocs.size != configIds.size) { val mutableSet = configIds.toMutableSet() configDocs.forEach { mutableSet.remove(it.docInfo.id) } + Metrics.NOTIFICATIONS_CONFIG_INFO_USER_ERROR_SET_NOT_FOUND.counter.increment() throw OpenSearchStatusException( "NotificationConfig $mutableSet not found", RestStatus.NOT_FOUND @@ -302,6 +341,7 @@ object ConfigIndexingActions { configDocs.forEach { val currentMetadata = it.configDoc.metadata if (!userAccess.doesUserHasAccess(user, currentMetadata.tenant, currentMetadata.access)) { + Metrics.NOTIFICATIONS_PERMISSION_USER_ERROR.counter.increment() throw OpenSearchStatusException( "Permission denied for NotificationConfig ${it.docInfo.id}", RestStatus.FORBIDDEN @@ -347,7 +387,7 @@ object ConfigIndexingActions { userAccess.validateUser(user) val supportedChannelListString = getSupportedChannelList().joinToString(",") val filterParams = mapOf( - Pair("feature_list", request.feature.tag), + Pair("feature_list", request.feature), Pair("config_type", supportedChannelListString) ) val getAllRequest = GetNotificationConfigRequest(filterParams = filterParams) @@ -370,7 +410,8 @@ object ConfigIndexingActions { ConfigType.SLACK.tag, ConfigType.CHIME.tag, ConfigType.WEBHOOK.tag, - ConfigType.EMAIL.tag + ConfigType.EMAIL.tag, + ConfigType.SNS.tag ) } @@ -386,6 +427,7 @@ object ConfigIndexingActions { val currentConfigDoc = operations.getNotificationConfig(configId) currentConfigDoc ?: run { + Metrics.NOTIFICATIONS_CONFIG_DELETE_USER_ERROR_INVALID_CONFIG_ID.counter.increment() throw OpenSearchStatusException( "NotificationConfig $configId not found", RestStatus.NOT_FOUND @@ -394,12 +436,14 @@ object ConfigIndexingActions { val currentMetadata = currentConfigDoc.configDoc.metadata if (!userAccess.doesUserHasAccess(user, currentMetadata.tenant, currentMetadata.access)) { + Metrics.NOTIFICATIONS_PERMISSION_USER_ERROR.counter.increment() throw OpenSearchStatusException( "Permission denied for NotificationConfig $configId", RestStatus.FORBIDDEN ) } if (!operations.deleteNotificationConfig(configId)) { + Metrics.NOTIFICATIONS_CONFIG_DELETE_SYSTEM_ERROR.counter.increment() throw OpenSearchStatusException( "NotificationConfig $configId delete failed", RestStatus.REQUEST_TIMEOUT @@ -421,6 +465,7 @@ object ConfigIndexingActions { if (configDocs.size != configIds.size) { val mutableSet = configIds.toMutableSet() configDocs.forEach { mutableSet.remove(it.docInfo.id) } + Metrics.NOTIFICATIONS_CONFIG_DELETE_USER_ERROR_SET_NOT_FOUND.counter.increment() throw OpenSearchStatusException( "NotificationConfig $mutableSet not found", RestStatus.NOT_FOUND @@ -429,6 +474,7 @@ object ConfigIndexingActions { configDocs.forEach { val currentMetadata = it.configDoc.metadata if (!userAccess.doesUserHasAccess(user, currentMetadata.tenant, currentMetadata.access)) { + Metrics.NOTIFICATIONS_PERMISSION_USER_ERROR.counter.increment() throw OpenSearchStatusException( "Permission denied for NotificationConfig ${it.docInfo.id}", RestStatus.FORBIDDEN diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/index/ConfigQueryHelper.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/index/ConfigQueryHelper.kt index 13df647b..b0992ba9 100644 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/index/ConfigQueryHelper.kt +++ b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/index/ConfigQueryHelper.kt @@ -41,13 +41,18 @@ import org.opensearch.commons.notifications.NotificationConstants.METHOD_TAG import org.opensearch.commons.notifications.NotificationConstants.NAME_TAG import org.opensearch.commons.notifications.NotificationConstants.QUERY_TAG import org.opensearch.commons.notifications.NotificationConstants.RECIPIENT_LIST_TAG +import org.opensearch.commons.notifications.NotificationConstants.REGION_TAG +import org.opensearch.commons.notifications.NotificationConstants.ROLE_ARN_TAG +import org.opensearch.commons.notifications.NotificationConstants.TOPIC_ARN_TAG import org.opensearch.commons.notifications.NotificationConstants.UPDATED_TIME_TAG import org.opensearch.commons.notifications.NotificationConstants.URL_TAG import org.opensearch.commons.notifications.model.ConfigType.CHIME import org.opensearch.commons.notifications.model.ConfigType.EMAIL import org.opensearch.commons.notifications.model.ConfigType.EMAIL_GROUP +import org.opensearch.commons.notifications.model.ConfigType.SES_ACCOUNT import org.opensearch.commons.notifications.model.ConfigType.SLACK import org.opensearch.commons.notifications.model.ConfigType.SMTP_ACCOUNT +import org.opensearch.commons.notifications.model.ConfigType.SNS import org.opensearch.commons.notifications.model.ConfigType.WEBHOOK import org.opensearch.index.query.BoolQueryBuilder import org.opensearch.index.query.QueryBuilder @@ -73,7 +78,8 @@ object ConfigQueryHelper { FEATURE_LIST_TAG, "${EMAIL.tag}.$EMAIL_ACCOUNT_ID_TAG", "${EMAIL.tag}.$EMAIL_GROUP_ID_LIST_TAG", - "${SMTP_ACCOUNT.tag}.$METHOD_TAG" + "${SMTP_ACCOUNT.tag}.$METHOD_TAG", + "${SES_ACCOUNT.tag}.$REGION_TAG" ) private val TEXT_FIELDS = setOf( NAME_TAG, @@ -84,7 +90,11 @@ object ConfigQueryHelper { "${EMAIL.tag}.$RECIPIENT_LIST_TAG", "${SMTP_ACCOUNT.tag}.$HOST_TAG", "${SMTP_ACCOUNT.tag}.$FROM_ADDRESS_TAG", - "${EMAIL_GROUP.tag}.$RECIPIENT_LIST_TAG" + "${EMAIL_GROUP.tag}.$RECIPIENT_LIST_TAG", + "${SNS.tag}.$TOPIC_ARN_TAG", + "${SNS.tag}.$ROLE_ARN_TAG", + "${SES_ACCOUNT.tag}.$ROLE_ARN_TAG", + "${SES_ACCOUNT.tag}.$FROM_ADDRESS_TAG" ) private val METADATA_FIELDS = METADATA_RANGE_FIELDS diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/index/EventIndexingActions.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/index/EventIndexingActions.kt index 29386307..99f6841d 100644 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/index/EventIndexingActions.kt +++ b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/index/EventIndexingActions.kt @@ -35,6 +35,7 @@ import org.opensearch.commons.notifications.model.NotificationEventInfo import org.opensearch.commons.notifications.model.NotificationEventSearchResult import org.opensearch.commons.utils.logger import org.opensearch.notifications.NotificationPlugin.Companion.LOG_PREFIX +import org.opensearch.notifications.metrics.Metrics import org.opensearch.notifications.security.UserAccess import org.opensearch.rest.RestStatus @@ -79,10 +80,12 @@ object EventIndexingActions { val eventDoc = operations.getNotificationEvent(eventId) eventDoc ?: run { + Metrics.NOTIFICATIONS_EVENTS_INFO_USER_ERROR_INVALID_CONFIG_ID.counter.increment() throw OpenSearchStatusException("NotificationEvent $eventId not found", RestStatus.NOT_FOUND) } val metadata = eventDoc.eventDoc.metadata if (!userAccess.doesUserHasAccess(user, metadata.tenant, metadata.access)) { + Metrics.NOTIFICATIONS_PERMISSION_USER_ERROR.counter.increment() throw OpenSearchStatusException("Permission denied for NotificationEvent $eventId", RestStatus.FORBIDDEN) } val eventInfo = NotificationEventInfo( @@ -107,6 +110,7 @@ object EventIndexingActions { if (eventDocs.size != eventIds.size) { val mutableSet = eventIds.toMutableSet() eventDocs.forEach { mutableSet.remove(it.docInfo.id) } + Metrics.NOTIFICATIONS_EVENTS_INFO_SYSTEM_ERROR.counter.increment() throw OpenSearchStatusException( "NotificationEvent $mutableSet not found", RestStatus.NOT_FOUND @@ -115,6 +119,7 @@ object EventIndexingActions { eventDocs.forEach { val currentMetadata = it.eventDoc.metadata if (!userAccess.doesUserHasAccess(user, currentMetadata.tenant, currentMetadata.access)) { + Metrics.NOTIFICATIONS_PERMISSION_USER_ERROR.counter.increment() throw OpenSearchStatusException( "Permission denied for NotificationEvent ${it.docInfo.id}", RestStatus.FORBIDDEN diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/metrics/BasicCounter.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/metrics/BasicCounter.kt new file mode 100644 index 00000000..b7c7ad45 --- /dev/null +++ b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/metrics/BasicCounter.kt @@ -0,0 +1,47 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.notifications.metrics + +import java.util.concurrent.atomic.LongAdder + +/** + * Counter to hold accumulative value over time. + */ +class BasicCounter : Counter { + private val count = LongAdder() + + /** + * {@inheritDoc} + */ + override fun increment() { + count.increment() + } + + /** + * {@inheritDoc} + */ + override fun add(n: Long) { + count.add(n) + } + + /** + * {@inheritDoc} + */ + override fun getValue(): Long { + return count.toLong() + } + + /** Reset the count value to zero */ + override fun reset() { + count.reset() + } +} diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/metrics/Counter.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/metrics/Counter.kt new file mode 100644 index 00000000..554cca72 --- /dev/null +++ b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/metrics/Counter.kt @@ -0,0 +1,28 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ +package org.opensearch.notifications.metrics + +/** + * Defines a generic counter. + */ +interface Counter { + /** Increments the count value by 1 unit */ + fun increment() + + /** Increments the count value by n unit */ + fun add(n: Long) + + /** Retrieves the count value accumulated up to this call */ + fun getValue(): Long + + /** Resets the count value to initial value when Counter is created */ + fun reset() +} diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/metrics/Metrics.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/metrics/Metrics.kt new file mode 100644 index 00000000..cd26a501 --- /dev/null +++ b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/metrics/Metrics.kt @@ -0,0 +1,281 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ +package org.opensearch.notifications.metrics + +import com.github.wnameless.json.unflattener.JsonUnflattener +import org.json.JSONObject + +/** + * Enum to hold all the metrics that need to be logged into _plugins/_notifications/local/stats API + */ +enum class Metrics(val metricName: String, val counter: Counter<*>) { + REQUEST_TOTAL("request_total", BasicCounter()), REQUEST_INTERVAL_COUNT( + "request_count", + RollingCounter() + ), + REQUEST_SUCCESS("success_count", RollingCounter()), REQUEST_USER_ERROR( + "failed_request_count_user_error", + RollingCounter() + ), + REQUEST_SYSTEM_ERROR("failed_request_count_system_error", RollingCounter()), + + /** + * Exceptions from: + * @see org.opensearch.notifications.action.PluginBaseAction + */ + NOTIFICATIONS_EXCEPTIONS_OS_STATUS_EXCEPTION( + "exception.os_status", + RollingCounter() + ), + NOTIFICATIONS_EXCEPTIONS_OS_SECURITY_EXCEPTION( + "exception.os_security", + RollingCounter() + ), + NOTIFICATIONS_EXCEPTIONS_VERSION_CONFLICT_ENGINE_EXCEPTION( + "exception.version_conflict_engine", RollingCounter() + ), + NOTIFICATIONS_EXCEPTIONS_INDEX_NOT_FOUND_EXCEPTION( + "exception.index_not_found", + RollingCounter() + ), + NOTIFICATIONS_EXCEPTIONS_INVALID_INDEX_NAME_EXCEPTION( + "exception.invalid_index_name", + RollingCounter() + ), + NOTIFICATIONS_EXCEPTIONS_ILLEGAL_ARGUMENT_EXCEPTION( + "exception.illegal_argument", + RollingCounter() + ), + NOTIFICATIONS_EXCEPTIONS_ILLEGAL_STATE_EXCEPTION( + "exception.illegal_state", + RollingCounter() + ), + NOTIFICATIONS_EXCEPTIONS_IO_EXCEPTION( + "exception.io", + RollingCounter() + ), + NOTIFICATIONS_EXCEPTIONS_INTERNAL_SERVER_ERROR( + "exception.internal_server_error", + RollingCounter() + ), // ==== Per REST endpoint metrics ==== // + + // Config Endpoints + // POST _plugins/_notifications/configs, Create a new notification config + NOTIFICATIONS_CONFIG_CREATE_TOTAL( + "notifications_config.create.total", + BasicCounter() + ), + NOTIFICATIONS_CONFIG_CREATE_INTERVAL_COUNT( + "notifications_config.create.count", + RollingCounter() + ), + NOTIFICATIONS_CONFIG_CREATE_SYSTEM_ERROR( + "notifications_config.create.system_error", + RollingCounter() + ), // PUT _plugins/_notifications/configs/{configId}, Update a notification config + NOTIFICATIONS_CONFIG_UPDATE_TOTAL( + "notifications_config.update.total", + BasicCounter() + ), + NOTIFICATIONS_CONFIG_UPDATE_INTERVAL_COUNT( + "notifications_config.update.count", + RollingCounter() + ), + NOTIFICATIONS_CONFIG_UPDATE_USER_ERROR_INVALID_CONFIG_ID( + "notifications_config.update.user_error.invalid_config_id", RollingCounter() + ), + NOTIFICATIONS_CONFIG_UPDATE_SYSTEM_ERROR( + "notifications_config.update.system_error", + RollingCounter() + ), // Notification config general user error + NOTIFICATIONS_CONFIG_USER_ERROR_INVALID_EMAIL_ACCOUNT_ID( + "notifications_config.user_error.invalid_email_account_id", RollingCounter() + ), + NOTIFICATIONS_CONFIG_USER_ERROR_INVALID_EMAIL_GROUP_ID( + "notifications_config.user_error.invalid_email_group_id", RollingCounter() + ), + NOTIFICATIONS_CONFIG_USER_ERROR_NEITHER_EMAIL_NOR_GROUP( + "notifications_config.user_error.neither_email_nor_group", RollingCounter() + ), // DELETE _plugins/_notifications/configs/{configId}, Delete a notification config + NOTIFICATIONS_CONFIG_DELETE_TOTAL( + "notifications_config.delete.total", + BasicCounter() + ), + NOTIFICATIONS_CONFIG_DELETE_INTERVAL_COUNT( + "notifications_config.delete.count", + RollingCounter() + ), + NOTIFICATIONS_CONFIG_DELETE_USER_ERROR_INVALID_CONFIG_ID( + "notifications_config.delete.user_error.invalid_config_id", RollingCounter() + ), + NOTIFICATIONS_CONFIG_DELETE_USER_ERROR_SET_NOT_FOUND( + "notifications_config.delete.user_error.set_not_found", RollingCounter() + ), + NOTIFICATIONS_CONFIG_DELETE_SYSTEM_ERROR( + "notifications_config.delete.system_error", + RollingCounter() + ), // GET _plugins/_notifications/configs/{configId} + NOTIFICATIONS_CONFIG_INFO_TOTAL( + "notifications_config.info.total", + BasicCounter() + ), + NOTIFICATIONS_CONFIG_INFO_INTERVAL_COUNT( + "notifications_config.info.count", + RollingCounter() + ), // add specific user errors for config GET operations + NOTIFICATIONS_CONFIG_INFO_USER_ERROR_INVALID_CONFIG_ID( + "notifications_config.info.user_error.invalid_config_id", RollingCounter() + ), + NOTIFICATIONS_CONFIG_INFO_USER_ERROR_SET_NOT_FOUND( + "notifications_config.info.user_error.set_not_found", RollingCounter() + ), + NOTIFICATIONS_CONFIG_INFO_SYSTEM_ERROR( + "notifications_config.info.system_error", + RollingCounter() + ), + // Event Endpoints + // GET _plugins/_notifications/events/{configId} + NOTIFICATIONS_EVENTS_INFO_TOTAL( + "notifications_events.info.total", + BasicCounter() + ), + NOTIFICATIONS_EVENTS_INFO_INTERVAL_COUNT( + "notifications_events.info.count", + RollingCounter() + ), + NOTIFICATIONS_EVENTS_INFO_USER_ERROR_INVALID_CONFIG_ID( + "notifications_events.info.user_error.invalid_config_id", RollingCounter() + ), + NOTIFICATIONS_EVENTS_INFO_SYSTEM_ERROR( + "notifications_events.info.system_error", RollingCounter() + ), + // Feature Channels Endpoints + // GET _plugins/_notifications/feature/channels/{featureTag} + NOTIFICATIONS_FEATURE_CHANNELS_INFO_TOTAL( + "notifications_feature_channels.info.total", + BasicCounter() + ), + NOTIFICATIONS_FEATURE_CHANNELS_INFO_INTERVAL_COUNT( + "notifications_feature_channels.info.count", RollingCounter() + ), + NOTIFICATIONS_FEATURE_CHANNELS_INFO_USER_ERROR_INVALID_FEATURE_TAG( + "notifications_feature_channels.info.user_error.invalid_feature_tag", RollingCounter() + ), + NOTIFICATIONS_FEATURE_CHANNELS_INFO_SYSTEM_ERROR( + "notifications_feature_channels.info.system_error", RollingCounter() + ), + // Features Endpoints + // GET _plugins/_notifications/features + NOTIFICATIONS_FEATURES_INFO_TOTAL( + "notifications_features.info.total", + BasicCounter() + ), + NOTIFICATIONS_FEATURES_INFO_INTERVAL_COUNT( + "notifications_features.info.count", + RollingCounter() + ), + NOTIFICATIONS_FEATURES_INFO_SYSTEM_ERROR( + "notifications_features.info.system_error", + RollingCounter() + ), + // Send Message Endpoints + // POST _plugins/_notifications/send + NOTIFICATIONS_SEND_MESSAGE_TOTAL( + "notifications.send_message.total", + BasicCounter() + ), + NOTIFICATIONS_SEND_MESSAGE_INTERVAL_COUNT( + "notifications.send_message.count", + RollingCounter() + ), // user errors for send message? + NOTIFICATIONS_SEND_MESSAGE_USER_ERROR_NOT_FOUND( + "notifications.send_message.user_error.not_found", RollingCounter() + ), + NOTIFICATIONS_SEND_MESSAGE_SYSTEM_ERROR( + "notifications.send_message.system_error", + RollingCounter() + ), // Track message destinations + NOTIFICATIONS_MESSAGE_DESTINATION_SLACK( + "notifications.message_destination.slack", + BasicCounter() + ), + NOTIFICATIONS_MESSAGE_DESTINATION_CHIME( + "notifications.message_destination.chime", + BasicCounter() + ), + NOTIFICATIONS_MESSAGE_DESTINATION_WEBHOOK( + "notifications.message_destination.webhook", + BasicCounter() + ), + NOTIFICATIONS_MESSAGE_DESTINATION_EMAIL( + "notifications.message_destination.email", + BasicCounter() + ), + NOTIFICATIONS_MESSAGE_DESTINATION_SES_ACCOUNT( + "notifications.message_destination.ses_account", BasicCounter() + ), + NOTIFICATIONS_MESSAGE_DESTINATION_SMTP_ACCOUNT( + "notifications.message_destination.smtp_account", BasicCounter() + ), + NOTIFICATIONS_MESSAGE_DESTINATION_EMAIL_GROUP( + "notifications.message_destination.email_group", BasicCounter() + ), // TODO: add after implementation added + NOTIFICATIONS_MESSAGE_DESTINATION_SNS( + "notifications.message_destination.sns", + BasicCounter() + ), + // Send Test Message Endpoints + // GET _plugins/_notifications/feature/test/{configId} + NOTIFICATIONS_SEND_TEST_MESSAGE_TOTAL( + "notifications.send_test_message.total", + BasicCounter() + ), + NOTIFICATIONS_SEND_TEST_MESSAGE_INTERVAL_COUNT( + "notifications.send_test_message.interval_count", RollingCounter() + ), // Send test message exceptions are thrown by the Send Message Action + NOTIFICATIONS_SECURITY_USER_ERROR( + "security_user_error", + RollingCounter() + ), + NOTIFICATIONS_PERMISSION_USER_ERROR("permissions_user_error", RollingCounter()); + + companion object { + private val values = values() + + /** + * Converts the enum metric values to JSON string + */ + fun collectToJSON(): String { + val metricsJSONObject = JSONObject() + for (metric in values) { + metricsJSONObject.put(metric.metricName, metric.counter.getValue()) + } + return metricsJSONObject.toString() + } + + /** + * Unflattens the JSON to nested JSON for easy readability and parsing + * The metric name is unflattened in the output JSON on the period '.' delimiter + * + * For ex: { "a.b.c_d" : 2 } becomes + * { + * "a" : { + * "b" : { + * "c_d" : 2 + * } + * } + * } + */ + fun collectToFlattenedJSON(): String { + return JsonUnflattener.unflatten(collectToJSON()) + } + } +} diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/metrics/RollingCounter.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/metrics/RollingCounter.kt new file mode 100644 index 00000000..023dfeff --- /dev/null +++ b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/metrics/RollingCounter.kt @@ -0,0 +1,89 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ +package org.opensearch.notifications.metrics + +import java.time.Clock +import java.util.concurrent.ConcurrentSkipListMap +import kotlin.jvm.JvmOverloads + +/** + * Rolling counter. The count is refreshed every interval. In every interval the count is cumulative. + */ +class RollingCounter @JvmOverloads constructor( + private val window: Long = METRICS_ROLLING_WINDOW_VALUE, + private val interval: Long = METRICS_ROLLING_INTERVAL_VALUE, + private val clock: Clock = Clock.systemDefaultZone() +) : Counter { + private val capacity: Long = window / interval * 2 + private val timeToCountMap = ConcurrentSkipListMap() + + /** + * {@inheritDoc} + */ + override fun increment() { + add(1L) + } + + /** + * {@inheritDoc} + */ + override fun add(n: Long) { + trim() + timeToCountMap.compute(getKey(clock.millis())) { k: Long?, v: Long? -> if (v == null) n else v + n } + } + + /** + * {@inheritDoc} + */ + override fun getValue(): Long { + return getValue(getPreKey(clock.millis())) + } + + /** + * {@inheritDoc} + */ + fun getValue(key: Long): Long { + return timeToCountMap[key] ?: return 0 + } + + private fun trim() { + if (timeToCountMap.size > capacity) { + timeToCountMap.headMap(getKey(clock.millis() - window * 1000)).clear() + } + } + + private fun getKey(millis: Long): Long { + return millis / 1000 / interval + } + + private fun getPreKey(millis: Long): Long { + return getKey(millis) - 1 + } + + /** + * Number of existing intervals + */ + fun size(): Int { + return timeToCountMap.size + } + + /** + * Remove all the items from counter + */ + override fun reset() { + timeToCountMap.clear() + } + + companion object { + private const val METRICS_ROLLING_WINDOW_VALUE = 3600L + private const val METRICS_ROLLING_INTERVAL_VALUE = 60L + } +} diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/resthandler/NotificationConfigRestHandler.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/resthandler/NotificationConfigRestHandler.kt index ec214bc0..fb2163c1 100644 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/resthandler/NotificationConfigRestHandler.kt +++ b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/resthandler/NotificationConfigRestHandler.kt @@ -44,6 +44,7 @@ import org.opensearch.commons.utils.logger import org.opensearch.notifications.NotificationPlugin.Companion.LOG_PREFIX import org.opensearch.notifications.NotificationPlugin.Companion.PLUGIN_BASE_URI import org.opensearch.notifications.index.ConfigQueryHelper +import org.opensearch.notifications.metrics.Metrics import org.opensearch.rest.BaseRestHandler.RestChannelConsumer import org.opensearch.rest.BytesRestResponse import org.opensearch.rest.RestHandler.Route @@ -143,6 +144,11 @@ internal class NotificationConfigRestHandler : PluginBaseHandler() { * smtp_account.host=domain * smtp_account.from_address=abc,xyz * smtp_account.recipient_list=abc,xyz + * sns.topic_arn=abc,xyz + * sns.role_arn=abc,xyz + * ses_account.region=abc,xyz + * ses_account.role_arn=abc,xyz + * ses_account.from_address=abc,xyz * query=search all above fields * Request body: Ref [org.opensearch.commons.notifications.action.GetNotificationConfigRequest] * Response body: [org.opensearch.commons.notifications.action.GetNotificationConfigResponse] @@ -185,6 +191,8 @@ internal class NotificationConfigRestHandler : PluginBaseHandler() { request: RestRequest, client: NodeClient ) = RestChannelConsumer { + Metrics.NOTIFICATIONS_CONFIG_UPDATE_TOTAL.counter.increment() + Metrics.NOTIFICATIONS_CONFIG_UPDATE_INTERVAL_COUNT.counter.increment() NotificationsPluginInterface.updateNotificationConfig( client, UpdateNotificationConfigRequest.parse( @@ -199,6 +207,8 @@ internal class NotificationConfigRestHandler : PluginBaseHandler() { request: RestRequest, client: NodeClient ) = RestChannelConsumer { + Metrics.NOTIFICATIONS_CONFIG_CREATE_TOTAL.counter.increment() + Metrics.NOTIFICATIONS_CONFIG_CREATE_INTERVAL_COUNT.counter.increment() NotificationsPluginInterface.createNotificationConfig( client, CreateNotificationConfigRequest.parse(request.contentParserNextToken()), @@ -210,6 +220,8 @@ internal class NotificationConfigRestHandler : PluginBaseHandler() { request: RestRequest, client: NodeClient ): RestChannelConsumer { + Metrics.NOTIFICATIONS_CONFIG_INFO_TOTAL.counter.increment() + Metrics.NOTIFICATIONS_CONFIG_INFO_INTERVAL_COUNT.counter.increment() val configId: String? = request.param(CONFIG_ID_TAG) val configIdList: String? = request.param(CONFIG_ID_LIST_TAG) val sortField: String? = request.param(SORT_FIELD_TAG) @@ -261,6 +273,8 @@ internal class NotificationConfigRestHandler : PluginBaseHandler() { request: RestRequest, client: NodeClient ): RestChannelConsumer { + Metrics.NOTIFICATIONS_CONFIG_DELETE_TOTAL.counter.increment() + Metrics.NOTIFICATIONS_CONFIG_DELETE_INTERVAL_COUNT.counter.increment() val configId: String? = request.param(CONFIG_ID_TAG) val configIdSet: Set = request.paramAsStringArray(CONFIG_ID_LIST_TAG, arrayOf(configId)) diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/resthandler/NotificationEventRestHandler.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/resthandler/NotificationEventRestHandler.kt index cf7f5a5f..b444e96e 100644 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/resthandler/NotificationEventRestHandler.kt +++ b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/resthandler/NotificationEventRestHandler.kt @@ -40,6 +40,7 @@ import org.opensearch.commons.utils.logger import org.opensearch.notifications.NotificationPlugin.Companion.LOG_PREFIX import org.opensearch.notifications.NotificationPlugin.Companion.PLUGIN_BASE_URI import org.opensearch.notifications.index.EventQueryHelper +import org.opensearch.notifications.metrics.Metrics import org.opensearch.rest.BaseRestHandler.RestChannelConsumer import org.opensearch.rest.BytesRestResponse import org.opensearch.rest.RestHandler.Route @@ -144,6 +145,8 @@ internal class NotificationEventRestHandler : PluginBaseHandler() { request: RestRequest, client: NodeClient ): RestChannelConsumer { + Metrics.NOTIFICATIONS_EVENTS_INFO_TOTAL.counter.increment() + Metrics.NOTIFICATIONS_EVENTS_INFO_INTERVAL_COUNT.counter.increment() val eventId: String? = request.param(EVENT_ID_TAG) val eventIdList: String? = request.param(EVENT_ID_LIST_TAG) val sortField: String? = request.param(SORT_FIELD_TAG) diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/resthandler/NotificationFeatureChannelListRestHandler.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/resthandler/NotificationFeatureChannelListRestHandler.kt index f8e56907..b947fd93 100644 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/resthandler/NotificationFeatureChannelListRestHandler.kt +++ b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/resthandler/NotificationFeatureChannelListRestHandler.kt @@ -15,8 +15,8 @@ import org.opensearch.client.node.NodeClient import org.opensearch.commons.notifications.NotificationConstants.FEATURE_TAG import org.opensearch.commons.notifications.NotificationsPluginInterface import org.opensearch.commons.notifications.action.GetFeatureChannelListRequest -import org.opensearch.commons.notifications.model.Feature import org.opensearch.notifications.NotificationPlugin.Companion.PLUGIN_BASE_URI +import org.opensearch.notifications.metrics.Metrics import org.opensearch.rest.BaseRestHandler.RestChannelConsumer import org.opensearch.rest.BytesRestResponse import org.opensearch.rest.RestHandler.Route @@ -71,7 +71,9 @@ internal class NotificationFeatureChannelListRestHandler : PluginBaseHandler() { override fun executeRequest(request: RestRequest, client: NodeClient): RestChannelConsumer { return when (request.method()) { GET -> { - val feature = Feature.fromTagOrDefault(request.param(FEATURE_TAG)) + Metrics.NOTIFICATIONS_FEATURE_CHANNELS_INFO_TOTAL.counter.increment() + Metrics.NOTIFICATIONS_FEATURE_CHANNELS_INFO_INTERVAL_COUNT.counter.increment() + val feature = request.param(FEATURE_TAG) RestChannelConsumer { NotificationsPluginInterface.getFeatureChannelList( client, diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/resthandler/NotificationFeaturesRestHandler.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/resthandler/NotificationFeaturesRestHandler.kt index 007b363c..f873fd30 100644 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/resthandler/NotificationFeaturesRestHandler.kt +++ b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/resthandler/NotificationFeaturesRestHandler.kt @@ -15,6 +15,7 @@ import org.opensearch.client.node.NodeClient import org.opensearch.commons.notifications.NotificationsPluginInterface import org.opensearch.commons.notifications.action.GetPluginFeaturesRequest import org.opensearch.notifications.NotificationPlugin.Companion.PLUGIN_BASE_URI +import org.opensearch.notifications.metrics.Metrics import org.opensearch.rest.BaseRestHandler.RestChannelConsumer import org.opensearch.rest.BytesRestResponse import org.opensearch.rest.RestHandler.Route @@ -69,6 +70,8 @@ internal class NotificationFeaturesRestHandler : PluginBaseHandler() { override fun executeRequest(request: RestRequest, client: NodeClient): RestChannelConsumer { return when (request.method()) { GET -> RestChannelConsumer { + Metrics.NOTIFICATIONS_FEATURES_INFO_TOTAL.counter.increment() + Metrics.NOTIFICATIONS_FEATURES_INFO_INTERVAL_COUNT.counter.increment() NotificationsPluginInterface.getPluginFeatures( client, GetPluginFeaturesRequest(), diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/resthandler/NotificationStatsRestHandler.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/resthandler/NotificationStatsRestHandler.kt new file mode 100644 index 00000000..976eeb72 --- /dev/null +++ b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/resthandler/NotificationStatsRestHandler.kt @@ -0,0 +1,69 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.notifications.resthandler + +import org.opensearch.client.node.NodeClient +import org.opensearch.notifications.NotificationPlugin.Companion.PLUGIN_BASE_URI +import org.opensearch.notifications.metrics.Metrics +import org.opensearch.rest.BaseRestHandler +import org.opensearch.rest.BaseRestHandler.RestChannelConsumer +import org.opensearch.rest.BytesRestResponse +import org.opensearch.rest.RestHandler.Route +import org.opensearch.rest.RestRequest +import org.opensearch.rest.RestRequest.Method.GET +import org.opensearch.rest.RestStatus + +/** + * Rest handler for getting notifications backend stats + */ +internal class NotificationStatsRestHandler : BaseRestHandler() { + companion object { + private const val NOTIFICATION_STATS_ACTION = "notification_stats" + private const val NOTIFICATION_STATS_URL = "$PLUGIN_BASE_URI/_local/stats" + } + + /** + * {@inheritDoc} + */ + override fun getName(): String { + return NOTIFICATION_STATS_ACTION + } + + /** + * {@inheritDoc} + */ + override fun routes(): List { + return listOf() + } + + /** + * {@inheritDoc} + */ + override fun responseParams(): Set { + return setOf() + } + + /** + * {@inheritDoc} + */ + override fun prepareRequest(request: RestRequest, client: NodeClient): RestChannelConsumer { + return when (request.method()) { + // TODO: Wrap this into TransportAction + GET -> RestChannelConsumer { + it.sendResponse(BytesRestResponse(RestStatus.OK, Metrics.collectToFlattenedJSON())) + } + else -> RestChannelConsumer { + it.sendResponse(BytesRestResponse(RestStatus.METHOD_NOT_ALLOWED, "${request.method()} is not allowed")) + } + } + } +} diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/resthandler/PluginBaseHandler.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/resthandler/PluginBaseHandler.kt index 5ca5880c..c564ce7f 100644 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/resthandler/PluginBaseHandler.kt +++ b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/resthandler/PluginBaseHandler.kt @@ -27,6 +27,7 @@ package org.opensearch.notifications.resthandler import org.opensearch.client.node.NodeClient +import org.opensearch.notifications.metrics.Metrics import org.opensearch.rest.BaseRestHandler import org.opensearch.rest.RestRequest @@ -39,6 +40,8 @@ abstract class PluginBaseHandler : BaseRestHandler() { * {@inheritDoc} */ override fun prepareRequest(request: RestRequest, client: NodeClient): RestChannelConsumer { + Metrics.REQUEST_TOTAL.counter.increment() + Metrics.REQUEST_INTERVAL_COUNT.counter.increment() return executeRequest(request, client) } diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/resthandler/RestResponseToXContentListener.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/resthandler/RestResponseToXContentListener.kt index ab5bf5e1..1b6ea5b6 100644 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/resthandler/RestResponseToXContentListener.kt +++ b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/resthandler/RestResponseToXContentListener.kt @@ -27,8 +27,12 @@ package org.opensearch.notifications.resthandler +import org.opensearch.common.xcontent.XContentBuilder import org.opensearch.commons.notifications.action.BaseResponse +import org.opensearch.notifications.metrics.Metrics +import org.opensearch.rest.BytesRestResponse import org.opensearch.rest.RestChannel +import org.opensearch.rest.RestResponse import org.opensearch.rest.RestStatus import org.opensearch.rest.action.RestToXContentListener @@ -39,6 +43,20 @@ import org.opensearch.rest.action.RestToXContentListener */ internal class RestResponseToXContentListener(channel: RestChannel) : RestToXContentListener(channel) { + override fun buildResponse(response: Response, builder: XContentBuilder?): RestResponse { + super.buildResponse(response, builder) + + Metrics.REQUEST_TOTAL.counter.increment() + Metrics.REQUEST_INTERVAL_COUNT.counter.increment() + + when (response.getStatus()) { + in RestStatus.OK..RestStatus.MULTI_STATUS -> Metrics.REQUEST_SUCCESS.counter.increment() + RestStatus.FORBIDDEN -> Metrics.NOTIFICATIONS_SECURITY_USER_ERROR.counter.increment() + in RestStatus.UNAUTHORIZED..RestStatus.TOO_MANY_REQUESTS -> Metrics.REQUEST_USER_ERROR.counter.increment() + else -> Metrics.REQUEST_SYSTEM_ERROR.counter.increment() + } + return BytesRestResponse(getStatus(response), builder) + } /** * {@inheritDoc} */ diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/resthandler/SendMessageRestHandler.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/resthandler/SendMessageRestHandler.kt deleted file mode 100644 index a4aaa4c4..00000000 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/resthandler/SendMessageRestHandler.kt +++ /dev/null @@ -1,91 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - * - * Modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -/* - * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - * - */ - -package org.opensearch.notifications.resthandler - -import org.opensearch.client.node.NodeClient -import org.opensearch.commons.utils.contentParserNextToken -import org.opensearch.notifications.NotificationPlugin.Companion.PLUGIN_BASE_URI -import org.opensearch.notifications.action.SendMessageAction -import org.opensearch.notifications.model.SendMessageRequest -import org.opensearch.rest.BaseRestHandler -import org.opensearch.rest.BaseRestHandler.RestChannelConsumer -import org.opensearch.rest.BytesRestResponse -import org.opensearch.rest.RestHandler.Route -import org.opensearch.rest.RestRequest -import org.opensearch.rest.RestRequest.Method.POST -import org.opensearch.rest.RestStatus - -/** - * Rest handler for sending notification. - * This handler [SendAction] for sending notification. - */ -internal class SendMessageRestHandler : BaseRestHandler() { - - internal companion object { - const val SEND_BASE_URI = "$PLUGIN_BASE_URI/send" - } - - /** - * {@inheritDoc} - */ - override fun getName(): String = "send_message" - - /** - * {@inheritDoc} - */ - override fun routes(): List { - return listOf( - Route(POST, SEND_BASE_URI) - ) - } - - /** - * {@inheritDoc} - */ - override fun responseParams(): Set { - return setOf() - } - - /** - * {@inheritDoc} - */ - override fun prepareRequest(request: RestRequest, client: NodeClient): RestChannelConsumer { - return when (request.method()) { - POST -> RestChannelConsumer { - client.execute( - SendMessageAction.ACTION_TYPE, - SendMessageRequest(request.contentParserNextToken()), - RestResponseToXContentListener(it) - ) - } - else -> RestChannelConsumer { - it.sendResponse(BytesRestResponse(RestStatus.METHOD_NOT_ALLOWED, "${request.method()} is not allowed")) - } - } - } -} diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/resthandler/SendTestMessageRestHandler.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/resthandler/SendTestMessageRestHandler.kt index 3795f73b..18b342f3 100644 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/resthandler/SendTestMessageRestHandler.kt +++ b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/resthandler/SendTestMessageRestHandler.kt @@ -17,9 +17,9 @@ import org.opensearch.commons.notifications.NotificationConstants.FEATURE_TAG import org.opensearch.commons.notifications.NotificationsPluginInterface import org.opensearch.commons.notifications.model.ChannelMessage import org.opensearch.commons.notifications.model.EventSource -import org.opensearch.commons.notifications.model.Feature import org.opensearch.commons.notifications.model.SeverityType import org.opensearch.notifications.NotificationPlugin.Companion.PLUGIN_BASE_URI +import org.opensearch.notifications.metrics.Metrics import org.opensearch.rest.BaseRestHandler.RestChannelConsumer import org.opensearch.rest.BytesRestResponse import org.opensearch.rest.RestHandler.Route @@ -84,7 +84,9 @@ internal class SendTestMessageRestHandler : PluginBaseHandler() { request: RestRequest, client: NodeClient ) = RestChannelConsumer { - val feature = Feature.fromTagOrDefault(request.param(FEATURE_TAG, Feature.NONE.tag)) + Metrics.NOTIFICATIONS_SEND_TEST_MESSAGE_TOTAL.counter.increment() + Metrics.NOTIFICATIONS_SEND_TEST_MESSAGE_INTERVAL_COUNT.counter.increment() + val feature = request.param(FEATURE_TAG) val configId = request.param(CONFIG_ID_TAG) val source = generateEventSource(feature, configId) val message = ChannelMessage( @@ -102,7 +104,7 @@ internal class SendTestMessageRestHandler : PluginBaseHandler() { ) } - private fun generateEventSource(feature: Feature, configId: String): EventSource { + private fun generateEventSource(feature: String, configId: String): EventSource { return EventSource( getMessageTitle(feature, configId), configId, @@ -111,20 +113,20 @@ internal class SendTestMessageRestHandler : PluginBaseHandler() { ) } - private fun getMessageTitle(feature: Feature, configId: String): String { + private fun getMessageTitle(feature: String, configId: String): String { return "[$feature] Test Message Title-$configId" // TODO: change as spec } - private fun getMessageTextDescription(feature: Feature, configId: String): String { - return "Test message content body for config id $configId\nfrom feature ${feature.tag}" // TODO: change as spec + private fun getMessageTextDescription(feature: String, configId: String): String { + return "Test message content body for config id $configId\nfrom feature $feature" // TODO: change as spec } - private fun getMessageHtmlDescription(feature: Feature, configId: String): String { + private fun getMessageHtmlDescription(feature: String, configId: String): String { return """
Test Message
-

Test Message for config id $configId from feature ${feature.tag}

+

Test Message for config id $configId from feature $feature

""".trimIndent() // TODO: change as spec diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/send/SendMessageActionHelper.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/send/SendMessageActionHelper.kt index d0efc5be..a97451b3 100644 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/send/SendMessageActionHelper.kt +++ b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/send/SendMessageActionHelper.kt @@ -16,6 +16,13 @@ import kotlinx.coroutines.awaitAll import kotlinx.coroutines.runBlocking import org.opensearch.OpenSearchStatusException import org.opensearch.commons.authuser.User +import org.opensearch.commons.destination.message.LegacyBaseMessage +import org.opensearch.commons.destination.message.LegacyCustomWebhookMessage +import org.opensearch.commons.destination.message.LegacyDestinationType +import org.opensearch.commons.destination.response.LegacyDestinationResponse +import org.opensearch.commons.notifications.NotificationConstants.FEATURE_INDEX_MANAGEMENT +import org.opensearch.commons.notifications.action.LegacyPublishNotificationRequest +import org.opensearch.commons.notifications.action.LegacyPublishNotificationResponse import org.opensearch.commons.notifications.action.SendNotificationRequest import org.opensearch.commons.notifications.action.SendNotificationResponse import org.opensearch.commons.notifications.model.ChannelMessage @@ -28,12 +35,16 @@ import org.opensearch.commons.notifications.model.EmailRecipientStatus import org.opensearch.commons.notifications.model.EventSource import org.opensearch.commons.notifications.model.EventStatus import org.opensearch.commons.notifications.model.NotificationEvent +import org.opensearch.commons.notifications.model.SesAccount import org.opensearch.commons.notifications.model.Slack +import org.opensearch.commons.notifications.model.SmtpAccount +import org.opensearch.commons.notifications.model.Sns import org.opensearch.commons.notifications.model.Webhook import org.opensearch.commons.utils.logger import org.opensearch.notifications.NotificationPlugin.Companion.LOG_PREFIX import org.opensearch.notifications.index.ConfigOperations import org.opensearch.notifications.index.EventOperations +import org.opensearch.notifications.metrics.Metrics import org.opensearch.notifications.model.DocMetadata import org.opensearch.notifications.model.NotificationConfigDocInfo import org.opensearch.notifications.model.NotificationEventDoc @@ -44,7 +55,10 @@ import org.opensearch.notifications.spi.model.MessageContent import org.opensearch.notifications.spi.model.destination.BaseDestination import org.opensearch.notifications.spi.model.destination.ChimeDestination import org.opensearch.notifications.spi.model.destination.CustomWebhookDestination +import org.opensearch.notifications.spi.model.destination.SesDestination import org.opensearch.notifications.spi.model.destination.SlackDestination +import org.opensearch.notifications.spi.model.destination.SmtpDestination +import org.opensearch.notifications.spi.model.destination.SnsDestination import org.opensearch.rest.RestStatus import java.time.Instant @@ -90,10 +104,26 @@ object SendMessageActionHelper { val event = NotificationEvent(eventSource, eventStatusList) val eventDoc = NotificationEventDoc(docMetadata, event) val docId = eventOperations.createNotificationEvent(eventDoc) - ?: throw OpenSearchStatusException("Indexing not Acknowledged", RestStatus.INSUFFICIENT_STORAGE) + ?: run { + Metrics.NOTIFICATIONS_SEND_MESSAGE_SYSTEM_ERROR.counter.increment() + throw OpenSearchStatusException("Indexing not Acknowledged", RestStatus.INSUFFICIENT_STORAGE) + } return SendNotificationResponse(docId) } + /** + * Send legacy notification message intended only for Index Management plugin. + * @param request request object + */ + fun executeLegacyRequest(request: LegacyPublishNotificationRequest): LegacyPublishNotificationResponse { + val baseMessage = request.baseMessage + val response: LegacyDestinationResponse + runBlocking { + response = sendMessageToLegacyDestination(baseMessage) + } + return LegacyPublishNotificationResponse(response) + } + /** * Create message content from the request parameters * @param eventSource event source of request @@ -151,36 +181,104 @@ object SendMessageActionHelper { childConfigs: List, message: MessageContent ): EventStatus { + Metrics.NOTIFICATIONS_SEND_MESSAGE_TOTAL.counter.increment() + Metrics.NOTIFICATIONS_SEND_MESSAGE_INTERVAL_COUNT.counter.increment() + val configType = channel.configDoc.config.configType + val configData = channel.configDoc.config.configData + var emailRecipientStatus = listOf() + if (configType == ConfigType.EMAIL) { + emailRecipientStatus = + listOf(EmailRecipientStatus("placeholder@amazon.com", DeliveryStatus("Scheduled", "Pending execution"))) + } val eventStatus = EventStatus( channel.docInfo.id!!, // ID from query so not expected to be null channel.configDoc.config.name, channel.configDoc.config.configType, - listOf(), + emailRecipientStatus, DeliveryStatus("Scheduled", "Pending execution") ) val invalidStatus: DeliveryStatus? = getStatusIfChannelIsNotEligibleToSendMessage(eventSource, channel) if (invalidStatus != null) { return eventStatus.copy(deliveryStatus = invalidStatus) } - val configType = channel.configDoc.config.configType - val configData = channel.configDoc.config.configData + val response = when (configType) { ConfigType.NONE -> null - ConfigType.SLACK -> sendSlackMessage(configData as Slack, message, eventStatus) - ConfigType.CHIME -> sendChimeMessage(configData as Chime, message, eventStatus) - ConfigType.WEBHOOK -> sendWebhookMessage(configData as Webhook, message, eventStatus) - ConfigType.EMAIL -> sendEmailMessage(configData as Email, childConfigs, message, eventStatus) + ConfigType.SLACK -> sendSlackMessage(configData as Slack, message, eventStatus, eventSource.referenceId) + ConfigType.CHIME -> sendChimeMessage(configData as Chime, message, eventStatus, eventSource.referenceId) + ConfigType.WEBHOOK -> sendWebhookMessage( + configData as Webhook, + message, + eventStatus, + eventSource.referenceId + ) + ConfigType.EMAIL -> sendEmailMessage( + configData as Email, + childConfigs, + message, + eventStatus, + eventSource.referenceId + ) + ConfigType.SES_ACCOUNT -> null ConfigType.SMTP_ACCOUNT -> null ConfigType.EMAIL_GROUP -> null + ConfigType.SNS -> sendSNSMessage(configData as Sns, message, eventStatus, eventSource.referenceId) } return if (response == null) { log.warn("Cannot send message to destination for config id :${channel.docInfo.id}") + Metrics.NOTIFICATIONS_SEND_MESSAGE_USER_ERROR_NOT_FOUND.counter.increment() eventStatus.copy(deliveryStatus = DeliveryStatus(RestStatus.NOT_FOUND.name, "Channel not found")) } else { response } } + /** + * Send message to a legacy destination intended only for Index Management + * + * Currently this simply converts the legacy base message to the equivalent destination classes that exist + * for the notification channels and utilizes the [sendMessageThroughSpi] method. If we get to the point + * where this method seems to be holding back notification channels from adding new functionality we can + * refactor this to have it's own internal private spi call to completely decouple them instead. + * + * @param baseMessage legacy base message + * @return notification delivery status for the legacy destination + */ + private fun sendMessageToLegacyDestination(baseMessage: LegacyBaseMessage): LegacyDestinationResponse { + val message = + MessageContent(title = "Index Management Notification", textDescription = baseMessage.messageContent) + // These legacy destination calls do not have reference Ids, just passing index management feature constant + return when (baseMessage.channelType) { + LegacyDestinationType.LEGACY_SLACK -> { + val destination = SlackDestination(baseMessage.url) + val status = sendMessageThroughSpi(destination, message, FEATURE_INDEX_MANAGEMENT) + LegacyDestinationResponse.Builder().withStatusCode(status.statusCode) + .withResponseContent(status.statusText).build() + } + LegacyDestinationType.LEGACY_CHIME -> { + val destination = ChimeDestination(baseMessage.url) + val status = sendMessageThroughSpi(destination, message, FEATURE_INDEX_MANAGEMENT) + LegacyDestinationResponse.Builder().withStatusCode(status.statusCode) + .withResponseContent(status.statusText).build() + } + LegacyDestinationType.LEGACY_CUSTOM_WEBHOOK -> { + val destination = CustomWebhookDestination( + (baseMessage as LegacyCustomWebhookMessage).uri.toString(), + baseMessage.headerParams, + baseMessage.method + ) + val status = sendMessageThroughSpi(destination, message, FEATURE_INDEX_MANAGEMENT) + LegacyDestinationResponse.Builder().withStatusCode(status.statusCode) + .withResponseContent(status.statusText).build() + } + null -> { + log.warn("No channel type given (null) for publishing to legacy destination") + LegacyDestinationResponse.Builder().withStatusCode(400) + .withResponseContent("No channel type given (null) for publishing to legacy destination").build() + } + } + } + /** * Check if channel is eligible to send message, return error status if not * @param eventSource event source information @@ -194,6 +292,7 @@ object SendMessageActionHelper { return if (!channel.configDoc.config.isEnabled) { DeliveryStatus(RestStatus.LOCKED.name, "The channel is muted") } else if (!channel.configDoc.config.features.contains(eventSource.feature)) { + Metrics.NOTIFICATIONS_PERMISSION_USER_ERROR.counter.increment() DeliveryStatus(RestStatus.FORBIDDEN.name, "Feature is not enabled for channel") } else { null @@ -203,27 +302,45 @@ object SendMessageActionHelper { /** * send message to slack destination */ - private fun sendSlackMessage(slack: Slack, message: MessageContent, eventStatus: EventStatus): EventStatus { + private fun sendSlackMessage( + slack: Slack, + message: MessageContent, + eventStatus: EventStatus, + referenceId: String + ): EventStatus { + Metrics.NOTIFICATIONS_MESSAGE_DESTINATION_SLACK.counter.increment() val destination = SlackDestination(slack.url) - val status = sendMessageThroughSpi(destination, message) + val status = sendMessageThroughSpi(destination, message, referenceId) return eventStatus.copy(deliveryStatus = DeliveryStatus(status.statusCode.toString(), status.statusText)) } /** * send message to chime destination */ - private fun sendChimeMessage(chime: Chime, message: MessageContent, eventStatus: EventStatus): EventStatus { + private fun sendChimeMessage( + chime: Chime, + message: MessageContent, + eventStatus: EventStatus, + referenceId: String + ): EventStatus { + Metrics.NOTIFICATIONS_MESSAGE_DESTINATION_CHIME.counter.increment() val destination = ChimeDestination(chime.url) - val status = sendMessageThroughSpi(destination, message) + val status = sendMessageThroughSpi(destination, message, referenceId) return eventStatus.copy(deliveryStatus = DeliveryStatus(status.statusCode.toString(), status.statusText)) } /** * send message to custom webhook destination */ - private fun sendWebhookMessage(webhook: Webhook, message: MessageContent, eventStatus: EventStatus): EventStatus { - val destination = CustomWebhookDestination(webhook.url, webhook.headerParams, "POST") - val status = sendMessageThroughSpi(destination, message) + private fun sendWebhookMessage( + webhook: Webhook, + message: MessageContent, + eventStatus: EventStatus, + referenceId: String + ): EventStatus { + Metrics.NOTIFICATIONS_MESSAGE_DESTINATION_WEBHOOK.counter.increment() + val destination = CustomWebhookDestination(webhook.url, webhook.headerParams, webhook.method.tag) + val status = sendMessageThroughSpi(destination, message, referenceId) return eventStatus.copy(deliveryStatus = DeliveryStatus(status.statusCode.toString(), status.statusText)) } @@ -234,16 +351,40 @@ object SendMessageActionHelper { email: Email, childConfigs: List, message: MessageContent, - eventStatus: EventStatus + eventStatus: EventStatus, + referenceId: String ): EventStatus { - val smtpAccount = childConfigs.find { it.docInfo.id == email.emailAccountID } + Metrics.NOTIFICATIONS_MESSAGE_DESTINATION_EMAIL.counter.increment() + val accountDocInfo = childConfigs.find { it.docInfo.id == email.emailAccountID } val groups = childConfigs.filter { email.emailGroupIds.contains(it.docInfo.id) } val groupRecipients = groups.map { (it.configDoc.config.configData as EmailGroup).recipients }.flatten() val recipients = email.recipients.union(groupRecipients) val emailRecipientStatus: List + val accountConfig = accountDocInfo?.configDoc!!.config runBlocking { val statusDeferredList = recipients.map { - async(Dispatchers.IO) { sendEmailFromSmtpAccount(smtpAccount, it, message) } + async(Dispatchers.IO) { + when (accountConfig.configType) { + ConfigType.SMTP_ACCOUNT -> sendEmailFromSmtpAccount( + accountConfig.name, + accountConfig.configData as SmtpAccount, + it, + message, + referenceId + ) + ConfigType.SES_ACCOUNT -> sendEmailFromSesAccount( + accountConfig.name, + accountConfig.configData as SesAccount, + it, + message, + referenceId + ) + else -> EmailRecipientStatus( + it, + DeliveryStatus(RestStatus.NOT_ACCEPTABLE.name, "email account type not enabled") + ) + } + } } emailRecipientStatus = statusDeferredList.awaitAll() } @@ -267,29 +408,80 @@ object SendMessageActionHelper { /** * send message to smtp destination */ - @Suppress("UnusedPrivateMember") private fun sendEmailFromSmtpAccount( - smtpAccount: NotificationConfigDocInfo?, + accountName: String, + smtpAccount: SmtpAccount, recipient: String, - message: MessageContent + message: MessageContent, + referenceId: String ): EmailRecipientStatus { - // TODO implement email channel conversion + Metrics.NOTIFICATIONS_MESSAGE_DESTINATION_SMTP_ACCOUNT.counter.increment() + val destination = SmtpDestination( + accountName, + smtpAccount.host, + smtpAccount.port, + smtpAccount.method.tag, + smtpAccount.fromAddress, + recipient + ) + val status = sendMessageThroughSpi(destination, message, referenceId) return EmailRecipientStatus( recipient, - DeliveryStatus(RestStatus.NOT_IMPLEMENTED.name, "SMTP Channel not implemented") + DeliveryStatus(status.statusCode.toString(), status.statusText) ) } + /** + * send message to ses destination + */ + private fun sendEmailFromSesAccount( + accountName: String, + sesAccount: SesAccount, + recipient: String, + message: MessageContent, + referenceId: String + ): EmailRecipientStatus { + Metrics.NOTIFICATIONS_MESSAGE_DESTINATION_SES_ACCOUNT.counter.increment() + val destination = SesDestination( + accountName, + sesAccount.awsRegion, + sesAccount.roleArn, + sesAccount.fromAddress, + recipient + ) + val status = sendMessageThroughSpi(destination, message, referenceId) + return EmailRecipientStatus( + recipient, + DeliveryStatus(status.statusCode.toString(), status.statusText) + ) + } + + /** + * send message to SNS destination + */ + private fun sendSNSMessage( + sns: Sns, + message: MessageContent, + eventStatus: EventStatus, + referenceId: String + ): EventStatus { + Metrics.NOTIFICATIONS_MESSAGE_DESTINATION_SNS.counter.increment() + val destination = SnsDestination(sns.topicArn, sns.roleArn) + val status = sendMessageThroughSpi(destination, message, referenceId) + return eventStatus.copy(deliveryStatus = DeliveryStatus(status.statusCode.toString(), status.statusText)) + } + /** * Send message to destination using SPI */ @Suppress("TooGenericExceptionCaught", "UnusedPrivateMember") private fun sendMessageThroughSpi( destination: BaseDestination, - message: MessageContent + message: MessageContent, + referenceId: String ): DestinationMessageResponse { return try { - val status = NotificationSpi.sendMessage(destination, message) + val status = NotificationSpi.sendMessage(destination, message, referenceId) log.info("$LOG_PREFIX:sendMessage:statusCode=${status.statusCode}, statusText=${status.statusText}") status } catch (exception: Exception) { diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/settings/PluginSettings.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/settings/PluginSettings.kt index c9fb5805..78f88908 100644 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/settings/PluginSettings.kt +++ b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/settings/PluginSettings.kt @@ -36,7 +36,6 @@ import org.opensearch.common.settings.Settings import org.opensearch.commons.utils.logger import org.opensearch.notifications.NotificationPlugin.Companion.LOG_PREFIX import org.opensearch.notifications.NotificationPlugin.Companion.PLUGIN_NAME -import software.amazon.awssdk.regions.Region import java.io.IOException import java.nio.file.Path @@ -46,7 +45,7 @@ import java.nio.file.Path internal object PluginSettings { /** - * Settings Key prefix for this plugin. + * Settings Key-prefix for this plugin. */ private const val KEY_PREFIX = "opensearch.notifications" @@ -55,11 +54,6 @@ internal object PluginSettings { */ private const val GENERAL_KEY_PREFIX = "$KEY_PREFIX.general" - /** - * Settings Key prefix for this plugin. - */ - private const val EMAIL_KEY_PREFIX = "$KEY_PREFIX.email" - /** * Access settings Key prefix. */ @@ -75,46 +69,6 @@ internal object PluginSettings { */ private const val DEFAULT_ITEMS_QUERY_COUNT_KEY = "$GENERAL_KEY_PREFIX.defaultItemsQueryCount" - /** - * Setting to choose smtp or SES for sending mail. - */ - private const val EMAIL_CHANNEL_KEY = "$EMAIL_KEY_PREFIX.channel" - - /** - * "From:" email address while sending email. - */ - private const val EMAIL_FROM_ADDRESS_KEY = "$EMAIL_KEY_PREFIX.fromAddress" - - /** - * Monthly email sending limit from this plugin. - */ - private const val EMAIL_LIMIT_MONTHLY_KEY = "$EMAIL_KEY_PREFIX.monthlyLimit" - - /** - * Email size limit. - */ - private const val EMAIL_SIZE_LIMIT_KEY = "$EMAIL_KEY_PREFIX.sizeLimit" - - /** - * Amazon SES AWS region to send mail to. - */ - private const val EMAIL_SES_AWS_REGION_KEY = "$EMAIL_KEY_PREFIX.ses.awsRegion" - - /** - * SMTP host address to send mail to. - */ - private const val EMAIL_SMTP_HOST_KEY = "$EMAIL_KEY_PREFIX.smtp.host" - - /** - * SMTP port number to send mail to. - */ - private const val EMAIL_SMTP_PORT_KEY = "$EMAIL_KEY_PREFIX.smtp.port" - - /** - * SMTP Transport method. starttls, ssl or plain. - */ - private const val EMAIL_SMTP_TRANSPORT_METHOD_KEY = "$EMAIL_KEY_PREFIX.smtp.transportMethod" - /** * Setting to choose admin access restriction. */ @@ -150,53 +104,6 @@ internal object PluginSettings { */ private const val MINIMUM_ITEMS_QUERY_COUNT = 10 - /** - * Default email channel. - */ - private val DEFAULT_EMAIL_CHANNEL = EmailChannelType.SMTP.stringValue - - /** - * Default monthly email sending limit from this plugin. - */ - private const val DEFAULT_EMAIL_LIMIT_MONTHLY = 200 - - /** - * Default email size limit as 10MB. - */ - private const val DEFAULT_EMAIL_SIZE_LIMIT = 10000000 - - /** - * Minimum email size limit as 10KB. - */ - private const val MINIMUM_EMAIL_SIZE_LIMIT = 10000 - - /** - * Default Amazon SES AWS region. - */ - private val DEFAULT_SES_AWS_REGION = Region.US_WEST_2.id() - - /** - * Default SMTP Host name to connect to. - */ - private const val DEFAULT_SMTP_HOST = "localhost" - - /** - * Default SMTP port number to connect to. - */ - private const val DEFAULT_SMTP_PORT = 10255 - - /** - * Default SMTP transport method. - */ - private const val DEFAULT_SMTP_TRANSPORT_METHOD = "starttls" - - /** - * If the "From:" email address is set to below value then email will NOT be submitted to server. - * any other valid "From:" email address would be submitted to server. - */ - const val UNCONFIGURED_EMAIL_ADDRESS = - "nobody@email.com" // Email will not be sent if email address different than this value - /** * Default admin access method. */ @@ -229,54 +136,6 @@ internal object PluginSettings { @Volatile var defaultItemsQueryCount: Int - /** - * Email channel setting [EmailChannelType] in string format - */ - @Volatile - var emailChannel: String - - /** - * Email "From:" Address setting - */ - @Volatile - var emailFromAddress: String - - /** - * Email monthly throttle limit setting - */ - @Volatile - var emailMonthlyLimit: Int - - /** - * Email size limit setting - */ - @Volatile - var emailSizeLimit: Int - - /** - * Amazon SES AWS region setting - */ - @Volatile - var sesAwsRegion: String - - /** - * SMTP server host setting - */ - @Volatile - var smtpHost: String - - /** - * SMTP server port setting - */ - @Volatile - var smtpPort: Int - - /** - * SMTP server transport method setting - */ - @Volatile - var smtpTransportMethod: String - /** * admin access method. */ @@ -331,14 +190,6 @@ internal object PluginSettings { operationTimeoutMs = (settings?.get(OPERATION_TIMEOUT_MS_KEY)?.toLong()) ?: DEFAULT_OPERATION_TIMEOUT_MS defaultItemsQueryCount = (settings?.get(DEFAULT_ITEMS_QUERY_COUNT_KEY)?.toInt()) ?: DEFAULT_ITEMS_QUERY_COUNT_VALUE - emailChannel = (settings?.get(EMAIL_CHANNEL_KEY) ?: DEFAULT_EMAIL_CHANNEL) - emailFromAddress = (settings?.get(EMAIL_FROM_ADDRESS_KEY) ?: UNCONFIGURED_EMAIL_ADDRESS) - emailMonthlyLimit = (settings?.get(EMAIL_LIMIT_MONTHLY_KEY)?.toInt()) ?: DEFAULT_EMAIL_LIMIT_MONTHLY - emailSizeLimit = (settings?.get(EMAIL_SIZE_LIMIT_KEY)?.toInt()) ?: DEFAULT_EMAIL_SIZE_LIMIT - sesAwsRegion = (settings?.get(EMAIL_SES_AWS_REGION_KEY) ?: DEFAULT_SES_AWS_REGION) - smtpHost = (settings?.get(EMAIL_SMTP_HOST_KEY) ?: DEFAULT_SMTP_HOST) - smtpPort = (settings?.get(EMAIL_SMTP_PORT_KEY)?.toInt()) ?: DEFAULT_SMTP_PORT - smtpTransportMethod = (settings?.get(EMAIL_SMTP_TRANSPORT_METHOD_KEY) ?: DEFAULT_SMTP_TRANSPORT_METHOD) adminAccess = AdminAccess.valueOf(settings?.get(ADMIN_ACCESS_KEY) ?: DEFAULT_ADMIN_ACCESS_METHOD) filterBy = FilterBy.valueOf(settings?.get(FILTER_BY_KEY) ?: DEFAULT_FILTER_BY_METHOD) ignoredRoles = settings?.getAsList(IGNORE_ROLE_KEY) ?: DEFAULT_IGNORED_ROLES @@ -346,14 +197,6 @@ internal object PluginSettings { defaultSettings = mapOf( OPERATION_TIMEOUT_MS_KEY to operationTimeoutMs.toString(DECIMAL_RADIX), DEFAULT_ITEMS_QUERY_COUNT_KEY to defaultItemsQueryCount.toString(DECIMAL_RADIX), - EMAIL_CHANNEL_KEY to emailChannel, - EMAIL_FROM_ADDRESS_KEY to emailFromAddress, - EMAIL_LIMIT_MONTHLY_KEY to emailMonthlyLimit.toString(DECIMAL_RADIX), - EMAIL_SIZE_LIMIT_KEY to emailSizeLimit.toString(DECIMAL_RADIX), - EMAIL_SES_AWS_REGION_KEY to sesAwsRegion, - EMAIL_SMTP_HOST_KEY to smtpHost, - EMAIL_SMTP_PORT_KEY to smtpPort.toString(DECIMAL_RADIX), - EMAIL_SMTP_TRANSPORT_METHOD_KEY to smtpTransportMethod, ADMIN_ACCESS_KEY to adminAccess.name, FILTER_BY_KEY to filterBy.name ) @@ -373,57 +216,6 @@ internal object PluginSettings { NodeScope, Dynamic ) - private val EMAIL_CHANNEL: Setting = Setting.simpleString( - EMAIL_CHANNEL_KEY, - defaultSettings[EMAIL_CHANNEL_KEY], - NodeScope, Dynamic - ) - - private val EMAIL_FROM_ADDRESS: Setting = Setting.simpleString( - EMAIL_FROM_ADDRESS_KEY, - defaultSettings[EMAIL_FROM_ADDRESS_KEY], - NodeScope, Dynamic - ) - - private val EMAIL_LIMIT_MONTHLY: Setting = Setting.intSetting( - EMAIL_LIMIT_MONTHLY_KEY, - defaultSettings[EMAIL_LIMIT_MONTHLY_KEY]!!.toInt(), - 0, - NodeScope, Dynamic - ) - - private val EMAIL_SIZE_LIMIT: Setting = Setting.intSetting( - EMAIL_SIZE_LIMIT_KEY, - defaultSettings[EMAIL_SIZE_LIMIT_KEY]!!.toInt(), - MINIMUM_EMAIL_SIZE_LIMIT, - NodeScope, Dynamic - ) - - private val EMAIL_SES_AWS_REGION: Setting = Setting.simpleString( - EMAIL_SES_AWS_REGION_KEY, - defaultSettings[EMAIL_SES_AWS_REGION_KEY], - NodeScope, Dynamic - ) - - private val EMAIL_SMTP_HOST: Setting = Setting.simpleString( - EMAIL_SMTP_HOST_KEY, - defaultSettings[EMAIL_SMTP_HOST_KEY], - NodeScope, Dynamic - ) - - private val EMAIL_SMTP_PORT: Setting = Setting.intSetting( - EMAIL_SMTP_PORT_KEY, - defaultSettings[EMAIL_SMTP_PORT_KEY]!!.toInt(), - 0, - NodeScope, Dynamic - ) - - private val EMAIL_SMTP_TRANSPORT_METHOD: Setting = Setting.simpleString( - EMAIL_SMTP_TRANSPORT_METHOD_KEY, - defaultSettings[EMAIL_SMTP_TRANSPORT_METHOD_KEY], - NodeScope, Dynamic - ) - private val ADMIN_ACCESS: Setting = Setting.simpleString( ADMIN_ACCESS_KEY, defaultSettings[ADMIN_ACCESS_KEY]!!, @@ -452,14 +244,6 @@ internal object PluginSettings { return listOf( OPERATION_TIMEOUT_MS, DEFAULT_ITEMS_QUERY_COUNT, - EMAIL_CHANNEL, - EMAIL_FROM_ADDRESS, - EMAIL_LIMIT_MONTHLY, - EMAIL_SIZE_LIMIT, - EMAIL_SES_AWS_REGION, - EMAIL_SMTP_HOST, - EMAIL_SMTP_PORT, - EMAIL_SMTP_TRANSPORT_METHOD, ADMIN_ACCESS, FILTER_BY, IGNORED_ROLES @@ -473,14 +257,6 @@ internal object PluginSettings { private fun updateSettingValuesFromLocal(clusterService: ClusterService) { operationTimeoutMs = OPERATION_TIMEOUT_MS.get(clusterService.settings) defaultItemsQueryCount = DEFAULT_ITEMS_QUERY_COUNT.get(clusterService.settings) - emailChannel = EMAIL_CHANNEL.get(clusterService.settings) - emailFromAddress = EMAIL_FROM_ADDRESS.get(clusterService.settings) - emailMonthlyLimit = EMAIL_LIMIT_MONTHLY.get(clusterService.settings) - emailSizeLimit = EMAIL_SIZE_LIMIT.get(clusterService.settings) - sesAwsRegion = EMAIL_SES_AWS_REGION.get(clusterService.settings) - smtpHost = EMAIL_SMTP_HOST.get(clusterService.settings) - smtpPort = EMAIL_SMTP_PORT.get(clusterService.settings) - smtpTransportMethod = EMAIL_SMTP_TRANSPORT_METHOD.get(clusterService.settings) adminAccess = AdminAccess.valueOf(ADMIN_ACCESS.get(clusterService.settings)) filterBy = FilterBy.valueOf(FILTER_BY.get(clusterService.settings)) ignoredRoles = IGNORED_ROLES.get(clusterService.settings) @@ -502,46 +278,6 @@ internal object PluginSettings { log.debug("$LOG_PREFIX:$DEFAULT_ITEMS_QUERY_COUNT_KEY -autoUpdatedTo-> $clusterDefaultItemsQueryCount") defaultItemsQueryCount = clusterDefaultItemsQueryCount } - val clusterEmailChannel = clusterService.clusterSettings.get(EMAIL_CHANNEL) - if (clusterEmailChannel != null) { - log.debug("$LOG_PREFIX:$EMAIL_CHANNEL_KEY -autoUpdatedTo-> $clusterEmailChannel") - emailChannel = clusterEmailChannel - } - val clusterEmailFromAddress = clusterService.clusterSettings.get(EMAIL_FROM_ADDRESS) - if (clusterEmailFromAddress != null) { - log.debug("$LOG_PREFIX:$EMAIL_FROM_ADDRESS_KEY -autoUpdatedTo-> $clusterEmailFromAddress") - emailFromAddress = clusterEmailFromAddress - } - val clusterEmailMonthlyLimit = clusterService.clusterSettings.get(EMAIL_LIMIT_MONTHLY) - if (clusterEmailMonthlyLimit != null) { - log.debug("$LOG_PREFIX:$EMAIL_LIMIT_MONTHLY_KEY -autoUpdatedTo-> $clusterEmailMonthlyLimit") - emailMonthlyLimit = clusterEmailMonthlyLimit - } - val clusterEmailSizeLimit = clusterService.clusterSettings.get(EMAIL_SIZE_LIMIT) - if (clusterEmailSizeLimit != null) { - log.debug("$LOG_PREFIX:$EMAIL_SIZE_LIMIT_KEY -autoUpdatedTo-> $clusterEmailSizeLimit") - emailSizeLimit = clusterEmailSizeLimit - } - val clusterSesAwsRegion = clusterService.clusterSettings.get(EMAIL_SES_AWS_REGION) - if (clusterSesAwsRegion != null) { - log.debug("$LOG_PREFIX:$EMAIL_SES_AWS_REGION_KEY -autoUpdatedTo-> $clusterSesAwsRegion") - sesAwsRegion = clusterSesAwsRegion - } - val clusterSmtpHost = clusterService.clusterSettings.get(EMAIL_SMTP_HOST) - if (clusterSmtpHost != null) { - log.debug("$LOG_PREFIX:$EMAIL_SMTP_HOST_KEY -autoUpdatedTo-> $clusterSmtpHost") - smtpHost = clusterSmtpHost - } - val clusterSmtpPort = clusterService.clusterSettings.get(EMAIL_SMTP_PORT) - if (clusterSmtpPort != null) { - log.debug("$LOG_PREFIX:$EMAIL_SMTP_PORT_KEY -autoUpdatedTo-> $clusterSmtpPort") - smtpPort = clusterSmtpPort - } - val clusterSmtpTransportMethod = clusterService.clusterSettings.get(EMAIL_SMTP_TRANSPORT_METHOD) - if (clusterSmtpTransportMethod != null) { - log.debug("$LOG_PREFIX:$EMAIL_SMTP_TRANSPORT_METHOD_KEY -autoUpdatedTo-> $clusterSmtpTransportMethod") - smtpTransportMethod = clusterSmtpTransportMethod - } val clusterAdminAccess = clusterService.clusterSettings.get(ADMIN_ACCESS) if (clusterAdminAccess != null) { log.debug("$LOG_PREFIX:$ADMIN_ACCESS_KEY -autoUpdatedTo-> $clusterAdminAccess") @@ -577,38 +313,6 @@ internal object PluginSettings { defaultItemsQueryCount = it log.info("$LOG_PREFIX:$DEFAULT_ITEMS_QUERY_COUNT_KEY -updatedTo-> $it") } - clusterService.clusterSettings.addSettingsUpdateConsumer(EMAIL_CHANNEL) { - emailChannel = it - log.info("$LOG_PREFIX:$EMAIL_CHANNEL_KEY -updatedTo-> $it") - } - clusterService.clusterSettings.addSettingsUpdateConsumer(EMAIL_FROM_ADDRESS) { - emailFromAddress = it - log.info("$LOG_PREFIX:$EMAIL_FROM_ADDRESS_KEY -updatedTo-> $it") - } - clusterService.clusterSettings.addSettingsUpdateConsumer(EMAIL_LIMIT_MONTHLY) { - emailMonthlyLimit = it - log.info("$LOG_PREFIX:$EMAIL_LIMIT_MONTHLY_KEY -updatedTo-> $it") - } - clusterService.clusterSettings.addSettingsUpdateConsumer(EMAIL_SIZE_LIMIT) { - emailSizeLimit = it - log.info("$LOG_PREFIX:$EMAIL_SIZE_LIMIT_KEY -updatedTo-> $it") - } - clusterService.clusterSettings.addSettingsUpdateConsumer(EMAIL_SES_AWS_REGION) { - sesAwsRegion = it - log.info("$LOG_PREFIX:$EMAIL_SES_AWS_REGION_KEY -updatedTo-> $it") - } - clusterService.clusterSettings.addSettingsUpdateConsumer(EMAIL_SMTP_HOST) { - smtpHost = it - log.info("$LOG_PREFIX:$EMAIL_SMTP_HOST_KEY -updatedTo-> $it") - } - clusterService.clusterSettings.addSettingsUpdateConsumer(EMAIL_SMTP_PORT) { - smtpPort = it - log.info("$LOG_PREFIX:$EMAIL_SMTP_PORT_KEY -updatedTo-> $it") - } - clusterService.clusterSettings.addSettingsUpdateConsumer(EMAIL_SMTP_TRANSPORT_METHOD) { - smtpTransportMethod = it - log.info("$LOG_PREFIX:$EMAIL_SMTP_TRANSPORT_METHOD_KEY -updatedTo-> $it") - } clusterService.clusterSettings.addSettingsUpdateConsumer(ADMIN_ACCESS) { adminAccess = AdminAccess.valueOf(it) log.info("$LOG_PREFIX:$ADMIN_ACCESS_KEY -updatedTo-> $it") diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/throttle/Accountant.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/throttle/Accountant.kt deleted file mode 100644 index 9118e3cf..00000000 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/throttle/Accountant.kt +++ /dev/null @@ -1,68 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - * - * Modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -/* - * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - * - */ - -package org.opensearch.notifications.throttle - -import org.opensearch.client.Client -import org.opensearch.cluster.service.ClusterService -import org.opensearch.notifications.settings.PluginSettings -import java.util.Date - -/** - * The object class for keep track of the messages sent and provide throttle data. - */ -internal object Accountant { - private var messageCounter: MessageCounter = EmptyMessageCounter - - /** - * Initialize the class - * @param client The client - * @param clusterService The cluster service - */ - fun initialize(client: Client, clusterService: ClusterService) { - this.messageCounter = CounterIndex(client, clusterService) - } - - /** - * Increment the counters by provided value - * @param counters the counter object - */ - fun incrementCounters(counters: Counters) { - messageCounter.incrementCountersForDay(Date(), counters) - } - - /** - * Check if message quota is available - * @param counters the counter object - * @return true if message quota is available, false otherwise - */ - fun isMessageQuotaAvailable(counters: Counters): Boolean { - val monthlyCounters = messageCounter.getCounterForMonth(Date()) - monthlyCounters.incrementCountersBy(counters) - return monthlyCounters.emailSentSuccessCount.get() <= PluginSettings.emailMonthlyLimit - } -} diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/throttle/CounterIndex.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/throttle/CounterIndex.kt deleted file mode 100644 index ed61a6c7..00000000 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/throttle/CounterIndex.kt +++ /dev/null @@ -1,253 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - * - * Modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -/* - * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - * - */ - -package org.opensearch.notifications.throttle - -import org.opensearch.ResourceAlreadyExistsException -import org.opensearch.action.DocWriteResponse -import org.opensearch.action.admin.indices.create.CreateIndexRequest -import org.opensearch.action.get.GetRequest -import org.opensearch.action.index.IndexRequest -import org.opensearch.action.search.SearchRequest -import org.opensearch.action.update.UpdateRequest -import org.opensearch.client.Client -import org.opensearch.cluster.service.ClusterService -import org.opensearch.common.unit.TimeValue -import org.opensearch.common.xcontent.LoggingDeprecationHandler -import org.opensearch.common.xcontent.NamedXContentRegistry -import org.opensearch.common.xcontent.XContentType -import org.opensearch.commons.utils.logger -import org.opensearch.index.engine.DocumentMissingException -import org.opensearch.index.engine.VersionConflictEngineException -import org.opensearch.index.query.QueryBuilders -import org.opensearch.index.seqno.SequenceNumbers -import org.opensearch.notifications.NotificationPlugin.Companion.LOG_PREFIX -import org.opensearch.notifications.settings.PluginSettings -import org.opensearch.notifications.throttle.CounterIndexModel.Companion.COUNTER_INDEX_MODEL_KEY -import org.opensearch.notifications.throttle.CounterIndexModel.Companion.MAX_ITEMS_IN_MONTH -import org.opensearch.notifications.throttle.CounterIndexModel.Companion.getIdForDate -import org.opensearch.notifications.throttle.CounterIndexModel.Companion.getIdForStartOfMonth -import org.opensearch.notifications.util.SecureIndexClient -import org.opensearch.search.builder.SearchSourceBuilder -import java.util.Date -import java.util.concurrent.TimeUnit - -/** - * Class for doing index operation to maintain counters in cluster. - */ -internal class CounterIndex(client: Client, private val clusterService: ClusterService) : MessageCounter { - private val client: Client - - init { - this.client = SecureIndexClient(client) - } - - internal companion object { - private val log by logger(CounterIndex::class.java) - private const val COUNTER_INDEX_NAME = ".opensearch-notifications-counter" - private const val COUNTER_INDEX_SCHEMA_FILE_NAME = "opensearch-notifications-counter.yml" - private const val COUNTER_INDEX_SETTINGS_FILE_NAME = "opensearch-notifications-counter-settings.yml" - private const val MAPPING_TYPE = "_doc" - } - - /** - * {@inheritDoc} - */ - override fun getCounterForMonth(counterDay: Date): Counters { - val retValue = Counters() - if (!isIndexExists()) { - createIndex() - } else { - val startDay = getIdForStartOfMonth(counterDay) - val currentDay = getIdForDate(counterDay) - val query = QueryBuilders.rangeQuery(COUNTER_INDEX_MODEL_KEY).gte(startDay).lte(currentDay) - val sourceBuilder = SearchSourceBuilder() - .timeout(TimeValue(PluginSettings.operationTimeoutMs, TimeUnit.MILLISECONDS)) - .size(MAX_ITEMS_IN_MONTH) - .from(0) - .query(query) - val searchRequest = SearchRequest() - .indices(COUNTER_INDEX_NAME) - .source(sourceBuilder) - val actionFuture = client.search(searchRequest) - val response = actionFuture.actionGet(PluginSettings.operationTimeoutMs) - response.hits.forEach { - val parser = XContentType.JSON.xContent().createParser( - NamedXContentRegistry.EMPTY, - LoggingDeprecationHandler.INSTANCE, - it.sourceAsString - ) - parser.nextToken() - val modelValues = CounterIndexModel.parse(parser) - retValue.requestCount.addAndGet(modelValues.requestCount) - retValue.emailSentSuccessCount.addAndGet(modelValues.emailSentSuccessCount) - retValue.emailSentFailureCount.addAndGet(modelValues.emailSentFailureCount) - } - log.info("$LOG_PREFIX:getCounterForMonth:$retValue") - } - return retValue - } - - /** - * {@inheritDoc} - */ - @Suppress("TooGenericExceptionCaught") - override fun incrementCountersForDay(counterDay: Date, counters: Counters) { - if (!isIndexExists()) { - createIndex() - } - var isIncremented = false - while (!isIncremented) { - isIncremented = try { - incrementCounterIndexFor(counterDay, counters) - } catch (ignored: VersionConflictEngineException) { - log.info("$LOG_PREFIX:VersionConflictEngineException retrying") - false - } catch (ignored: DocumentMissingException) { - log.info("$LOG_PREFIX:DocumentMissingException retrying") - false - } - } - } - - /** - * Create index using the schema defined in resource - */ - @Suppress("TooGenericExceptionCaught") - private fun createIndex() { - val indexMappingSource = - CounterIndex::class.java.classLoader.getResource(COUNTER_INDEX_SCHEMA_FILE_NAME)?.readText()!! - val indexSettingsSource = - CounterIndex::class.java.classLoader.getResource(COUNTER_INDEX_SETTINGS_FILE_NAME)?.readText()!! - val request = CreateIndexRequest(COUNTER_INDEX_NAME) - .mapping(MAPPING_TYPE, indexMappingSource, XContentType.YAML) - .settings(indexSettingsSource, XContentType.YAML) - try { - val actionFuture = client.admin().indices().create(request) - val response = actionFuture.actionGet(PluginSettings.operationTimeoutMs) - if (response.isAcknowledged) { - log.info("$LOG_PREFIX:Index $COUNTER_INDEX_NAME creation Acknowledged") - } else { - throw IllegalStateException("$LOG_PREFIX:Index $COUNTER_INDEX_NAME creation not Acknowledged") - } - } catch (exception: Exception) { - if (exception !is ResourceAlreadyExistsException && exception.cause !is ResourceAlreadyExistsException) { - throw exception - } - } - } - - /** - * Check if the index is created and available. - * @return true if index is available, false otherwise - */ - private fun isIndexExists(): Boolean { - val clusterState = clusterService.state() - return clusterState.routingTable.hasIndex(COUNTER_INDEX_NAME) - } - - /** - * Query index for counter for given day - * @param counterDay the counter day - * @return counter index model - */ - private fun getCounterIndexFor(counterDay: Date): CounterIndexModel { - val getRequest = GetRequest(COUNTER_INDEX_NAME).id(getIdForDate(counterDay)) - val actionFuture = client.get(getRequest) - val response = actionFuture.actionGet(PluginSettings.operationTimeoutMs) - return if (response.sourceAsString == null) { - CounterIndexModel(counterDay, 0, 0, 0) - } else { - val parser = XContentType.JSON.xContent().createParser( - NamedXContentRegistry.EMPTY, - LoggingDeprecationHandler.INSTANCE, - response.sourceAsString - ) - parser.nextToken() - val retValue = CounterIndexModel.parse(parser, response.seqNo, response.primaryTerm) - if (getIdForDate(retValue.counterDay) == getIdForDate(counterDay)) { - CounterIndexModel(counterDay, 0, 0, 0) - } - retValue - } - } - - /** - * create a new doc for counter for given day - * @param counterDay the counter day - * @param counters the initial counter values - * @return true if successful, false otherwise - */ - private fun createCounterIndexFor(counterDay: Date, counters: Counters): Boolean { - val indexRequest = IndexRequest(COUNTER_INDEX_NAME) - .id(getIdForDate(counterDay)) - .source(CounterIndexModel.getCounterIndexModel(counterDay, counters).toXContent()) - .setIfSeqNo(SequenceNumbers.UNASSIGNED_SEQ_NO) - .setIfPrimaryTerm(SequenceNumbers.UNASSIGNED_PRIMARY_TERM) - .create(true) - val actionFuture = client.index(indexRequest) - val response = actionFuture.actionGet(PluginSettings.operationTimeoutMs) - log.debug("$LOG_PREFIX:CounterIndex createCounterIndex - $counters status:${response.result}") - return response.result == DocWriteResponse.Result.CREATED - } - - /** - * update existing doc for counter for given day - * @param counterDay the counter day - * @param counterIndexModel the counter index to update - * @return true if successful, false otherwise - */ - private fun updateCounterIndexFor(counterDay: Date, counterIndexModel: CounterIndexModel): Boolean { - val updateRequest = UpdateRequest() - .index(COUNTER_INDEX_NAME) - .id(getIdForDate(counterDay)) - .setIfSeqNo(counterIndexModel.seqNo) - .setIfPrimaryTerm(counterIndexModel.primaryTerm) - .doc(counterIndexModel.toXContent()) - .fetchSource(true) - val actionFuture = client.update(updateRequest) - val response = actionFuture.actionGet(PluginSettings.operationTimeoutMs) - log.debug("$LOG_PREFIX:CounterIndex updateCounterIndex - $counterIndexModel status:${response.result}") - return response.result == DocWriteResponse.Result.UPDATED - } - - /** - * create or update doc with counter added to existing value - * @param counterDay the counter day - * @param counters the counter values to increment - * @return true if successful, false otherwise - */ - private fun incrementCounterIndexFor(counterDay: Date, counters: Counters): Boolean { - val currentValue = getCounterIndexFor(counterDay) - log.debug("$LOG_PREFIX:CounterIndex currentValue - $currentValue") - return if (currentValue.seqNo == SequenceNumbers.UNASSIGNED_SEQ_NO) { - createCounterIndexFor(counterDay, counters) - } else { - updateCounterIndexFor(counterDay, currentValue.copyAndIncrementBy(counters)) - } - } -} diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/throttle/CounterIndexModel.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/throttle/CounterIndexModel.kt deleted file mode 100644 index 01343308..00000000 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/throttle/CounterIndexModel.kt +++ /dev/null @@ -1,190 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - * - * Modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -/* - * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - * - */ - -package org.opensearch.notifications.throttle - -import org.opensearch.common.xcontent.ToXContent -import org.opensearch.common.xcontent.ToXContentObject -import org.opensearch.common.xcontent.XContentBuilder -import org.opensearch.common.xcontent.XContentFactory -import org.opensearch.common.xcontent.XContentParser -import org.opensearch.common.xcontent.XContentParser.Token.END_OBJECT -import org.opensearch.common.xcontent.XContentParser.Token.START_OBJECT -import org.opensearch.common.xcontent.XContentParserUtils -import org.opensearch.commons.utils.logger -import org.opensearch.index.seqno.SequenceNumbers -import org.opensearch.notifications.NotificationPlugin.Companion.LOG_PREFIX -import java.text.SimpleDateFormat -import java.util.Calendar -import java.util.Date -import java.util.Locale -import java.util.TimeZone - -/** - * Data class representing the doc for a day. - */ -internal data class CounterIndexModel( - val counterDay: Date, - val requestCount: Int, - val emailSentSuccessCount: Int, - val emailSentFailureCount: Int, - val seqNo: Long = SequenceNumbers.UNASSIGNED_SEQ_NO, - val primaryTerm: Long = SequenceNumbers.UNASSIGNED_PRIMARY_TERM -) : ToXContentObject { - internal companion object { - private val log by logger(CounterIndexModel::class.java) - private const val COUNTER_DAY_TAG = "counter_day" - private const val REQUEST_COUNT_TAG = "request_count" - private const val EMAIL_SENT_SUCCESS_COUNT = "email_sent_success_count" - private const val EMAIL_SENT_FAILURE_COUNT = "email_sent_failure_count" - private val DATE_FORMATTER = SimpleDateFormat("yyyy-MM-dd", Locale.ROOT) - - const val COUNTER_INDEX_MODEL_KEY = COUNTER_DAY_TAG - const val MAX_ITEMS_IN_MONTH = 31 - - /** - * get the ID for a given date - * @param day the day to create ID - * @return ID for the day - */ - fun getIdForDate(day: Date): String { - return DATE_FORMATTER.format(day) - } - - /** - * get the ID for beginning of the month of a given Instant - * @param day the reference day to create ID - * @return ID for the beginning of the month - */ - fun getIdForStartOfMonth(day: Date): String { - return getIdForDate(getFirstDateOfMonth(day)) - } - - private fun getFirstDateOfMonth(date: Date): Date { - val cal = Calendar.getInstance(TimeZone.getTimeZone("GMT"), Locale.ROOT) - cal.time = date - cal[Calendar.DAY_OF_MONTH] = cal.getActualMinimum(Calendar.DAY_OF_MONTH) - return cal.time - } - - /** - * get/create Counter index model from counters - * @param day the day to create model - * @param counters the counter values - * @return created counter index model - */ - fun getCounterIndexModel(day: Date, counters: Counters): CounterIndexModel { - return CounterIndexModel( - day, - counters.requestCount.get(), - counters.emailSentSuccessCount.get(), - counters.emailSentFailureCount.get() - ) - } - - /** - * Parse the data from parser and create Counter index model - * @param parser data referenced at parser - * @param seqNo the seqNo of the document - * @param primaryTerm the primaryTerm of the document - * @return created counter index model - */ - fun parse( - parser: XContentParser, - seqNo: Long = SequenceNumbers.UNASSIGNED_SEQ_NO, - primaryTerm: Long = SequenceNumbers.UNASSIGNED_PRIMARY_TERM - ): CounterIndexModel { - var counterDay: Date? = null - var requestCount: Int? = null - var emailSentSuccessCount: Int? = null - var emailSentFailureCount: Int? = null - XContentParserUtils.ensureExpectedToken(START_OBJECT, parser.currentToken(), parser) - while (END_OBJECT != parser.nextToken()) { - val fieldName = parser.currentName() - parser.nextToken() - when (fieldName) { - COUNTER_DAY_TAG -> counterDay = DATE_FORMATTER.parse(parser.text()) - REQUEST_COUNT_TAG -> requestCount = parser.intValue() - EMAIL_SENT_SUCCESS_COUNT -> emailSentSuccessCount = parser.intValue() - EMAIL_SENT_FAILURE_COUNT -> emailSentFailureCount = parser.intValue() - else -> { - parser.skipChildren() - log.warn("$LOG_PREFIX:Skipping Unknown field $fieldName") - } - } - } - counterDay ?: throw IllegalArgumentException("$COUNTER_DAY_TAG field not present") - requestCount ?: throw IllegalArgumentException("$REQUEST_COUNT_TAG field not present") - emailSentSuccessCount ?: throw IllegalArgumentException("$EMAIL_SENT_SUCCESS_COUNT field not present") - emailSentFailureCount ?: throw IllegalArgumentException("$EMAIL_SENT_FAILURE_COUNT field not present") - return CounterIndexModel( - counterDay, - requestCount, - emailSentSuccessCount, - emailSentFailureCount, - seqNo, - primaryTerm - ) - } - } - - /** - * copy/create Counter index model from this object - * @param counters the counter values to add to this object - * @return created counter index model - */ - fun copyAndIncrementBy(counters: Counters): CounterIndexModel { - return copy( - requestCount = requestCount + counters.requestCount.get(), - emailSentSuccessCount = emailSentSuccessCount + counters.emailSentSuccessCount.get(), - emailSentFailureCount = emailSentFailureCount + counters.emailSentFailureCount.get() - ) - } - - /** - * create XContentBuilder from this object using [XContentFactory.jsonBuilder()] - * @return created XContentBuilder object - */ - fun toXContent(): XContentBuilder? { - return toXContent(XContentFactory.jsonBuilder(), ToXContent.EMPTY_PARAMS) - } - - /** - * {@inheritDoc} - */ - override fun toXContent(builder: XContentBuilder?, params: ToXContent.Params?): XContentBuilder? { - if (builder != null) { - builder.startObject() - .field(COUNTER_DAY_TAG, getIdForDate(counterDay)) - .field(REQUEST_COUNT_TAG, requestCount) - .field(EMAIL_SENT_SUCCESS_COUNT, emailSentSuccessCount) - .field(EMAIL_SENT_FAILURE_COUNT, emailSentFailureCount) - .endObject() - } - return builder - } -} diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/throttle/Counters.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/throttle/Counters.kt deleted file mode 100644 index d656dc12..00000000 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/throttle/Counters.kt +++ /dev/null @@ -1,67 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - * - * Modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -/* - * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - * - */ - -package org.opensearch.notifications.throttle - -import java.util.concurrent.atomic.AtomicInteger - -/** - * Counter class to maintain the counting of the items - */ -internal class Counters { - /** - * Number of requests. - */ - val requestCount = AtomicInteger() - - /** - * Number of email sent successfully - */ - val emailSentSuccessCount = AtomicInteger() - - /** - * Number of email request failed - */ - val emailSentFailureCount = AtomicInteger() - - /** - * Increment the counters by given counter values - * @param counters The counter values to increment - */ - fun incrementCountersBy(counters: Counters) { - requestCount.addAndGet(counters.requestCount.get()) - emailSentSuccessCount.addAndGet(counters.emailSentSuccessCount.get()) - emailSentFailureCount.addAndGet(counters.emailSentFailureCount.get()) - } - - /** - * {@inheritDoc} - */ - override fun toString(): String { - return "{requestCount=$requestCount, emailSentSuccessCount=$emailSentSuccessCount, emailSentFailureCount=$emailSentFailureCount}" - } -} diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/throttle/EmptyMessageCounter.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/throttle/EmptyMessageCounter.kt deleted file mode 100644 index 9802c71d..00000000 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/throttle/EmptyMessageCounter.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - * - * Modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -/* - * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - * - */ - -package org.opensearch.notifications.throttle - -import java.util.Date - -/** - * Empty implementation of the message counter which responds with IllegalStateException all operations. - */ -internal object EmptyMessageCounter : MessageCounter { - /** - * {@inheritDoc} - */ - override fun incrementCountersForDay(counterDay: Date, counters: Counters) { - throw IllegalStateException("MessageCounter not initialized") - } - - /** - * {@inheritDoc} - */ - override fun getCounterForMonth(counterDay: Date): Counters { - throw IllegalStateException("MessageCounter not initialized") - } -} diff --git a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/throttle/MessageCounter.kt b/notifications/notifications/src/main/kotlin/org/opensearch/notifications/throttle/MessageCounter.kt deleted file mode 100644 index a5764e05..00000000 --- a/notifications/notifications/src/main/kotlin/org/opensearch/notifications/throttle/MessageCounter.kt +++ /dev/null @@ -1,49 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - * - * Modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -/* - * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - * - */ - -package org.opensearch.notifications.throttle - -import java.util.Date - -/** - * Message counter interface - */ -internal interface MessageCounter { - /** - * Increment the each values in the counters for the day in the index. - * @param counterDay the reference day - * @param counters the counter values to increment - */ - fun incrementCountersForDay(counterDay: Date, counters: Counters) - - /** - * Get the current counters for the month from the index. - * @param counterDay the reference day - * @return the counters with values corresponds to month of counterDay. - */ - fun getCounterForMonth(counterDay: Date): Counters -} diff --git a/notifications/notifications/src/main/plugin-metadata/plugin-security.policy b/notifications/notifications/src/main/plugin-metadata/plugin-security.policy index ba91b1a4..6a673e96 100644 --- a/notifications/notifications/src/main/plugin-metadata/plugin-security.policy +++ b/notifications/notifications/src/main/plugin-metadata/plugin-security.policy @@ -33,4 +33,29 @@ grant { permission java.net.SocketPermission "*", "connect,resolve"; permission java.net.NetPermission "getProxySelector"; permission java.io.FilePermission "${user.home}${/}.aws${/}*", "read"; + + // https://github.com/lezzago/alerting/blob/374a379f525d4638969890d15b913179e7afd122/alerting/src/main/plugin-metadata/plugin-security.policy + // needed because of problems in ClientConfiguration + // TODO: get these fixed in aws sdk + permission java.lang.RuntimePermission "accessDeclaredMembers"; + permission java.lang.RuntimePermission "getClassLoader"; + permission java.net.SocketPermission "*", "connect"; + // Needed because of problems in AmazonSNS: + // When no region is set on a STSClient instance, the + // AWS SDK loads all known partitions from a JSON file and + // uses a Jackson's ObjectMapper for that: this one, in + // version 2.5.3 with the default binding options, tries + // to suppress access checks of ctor/field/method and thus + // requires this special permission. AWS must be fixed to + // uses Jackson correctly and have the correct modifiers + // on binded classes. + // TODO: get these fixed in aws sdk + // See https://github.com/aws/aws-sdk-java/issues/766 + permission java.lang.reflect.ReflectPermission "suppressAccessChecks"; + + // Below is specific for notification SNS client + permission javax.management.MBeanServerPermission "createMBeanServer"; + permission javax.management.MBeanServerPermission "findMBeanServer"; + permission javax.management.MBeanPermission "com.amazonaws.metrics.*", "*"; + permission javax.management.MBeanTrustPermission "register"; }; diff --git a/notifications/notifications/src/main/resources/notifications-config-mapping.yml b/notifications/notifications/src/main/resources/notifications-config-mapping.yml index 026ed3f8..26eb0b68 100644 --- a/notifications/notifications/src/main/resources/notifications-config-mapping.yml +++ b/notifications/notifications/src/main/resources/notifications-config-mapping.yml @@ -100,6 +100,19 @@ properties: type: keyword email_group_id_list: type: keyword + sns: # sns configuration + type: object + properties: + topic_arn: + type: text + fields: + keyword: + type: keyword + role_arn: + type: text + fields: + keyword: + type: keyword smtp_account: # smtp account configuration type: object properties: @@ -117,6 +130,21 @@ properties: fields: keyword: type: keyword + ses_account: # smtp account configuration + type: object + properties: + region: + type: keyword + role_arn: + type: text + fields: + keyword: + type: keyword + from_address: + type: text + fields: + keyword: + type: keyword email_group: # email group configuration type: object properties: diff --git a/notifications/notifications/src/test/java/org/opensearch/notifications/metrics/BasicCounterTest.java b/notifications/notifications/src/test/java/org/opensearch/notifications/metrics/BasicCounterTest.java new file mode 100644 index 00000000..97bcc24b --- /dev/null +++ b/notifications/notifications/src/test/java/org/opensearch/notifications/metrics/BasicCounterTest.java @@ -0,0 +1,39 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.notifications.metrics; + +import org.junit.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +public class BasicCounterTest { + + @Test + public void increment() { + BasicCounter counter = new BasicCounter(); + for (int i=0; i<5; ++i) { + counter.increment(); + } + + assertThat(counter.getValue(), equalTo(5L)); + } + + @Test + public void incrementN() { + BasicCounter counter = new BasicCounter(); + counter.add(5); + + assertThat(counter.getValue(), equalTo(5L)); + } + +} \ No newline at end of file diff --git a/notifications/notifications/src/test/java/org/opensearch/notifications/metrics/RollingCounterTest.java b/notifications/notifications/src/test/java/org/opensearch/notifications/metrics/RollingCounterTest.java new file mode 100644 index 00000000..0e2330a4 --- /dev/null +++ b/notifications/notifications/src/test/java/org/opensearch/notifications/metrics/RollingCounterTest.java @@ -0,0 +1,85 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.notifications.metrics; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; + +import java.time.Clock; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.lessThanOrEqualTo; +import static org.mockito.Mockito.when; + +@RunWith(MockitoJUnitRunner.class) +public class RollingCounterTest { + + @Mock + Clock clock; + + @Test + public void increment() { + RollingCounter counter = new RollingCounter(3, 1, clock); + for (int i=0; i<5; ++i) { + counter.increment(); + } + + assertThat(counter.getValue(), equalTo(0L)); + + when(clock.millis()).thenReturn(1000L); // 1 second passed + assertThat(counter.getValue(), equalTo(5L)); + + counter.increment(); + counter.increment(); + + when(clock.millis()).thenReturn(2000L); // 1 second passed + assertThat(counter.getValue(), lessThanOrEqualTo(3L)); + + when(clock.millis()).thenReturn(3000L); // 1 second passed + assertThat(counter.getValue(), equalTo(0L)); + + } + + @Test + public void add() { + RollingCounter counter = new RollingCounter(3, 1, clock); + + counter.add(6); + assertThat(counter.getValue(), equalTo(0L)); + + when(clock.millis()).thenReturn(1000L); // 1 second passed + assertThat(counter.getValue(), equalTo(6L)); + + counter.add(4); + when(clock.millis()).thenReturn(2000L); // 1 second passed + assertThat(counter.getValue(), equalTo(4L)); + + when(clock.millis()).thenReturn(3000L); // 1 second passed + assertThat(counter.getValue(), equalTo(0L)); + } + + @Test + public void trim() { + RollingCounter counter = new RollingCounter(2, 1, clock); + + for (int i=1; i<6; ++i) { + counter.increment(); + assertThat(counter.size(), equalTo(i)); + when(clock.millis()).thenReturn(i * 1000L); // i seconds passed + } + counter.increment(); + assertThat(counter.size(), lessThanOrEqualTo(3)); + } +} diff --git a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/NotificationsRestTestCase.kt b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/NotificationsRestTestCase.kt deleted file mode 100644 index 54caac55..00000000 --- a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/NotificationsRestTestCase.kt +++ /dev/null @@ -1,126 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - * - * Modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -/* - * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - * - */ - -package org.opensearch.integtest - -import com.google.gson.JsonObject -import org.junit.After -import org.junit.Before -import org.opensearch.client.Request -import org.opensearch.client.RequestOptions -import org.opensearch.notifications.resthandler.SendMessageRestHandler.Companion.SEND_BASE_URI -import org.opensearch.notifications.settings.PluginSettings -import org.springframework.integration.test.mail.TestMailServer - -abstract class NotificationsRestTestCase : PluginRestTestCase() { - - private val smtpPort = PluginSettings.smtpPort - private val smtpServer: TestMailServer.SmtpServer - private val fromAddress = "from@email.com" - - init { - smtpServer = TestMailServer.smtp(smtpPort) - } - - @Before - @Throws(InterruptedException::class) - fun setupNotification() { - resetFromAddress() - init() - } - - @After - open fun tearDownServer() { - smtpServer.stop() - smtpServer.resetServer() - } - - protected fun executeRequest( - refTag: String, - recipients: List, - title: String, - textDescription: String, - htmlDescription: String, - attachment: JsonObject - ): JsonObject { - val request = buildRequest(refTag, recipients, title, textDescription, htmlDescription, attachment) - return executeRequest(request) - } - - protected fun buildRequest( - refTag: String, - recipients: List, - title: String, - textDescription: String, - htmlDescription: String, - attachment: JsonObject - ): Request { - val request = Request("POST", SEND_BASE_URI) - - val jsonEntity = NotificationsJsonEntity.Builder() - .setRefTag(refTag) - .setRecipients(recipients) - .setTitle(title) - .setTextDescription(textDescription) - .setHtmlDescription(htmlDescription) - .setAttachment(attachment.toString()) - .build() - request.setJsonEntity(jsonEntity.getJsonEntityAsString()) - - val restOptionsBuilder = RequestOptions.DEFAULT.toBuilder() - restOptionsBuilder.addHeader("Content-Type", "application/json") - request.setOptions(restOptionsBuilder) - return request - } - - /** Provided for each test to load test index, data and other setup work */ - protected open fun init() {} - - protected fun setFromAddress(address: String): JsonObject? { - return updateClusterSettings( - ClusterSetting( - "persistent", "opensearch.notifications.email.fromAddress", address - ) - ) - } - - protected fun resetFromAddress(): JsonObject? { - return setFromAddress(fromAddress) - } - - protected fun setChannelType(type: String) { - updateClusterSettings( - ClusterSetting( - "persistent", "opensearch.notifications.email.channel", type - ) - ) - } - - protected fun resetChannelType() { - setChannelType(PluginSettings.emailChannel) - } -} diff --git a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/channel/SesChannelIT.kt b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/channel/SesChannelIT.kt deleted file mode 100644 index 02a8b73c..00000000 --- a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/channel/SesChannelIT.kt +++ /dev/null @@ -1,72 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - * - * Modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -/* - * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - * - */ - -package org.opensearch.integtest.channel - -import org.junit.After -import org.opensearch.integtest.NotificationsRestTestCase -import org.opensearch.integtest.getStatusCode -import org.opensearch.integtest.getStatusText -import org.opensearch.integtest.jsonify -import org.opensearch.rest.RestStatus - -class SesChannelIT : NotificationsRestTestCase() { - private val refTag = "ref" - private val title = "title" - private val textDescription = "text" - private val htmlDescription = "html" - private val attachment = jsonify( - """ - { - "file_name": "odfe.data", - "file_encoding": "base64", - "file_content_type": "application/octet-stream", - "file_data": "VGVzdCBtZXNzYWdlCgo=" - } - """.trimIndent() - ) - - override fun init() { - setChannelType("ses") - } - - @After - fun reset() { - resetChannelType() - } - - fun `test send email over ses channel due to ses authorization failure`() { - val recipients = listOf("mailto:test@localhost") - val response = executeRequest(refTag, recipients, title, textDescription, htmlDescription, attachment) - - val statusCode = getStatusCode(response) - assertEquals(RestStatus.FAILED_DEPENDENCY.status, statusCode) - - val statusText = getStatusText(response) - assertEquals("sendEmail Error, SES status:403:Optional[Forbidden]", statusText) - } -} diff --git a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/channel/SmtpChannelIT.kt b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/channel/SmtpChannelIT.kt deleted file mode 100644 index 5f0878af..00000000 --- a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/channel/SmtpChannelIT.kt +++ /dev/null @@ -1,78 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - * - * Modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -/* - * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - * - */ - -package org.opensearch.integtest.channel - -import org.opensearch.integtest.NotificationsRestTestCase -import org.opensearch.integtest.getStatusCode -import org.opensearch.integtest.getStatusText -import org.opensearch.integtest.jsonify -import org.opensearch.integtest.verifyResponse -import org.opensearch.notifications.settings.PluginSettings -import org.opensearch.rest.RestStatus - -internal class SmtpChannelIT : NotificationsRestTestCase() { - private val refTag = "sample ref name" - private val title = "sample title" - private val textDescription = "Description for notification in text" - private val htmlDescription = "Description for notification in json encode html format" - private val attachment = jsonify( - """ - { - "file_name": "odfe.data", - "file_encoding": "base64", - "file_content_type": "application/octet-stream", - "file_data": "VGVzdCBtZXNzYWdlCgo=" - } - """.trimIndent() - ) - - fun `test send email to one recipient over Smtp server`() { - val recipients = listOf("mailto:test@localhost") - val response = executeRequest(refTag, recipients, title, textDescription, htmlDescription, attachment) - verifyResponse(response, refTag, recipients) - } - - fun `test send email to multiple recipient over Smtp server`() { - val recipients = listOf("mailto:test1@localhost", "mailto:test2@abc.com", "mailto:test3@123.com") - val response = executeRequest(refTag, recipients, title, textDescription, htmlDescription, attachment) - verifyResponse(response, refTag, recipients) - } - - fun `test send email with unconfigured address`() { - setFromAddress(PluginSettings.UNCONFIGURED_EMAIL_ADDRESS) - val recipients = listOf("mailto:test@localhost") - val response = executeRequest(refTag, recipients, title, textDescription, htmlDescription, attachment) - - val statusCode = getStatusCode(response) - assertEquals(RestStatus.NOT_IMPLEMENTED.status, statusCode) - - val statusText = getStatusText(response) - assertEquals("Email from: address not configured", statusText) - resetFromAddress() - } -} diff --git a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/config/ChimeNotificationConfigCrudIT.kt b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/config/ChimeNotificationConfigCrudIT.kt index 40149453..1263f6f8 100644 --- a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/config/ChimeNotificationConfigCrudIT.kt +++ b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/config/ChimeNotificationConfigCrudIT.kt @@ -27,16 +27,17 @@ package org.opensearch.integtest.config import org.junit.Assert +import org.opensearch.commons.notifications.NotificationConstants.FEATURE_ALERTING +import org.opensearch.commons.notifications.NotificationConstants.FEATURE_INDEX_MANAGEMENT +import org.opensearch.commons.notifications.NotificationConstants.FEATURE_REPORTS import org.opensearch.commons.notifications.model.Chime import org.opensearch.commons.notifications.model.ConfigType -import org.opensearch.commons.notifications.model.Feature import org.opensearch.commons.notifications.model.NotificationConfig import org.opensearch.integtest.PluginRestTestCase import org.opensearch.notifications.NotificationPlugin.Companion.PLUGIN_BASE_URI import org.opensearch.notifications.verifySingleConfigEquals import org.opensearch.rest.RestRequest import org.opensearch.rest.RestStatus -import java.util.EnumSet class ChimeNotificationConfigCrudIT : PluginRestTestCase() { @@ -47,7 +48,7 @@ class ChimeNotificationConfigCrudIT : PluginRestTestCase() { "this is a sample config name", "this is a sample config description", ConfigType.CHIME, - EnumSet.of(Feature.ALERTING, Feature.REPORTS), + setOf(FEATURE_ALERTING, FEATURE_REPORTS), isEnabled = true, configData = sampleChime ) @@ -106,7 +107,7 @@ class ChimeNotificationConfigCrudIT : PluginRestTestCase() { "this is a updated config name", "this is a updated config description", ConfigType.CHIME, - EnumSet.of(Feature.INDEX_MANAGEMENT), + setOf(FEATURE_INDEX_MANAGEMENT), isEnabled = true, configData = updatedChime ) @@ -174,7 +175,7 @@ class ChimeNotificationConfigCrudIT : PluginRestTestCase() { "this is a sample config name", "this is a sample config description", ConfigType.CHIME, - EnumSet.of(Feature.ALERTING, Feature.REPORTS), + setOf(FEATURE_ALERTING, FEATURE_REPORTS), isEnabled = true, configData = sampleChime ) @@ -203,4 +204,65 @@ class ChimeNotificationConfigCrudIT : PluginRestTestCase() { RestStatus.BAD_REQUEST.status ) } + + fun `test update existing config to different config type`() { + // Create sample config request reference + val sampleChime = Chime("https://domain.com/sample_chime_url#1234567890") + val referenceObject = NotificationConfig( + "this is a sample config name", + "this is a sample config description", + ConfigType.CHIME, + setOf(FEATURE_ALERTING, FEATURE_REPORTS), + isEnabled = true, + configData = sampleChime + ) + + // Create chime notification config + val createRequestJsonString = """ + { + "config":{ + "name":"${referenceObject.name}", + "description":"${referenceObject.description}", + "config_type":"chime", + "feature_list":[ + "${referenceObject.features.elementAt(0)}", + "${referenceObject.features.elementAt(1)}" + ], + "is_enabled":${referenceObject.isEnabled}, + "chime":{"url":"${(referenceObject.configData as Chime).url}"} + } + } + """.trimIndent() + val createResponse = executeRequest( + RestRequest.Method.POST.name, + "$PLUGIN_BASE_URI/configs", + createRequestJsonString, + RestStatus.OK.status + ) + val configId = createResponse.get("config_id").asString + Assert.assertNotNull(configId) + Thread.sleep(1000) + + // Update to slack notification config + val updateRequestJsonString = """ + { + "config":{ + "name":"this is a updated config name", + "description":"this is a updated config description", + "config_type":"slack", + "feature_list":[ + "$FEATURE_INDEX_MANAGEMENT" + ], + "is_enabled":"true", + "slack":{"url":"https://updated.domain.com/updated_slack_url#0987654321"} + } + } + """.trimIndent() + executeRequest( + RestRequest.Method.PUT.name, + "$PLUGIN_BASE_URI/configs/$configId", + updateRequestJsonString, + RestStatus.CONFLICT.status + ) + } } diff --git a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/config/CreateNotificationConfigIT.kt b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/config/CreateNotificationConfigIT.kt index 3d4044d4..2dcd530a 100644 --- a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/config/CreateNotificationConfigIT.kt +++ b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/config/CreateNotificationConfigIT.kt @@ -28,9 +28,11 @@ package org.opensearch.integtest.config import org.junit.Assert +import org.opensearch.commons.notifications.NotificationConstants.FEATURE_ALERTING +import org.opensearch.commons.notifications.NotificationConstants.FEATURE_INDEX_MANAGEMENT +import org.opensearch.commons.notifications.NotificationConstants.FEATURE_REPORTS import org.opensearch.commons.notifications.model.Chime import org.opensearch.commons.notifications.model.ConfigType -import org.opensearch.commons.notifications.model.Feature import org.opensearch.commons.notifications.model.MethodType import org.opensearch.commons.notifications.model.NotificationConfig import org.opensearch.commons.notifications.model.Slack @@ -41,7 +43,6 @@ import org.opensearch.notifications.NotificationPlugin.Companion.PLUGIN_BASE_URI import org.opensearch.notifications.verifySingleConfigEquals import org.opensearch.rest.RestRequest import org.opensearch.rest.RestStatus -import java.util.EnumSet class CreateNotificationConfigIT : PluginRestTestCase() { @@ -52,7 +53,7 @@ class CreateNotificationConfigIT : PluginRestTestCase() { "this is a sample config name", "this is a sample config description", ConfigType.SLACK, - EnumSet.of(Feature.INDEX_MANAGEMENT, Feature.REPORTS), + setOf(FEATURE_INDEX_MANAGEMENT, FEATURE_REPORTS), isEnabled = true, configData = sampleSlack ) @@ -83,7 +84,7 @@ class CreateNotificationConfigIT : PluginRestTestCase() { Assert.assertNotNull(configId) Thread.sleep(1000) - // Get slack notification config + // Get Slack notification config val getConfigResponse = executeRequest( RestRequest.Method.GET.name, @@ -102,7 +103,7 @@ class CreateNotificationConfigIT : PluginRestTestCase() { "this is a sample config name", "this is a sample config description", ConfigType.CHIME, - EnumSet.of(Feature.ALERTING, Feature.REPORTS), + setOf(FEATURE_ALERTING, FEATURE_REPORTS), isEnabled = true, configData = sampleChime ) @@ -151,7 +152,7 @@ class CreateNotificationConfigIT : PluginRestTestCase() { "this is a sample config name", "this is a sample config description", ConfigType.WEBHOOK, - EnumSet.of(Feature.INDEX_MANAGEMENT, Feature.REPORTS, Feature.ALERTING), + setOf(FEATURE_INDEX_MANAGEMENT, FEATURE_REPORTS, FEATURE_ALERTING), isEnabled = true, configData = sampleWebhook ) @@ -200,7 +201,7 @@ class CreateNotificationConfigIT : PluginRestTestCase() { "this is another config name", "this is another config description", ConfigType.WEBHOOK, - EnumSet.of(Feature.INDEX_MANAGEMENT, Feature.REPORTS), + setOf(FEATURE_INDEX_MANAGEMENT, FEATURE_REPORTS), isEnabled = true, configData = anotherWebhook ) @@ -244,7 +245,7 @@ class CreateNotificationConfigIT : PluginRestTestCase() { "this is a sample smtp account config name", "this is a sample smtp account config description", ConfigType.SMTP_ACCOUNT, - EnumSet.of(Feature.REPORTS), + setOf(FEATURE_REPORTS), isEnabled = true, configData = sampleSmtpAccount ) diff --git a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/config/EmailNotificationConfigCrudIT.kt b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/config/EmailNotificationConfigCrudIT.kt index bc1382b1..5f754ae5 100644 --- a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/config/EmailNotificationConfigCrudIT.kt +++ b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/config/EmailNotificationConfigCrudIT.kt @@ -28,12 +28,14 @@ package org.opensearch.integtest.config import org.junit.Assert +import org.opensearch.commons.notifications.NotificationConstants.FEATURE_INDEX_MANAGEMENT +import org.opensearch.commons.notifications.NotificationConstants.FEATURE_REPORTS import org.opensearch.commons.notifications.model.ConfigType import org.opensearch.commons.notifications.model.Email import org.opensearch.commons.notifications.model.EmailGroup -import org.opensearch.commons.notifications.model.Feature import org.opensearch.commons.notifications.model.MethodType import org.opensearch.commons.notifications.model.NotificationConfig +import org.opensearch.commons.notifications.model.SesAccount import org.opensearch.commons.notifications.model.SmtpAccount import org.opensearch.integtest.PluginRestTestCase import org.opensearch.notifications.NotificationPlugin.Companion.PLUGIN_BASE_URI @@ -43,11 +45,10 @@ import org.opensearch.notifications.verifySingleConfigEquals import org.opensearch.notifications.verifySingleConfigIdEquals import org.opensearch.rest.RestRequest import org.opensearch.rest.RestStatus -import java.util.EnumSet class EmailNotificationConfigCrudIT : PluginRestTestCase() { - fun `test Create, Get, Update, Delete email notification config using REST client`() { + fun `test Create, Get, Update, Delete smtp email notification config using REST client`() { // Create sample smtp account config request reference val sampleSmtpAccount = SmtpAccount( "smtp.domain.com", @@ -59,7 +60,7 @@ class EmailNotificationConfigCrudIT : PluginRestTestCase() { "this is a sample smtp account config name", "this is a sample smtp account config description", ConfigType.SMTP_ACCOUNT, - EnumSet.of(Feature.REPORTS), + setOf(FEATURE_REPORTS), isEnabled = true, configData = sampleSmtpAccount ) @@ -100,7 +101,7 @@ class EmailNotificationConfigCrudIT : PluginRestTestCase() { "this is a sample email group config name", "this is a sample email group config description", ConfigType.EMAIL_GROUP, - EnumSet.of(Feature.REPORTS), + setOf(FEATURE_REPORTS), isEnabled = true, configData = sampleEmailGroup ) @@ -145,7 +146,7 @@ class EmailNotificationConfigCrudIT : PluginRestTestCase() { "this is a sample config name", "this is a sample config description", ConfigType.EMAIL, - EnumSet.of(Feature.REPORTS), + setOf(FEATURE_REPORTS), isEnabled = true, configData = sampleEmail ) @@ -241,7 +242,7 @@ class EmailNotificationConfigCrudIT : PluginRestTestCase() { "this is a updated smtp account config name", "this is a updated smtp account config description", ConfigType.SMTP_ACCOUNT, - EnumSet.of(Feature.REPORTS), + setOf(FEATURE_REPORTS), isEnabled = true, configData = updatedSmtpAccount ) @@ -326,6 +327,281 @@ class EmailNotificationConfigCrudIT : PluginRestTestCase() { Thread.sleep(100) } + fun `test Create, Get, Update, Delete ses email notification config using REST client`() { + // Create sample ses account config request reference + val sampleSesAccount = SesAccount( + "us-east-1", + "arn:aws:iam::012345678912:role/iam-test", + "from@domain.com" + ) + val sesAccountConfig = NotificationConfig( + "this is a sample ses account config name", + "this is a sample ses account config description", + ConfigType.SES_ACCOUNT, + setOf(FEATURE_REPORTS), + isEnabled = true, + configData = sampleSesAccount + ) + + // Create ses account notification config + val createSesAccountRequestJsonString = """ + { + "config":{ + "name":"${sesAccountConfig.name}", + "description":"${sesAccountConfig.description}", + "config_type":"ses_account", + "feature_list":[ + "${sesAccountConfig.features.elementAt(0)}" + ], + "is_enabled":${sesAccountConfig.isEnabled}, + "ses_account":{ + "region":"${sampleSesAccount.awsRegion}", + "role_arn":"${sampleSesAccount.roleArn}", + "from_address":"${sampleSesAccount.fromAddress}" + } + } + } + """.trimIndent() + val createSesAccountResponse = executeRequest( + RestRequest.Method.POST.name, + "$PLUGIN_BASE_URI/configs", + createSesAccountRequestJsonString, + RestStatus.OK.status + ) + val sesAccountConfigId = createSesAccountResponse.get("config_id").asString + Assert.assertNotNull(sesAccountConfigId) + Thread.sleep(100) + + // Create sample email group config request reference + val sampleEmailGroup = EmailGroup(listOf("email1@email.com", "email2@email.com")) + val emailGroupConfig = NotificationConfig( + "this is a sample email group config name", + "this is a sample email group config description", + ConfigType.EMAIL_GROUP, + setOf(FEATURE_REPORTS), + isEnabled = true, + configData = sampleEmailGroup + ) + + // Create email group notification config + val createEmailGroupRequestJsonString = """ + { + "config":{ + "name":"${emailGroupConfig.name}", + "description":"${emailGroupConfig.description}", + "config_type":"email_group", + "feature_list":[ + "${emailGroupConfig.features.elementAt(0)}" + ], + "is_enabled":${emailGroupConfig.isEnabled}, + "email_group":{ + "recipient_list":[ + "${sampleEmailGroup.recipients[0]}", + "${sampleEmailGroup.recipients[1]}" + ] + } + } + } + """.trimIndent() + val createEmailGroupResponse = executeRequest( + RestRequest.Method.POST.name, + "$PLUGIN_BASE_URI/configs", + createEmailGroupRequestJsonString, + RestStatus.OK.status + ) + val emailGroupConfigId = createEmailGroupResponse.get("config_id").asString + Assert.assertNotNull(emailGroupConfigId) + Thread.sleep(100) + + // Create sample email config request reference + val sampleEmail = Email( + sesAccountConfigId, + listOf("default-email1@email.com", "default-email2@email.com"), + listOf(emailGroupConfigId) + ) + val emailConfig = NotificationConfig( + "this is a sample config name", + "this is a sample config description", + ConfigType.EMAIL, + setOf(FEATURE_REPORTS), + isEnabled = true, + configData = sampleEmail + ) + + // Create email notification config + val createEmailRequestJsonString = """ + { + "config":{ + "name":"${emailConfig.name}", + "description":"${emailConfig.description}", + "config_type":"email", + "feature_list":[ + "${emailConfig.features.elementAt(0)}" + ], + "is_enabled":${emailConfig.isEnabled}, + "email":{ + "email_account_id":"${sampleEmail.emailAccountID}", + "recipient_list":[ + "${sampleEmail.recipients[0]}", + "${sampleEmail.recipients[1]}" + ], + "email_group_id_list":[ + "${sampleEmail.emailGroupIds[0]}" + ] + } + } + } + """.trimIndent() + val createEmailResponse = executeRequest( + RestRequest.Method.POST.name, + "$PLUGIN_BASE_URI/configs", + createEmailRequestJsonString, + RestStatus.OK.status + ) + val emailConfigId = createEmailResponse.get("config_id").asString + Assert.assertNotNull(emailConfigId) + Thread.sleep(1000) + + // Get email notification config + val getSesAccountResponse = executeRequest( + RestRequest.Method.GET.name, + "$PLUGIN_BASE_URI/configs/$sesAccountConfigId", + "", + RestStatus.OK.status + ) + verifySingleConfigEquals(sesAccountConfigId, sesAccountConfig, getSesAccountResponse) + Thread.sleep(100) + + val getEmailGroupResponse = executeRequest( + RestRequest.Method.GET.name, + "$PLUGIN_BASE_URI/configs/$emailGroupConfigId", + "", + RestStatus.OK.status + ) + verifySingleConfigEquals(emailGroupConfigId, emailGroupConfig, getEmailGroupResponse) + Thread.sleep(100) + + val getEmailResponse = executeRequest( + RestRequest.Method.GET.name, + "$PLUGIN_BASE_URI/configs/$emailConfigId", + "", + RestStatus.OK.status + ) + verifySingleConfigEquals(emailConfigId, emailConfig, getEmailResponse) + Thread.sleep(100) + + // Get all notification config + + val getAllConfigResponse = executeRequest( + RestRequest.Method.GET.name, + "$PLUGIN_BASE_URI/configs", + "", + RestStatus.OK.status + ) + verifyMultiConfigEquals( + mapOf( + Pair(sesAccountConfigId, sesAccountConfig), + Pair(emailGroupConfigId, emailGroupConfig), + Pair(emailConfigId, emailConfig) + ), + getAllConfigResponse + ) + Thread.sleep(100) + + // Updated ses account config object + val updatedSesAccount = SesAccount( + "us-west-2", + "arn:aws:iam::012345678912:role/updated-role-test", + "updated-from@domain.com" + ) + val updatedSesAccountConfig = NotificationConfig( + "this is a updated ses account config name", + "this is a updated ses account config description", + ConfigType.SES_ACCOUNT, + setOf(FEATURE_REPORTS), + isEnabled = true, + configData = updatedSesAccount + ) + + // Update ses account notification config + val updateSesAccountRequestJsonString = """ + { + "config":{ + "name":"${updatedSesAccountConfig.name}", + "description":"${updatedSesAccountConfig.description}", + "config_type":"ses_account", + "feature_list":[ + "${updatedSesAccountConfig.features.elementAt(0)}" + ], + "is_enabled":${updatedSesAccountConfig.isEnabled}, + "ses_account":{ + "region":"${updatedSesAccount.awsRegion}", + "role_arn":"${updatedSesAccount.roleArn}", + "from_address":"${updatedSesAccount.fromAddress}" + } + } + } + """.trimIndent() + + val updateSesAccountResponse = executeRequest( + RestRequest.Method.PUT.name, + "$PLUGIN_BASE_URI/configs/$sesAccountConfigId", + updateSesAccountRequestJsonString, + RestStatus.OK.status + ) + Assert.assertEquals(sesAccountConfigId, updateSesAccountResponse.get("config_id").asString) + + Thread.sleep(1000) + + // Get updated ses account config + + val getUpdatedSesAccountResponse = executeRequest( + RestRequest.Method.GET.name, + "$PLUGIN_BASE_URI/configs/$sesAccountConfigId", + "", + RestStatus.OK.status + ) + verifySingleConfigEquals(sesAccountConfigId, updatedSesAccountConfig, getUpdatedSesAccountResponse) + Thread.sleep(100) + + // Get all updated config + val getAllUpdatedConfigResponse = executeRequest( + RestRequest.Method.GET.name, + "$PLUGIN_BASE_URI/configs", + "", + RestStatus.OK.status + ) + verifyMultiConfigEquals( + mapOf( + Pair(sesAccountConfigId, updatedSesAccountConfig), + Pair(emailGroupConfigId, emailGroupConfig), + Pair(emailConfigId, emailConfig) + ), + getAllUpdatedConfigResponse + ) + Thread.sleep(100) + + // Delete email notification config + val deleteResponse = executeRequest( + RestRequest.Method.DELETE.name, + "$PLUGIN_BASE_URI/configs/$emailConfigId", + "", + RestStatus.OK.status + ) + Assert.assertEquals("OK", deleteResponse.get("delete_response_list").asJsonObject.get(emailConfigId).asString) + Thread.sleep(1000) + + // Get email notification config after delete + + executeRequest( + RestRequest.Method.GET.name, + "$PLUGIN_BASE_URI/configs/$emailConfigId", + "", + RestStatus.NOT_FOUND.status + ) + Thread.sleep(100) + } + fun `test Create email notification config without email_group IDs`() { // Create smtp account notification config val createSmtpAccountRequestJsonString = """ @@ -367,7 +643,7 @@ class EmailNotificationConfigCrudIT : PluginRestTestCase() { "this is a sample config name", "this is a sample config description", ConfigType.EMAIL, - EnumSet.of(Feature.REPORTS), + setOf(FEATURE_REPORTS), isEnabled = true, configData = sampleEmail ) @@ -425,7 +701,7 @@ class EmailNotificationConfigCrudIT : PluginRestTestCase() { "this is a sample config name", "this is a sample config description", ConfigType.EMAIL, - EnumSet.of(Feature.REPORTS), + setOf(FEATURE_REPORTS), isEnabled = true, configData = sampleEmail ) @@ -483,7 +759,7 @@ class EmailNotificationConfigCrudIT : PluginRestTestCase() { "this is a sample smtp account config name", "this is a sample smtp account config description", ConfigType.SMTP_ACCOUNT, - EnumSet.of(Feature.REPORTS), + setOf(FEATURE_REPORTS), isEnabled = true, configData = sampleSmtpAccount ) @@ -528,7 +804,7 @@ class EmailNotificationConfigCrudIT : PluginRestTestCase() { "this is a sample config name", "this is a sample config description", ConfigType.EMAIL, - EnumSet.of(Feature.REPORTS), + setOf(FEATURE_REPORTS), isEnabled = true, configData = sampleEmail ) @@ -618,7 +894,7 @@ class EmailNotificationConfigCrudIT : PluginRestTestCase() { "this is a sample config name", "this is a sample config description", ConfigType.EMAIL, - EnumSet.of(Feature.REPORTS, Feature.INDEX_MANAGEMENT), + setOf(FEATURE_REPORTS, FEATURE_INDEX_MANAGEMENT), isEnabled = true, configData = sampleEmail ) @@ -881,7 +1157,7 @@ class EmailNotificationConfigCrudIT : PluginRestTestCase() { "this is a sample smtp account config name", "this is a sample smtp account config description", ConfigType.SMTP_ACCOUNT, - EnumSet.of(Feature.REPORTS), + setOf(FEATURE_REPORTS), isEnabled = true, configData = sampleSmtpAccount ) @@ -926,7 +1202,7 @@ class EmailNotificationConfigCrudIT : PluginRestTestCase() { "this is a sample config name", "this is a sample config description", ConfigType.EMAIL, - EnumSet.of(Feature.REPORTS), + setOf(FEATURE_REPORTS), isEnabled = true, configData = sampleEmail ) diff --git a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/config/QueryNotificationConfigIT.kt b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/config/QueryNotificationConfigIT.kt index e8c4fa9a..58053983 100644 --- a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/config/QueryNotificationConfigIT.kt +++ b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/config/QueryNotificationConfigIT.kt @@ -28,15 +28,17 @@ package org.opensearch.integtest.config import org.junit.Assert +import org.opensearch.commons.notifications.NotificationConstants.FEATURE_ALERTING +import org.opensearch.commons.notifications.NotificationConstants.FEATURE_INDEX_MANAGEMENT +import org.opensearch.commons.notifications.NotificationConstants.FEATURE_REPORTS +import org.opensearch.commons.notifications.model.Chime import org.opensearch.commons.notifications.model.ConfigType -import org.opensearch.commons.notifications.model.Feature -import org.opensearch.commons.notifications.model.Feature.ALERTING -import org.opensearch.commons.notifications.model.Feature.INDEX_MANAGEMENT -import org.opensearch.commons.notifications.model.Feature.REPORTS +import org.opensearch.commons.notifications.model.NotificationConfig import org.opensearch.integtest.PluginRestTestCase import org.opensearch.notifications.NotificationPlugin.Companion.PLUGIN_BASE_URI import org.opensearch.notifications.verifyMultiConfigIdEquals import org.opensearch.notifications.verifyOrderedConfigList +import org.opensearch.notifications.verifySingleConfigEquals import org.opensearch.notifications.verifySingleConfigIdEquals import org.opensearch.rest.RestRequest import org.opensearch.rest.RestStatus @@ -50,7 +52,7 @@ class QueryNotificationConfigIT : PluginRestTestCase() { nameSubstring: String, configType: ConfigType, isEnabled: Boolean, - features: Set + features: Set ): String { val randomString = (1..20) .map { Random.nextInt(0, charPool.size) } @@ -104,7 +106,7 @@ class QueryNotificationConfigIT : PluginRestTestCase() { nameSubstring: String = "", configType: ConfigType = ConfigType.SLACK, isEnabled: Boolean = true, - features: Set = setOf(ALERTING, INDEX_MANAGEMENT, Feature.REPORTS) + features: Set = setOf(FEATURE_ALERTING, FEATURE_INDEX_MANAGEMENT, FEATURE_REPORTS) ): String { val createRequestJsonString = getCreateRequestJsonString(nameSubstring, configType, isEnabled, features) val createResponse = executeRequest( @@ -397,13 +399,13 @@ class QueryNotificationConfigIT : PluginRestTestCase() { } fun `test Get sorted notification config using multi keyword sort_field(features)`() { - val iId = createConfig(features = setOf(INDEX_MANAGEMENT)) - val aId = createConfig(features = setOf(ALERTING)) - val rId = createConfig(features = setOf(REPORTS)) - val iaId = createConfig(features = setOf(INDEX_MANAGEMENT, ALERTING)) - val raId = createConfig(features = setOf(REPORTS, ALERTING)) - val riId = createConfig(features = setOf(REPORTS, INDEX_MANAGEMENT)) - val iarId = createConfig(features = setOf(INDEX_MANAGEMENT, ALERTING, REPORTS)) + val iId = createConfig(features = setOf(FEATURE_INDEX_MANAGEMENT)) + val aId = createConfig(features = setOf(FEATURE_ALERTING)) + val rId = createConfig(features = setOf(FEATURE_REPORTS)) + val iaId = createConfig(features = setOf(FEATURE_INDEX_MANAGEMENT, FEATURE_ALERTING)) + val raId = createConfig(features = setOf(FEATURE_REPORTS, FEATURE_ALERTING)) + val riId = createConfig(features = setOf(FEATURE_REPORTS, FEATURE_INDEX_MANAGEMENT)) + val iarId = createConfig(features = setOf(FEATURE_INDEX_MANAGEMENT, FEATURE_ALERTING, FEATURE_REPORTS)) Thread.sleep(1000) val sortedConfigIds = listOf(aId, iaId, raId, iarId, iId, riId, rId) @@ -572,13 +574,13 @@ class QueryNotificationConfigIT : PluginRestTestCase() { } fun `test Get filtered notification config using keyword filter_param_list(features)`() { - val iId = createConfig(features = setOf(INDEX_MANAGEMENT)) - val aId = createConfig(features = setOf(ALERTING)) - val rId = createConfig(features = setOf(REPORTS)) - val iaId = createConfig(features = setOf(INDEX_MANAGEMENT, ALERTING)) - val raId = createConfig(features = setOf(REPORTS, ALERTING)) - val riId = createConfig(features = setOf(REPORTS, INDEX_MANAGEMENT)) - val iarId = createConfig(features = setOf(INDEX_MANAGEMENT, ALERTING, REPORTS)) + val iId = createConfig(features = setOf(FEATURE_INDEX_MANAGEMENT)) + val aId = createConfig(features = setOf(FEATURE_ALERTING)) + val rId = createConfig(features = setOf(FEATURE_REPORTS)) + val iaId = createConfig(features = setOf(FEATURE_INDEX_MANAGEMENT, FEATURE_ALERTING)) + val raId = createConfig(features = setOf(FEATURE_REPORTS, FEATURE_ALERTING)) + val riId = createConfig(features = setOf(FEATURE_REPORTS, FEATURE_INDEX_MANAGEMENT)) + val iarId = createConfig(features = setOf(FEATURE_INDEX_MANAGEMENT, FEATURE_ALERTING, FEATURE_REPORTS)) Thread.sleep(1000) val reportIds = setOf(rId, raId, riId, iarId) @@ -817,4 +819,65 @@ class QueryNotificationConfigIT : PluginRestTestCase() { verifyMultiConfigIdEquals(domainIds, getDomainResponse, domainIds.size) Thread.sleep(100) } + + fun `test Get single absent config should fail and then create a config using absent id should pass`() { + val absentId = "absent_id" + Thread.sleep(1000) + // Get notification config with absent id + executeRequest( + RestRequest.Method.GET.name, + "$PLUGIN_BASE_URI/configs/$absentId", + "", + RestStatus.NOT_FOUND.status + ) + + Thread.sleep(1000) + + // Create sample config request reference + val sampleChime = Chime("https://domain.com/sample_chime_url#1234567890") + val referenceObject = NotificationConfig( + "this is a sample config name", + "this is a sample config description", + ConfigType.CHIME, + setOf(FEATURE_ALERTING, FEATURE_REPORTS), + isEnabled = true, + configData = sampleChime + ) + + // Create chime notification config + val createRequestJsonString = """ + { + "config_id":"$absentId", + "config":{ + "name":"${referenceObject.name}", + "description":"${referenceObject.description}", + "config_type":"chime", + "feature_list":[ + "${referenceObject.features.elementAt(0)}", + "${referenceObject.features.elementAt(1)}" + ], + "is_enabled":${referenceObject.isEnabled}, + "chime":{"url":"${(referenceObject.configData as Chime).url}"} + } + } + """.trimIndent() + val createResponse = executeRequest( + RestRequest.Method.POST.name, + "$PLUGIN_BASE_URI/configs", + createRequestJsonString, + RestStatus.OK.status + ) + Assert.assertEquals(absentId, createResponse.get("config_id").asString) + Thread.sleep(1000) + + // Get chime notification config + + val getConfigResponse = executeRequest( + RestRequest.Method.GET.name, + "$PLUGIN_BASE_URI/configs/$absentId", + "", + RestStatus.OK.status + ) + verifySingleConfigEquals(absentId, referenceObject, getConfigResponse) + } } diff --git a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/config/SlackNotificationConfigCrudIT.kt b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/config/SlackNotificationConfigCrudIT.kt index 32822dea..56117cd6 100644 --- a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/config/SlackNotificationConfigCrudIT.kt +++ b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/config/SlackNotificationConfigCrudIT.kt @@ -28,8 +28,9 @@ package org.opensearch.integtest.config import org.junit.Assert +import org.opensearch.commons.notifications.NotificationConstants.FEATURE_INDEX_MANAGEMENT +import org.opensearch.commons.notifications.NotificationConstants.FEATURE_REPORTS import org.opensearch.commons.notifications.model.ConfigType -import org.opensearch.commons.notifications.model.Feature import org.opensearch.commons.notifications.model.NotificationConfig import org.opensearch.commons.notifications.model.Slack import org.opensearch.integtest.PluginRestTestCase @@ -37,7 +38,6 @@ import org.opensearch.notifications.NotificationPlugin.Companion.PLUGIN_BASE_URI import org.opensearch.notifications.verifySingleConfigEquals import org.opensearch.rest.RestRequest import org.opensearch.rest.RestStatus -import java.util.EnumSet class SlackNotificationConfigCrudIT : PluginRestTestCase() { @@ -48,7 +48,7 @@ class SlackNotificationConfigCrudIT : PluginRestTestCase() { "this is a sample config name", "this is a sample config description", ConfigType.SLACK, - EnumSet.of(Feature.INDEX_MANAGEMENT, Feature.REPORTS), + setOf(FEATURE_INDEX_MANAGEMENT, FEATURE_REPORTS), isEnabled = true, configData = sampleSlack ) @@ -79,7 +79,7 @@ class SlackNotificationConfigCrudIT : PluginRestTestCase() { Assert.assertNotNull(configId) Thread.sleep(1000) - // Get slack notification config + // Get Slack notification config val getConfigResponse = executeRequest( RestRequest.Method.GET.name, @@ -107,7 +107,7 @@ class SlackNotificationConfigCrudIT : PluginRestTestCase() { "this is a updated config name", "this is a updated config description", ConfigType.SLACK, - EnumSet.of(Feature.INDEX_MANAGEMENT, Feature.REPORTS), + setOf(FEATURE_INDEX_MANAGEMENT, FEATURE_REPORTS), isEnabled = true, configData = updatedSlack ) @@ -137,7 +137,7 @@ class SlackNotificationConfigCrudIT : PluginRestTestCase() { Assert.assertEquals(configId, updateResponse.get("config_id").asString) Thread.sleep(1000) - // Get updated slack notification config + // Get updated Slack notification config val getUpdatedConfigResponse = executeRequest( RestRequest.Method.GET.name, @@ -176,7 +176,7 @@ class SlackNotificationConfigCrudIT : PluginRestTestCase() { "this is a sample config name", "this is a sample config description", ConfigType.SLACK, - EnumSet.of(Feature.INDEX_MANAGEMENT, Feature.REPORTS), + setOf(FEATURE_INDEX_MANAGEMENT, FEATURE_REPORTS), isEnabled = true, configData = sampleSlack ) diff --git a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/config/WebhookNotificationConfigCrudIT.kt b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/config/WebhookNotificationConfigCrudIT.kt index 5f8c022d..b7ad33e3 100644 --- a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/config/WebhookNotificationConfigCrudIT.kt +++ b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/config/WebhookNotificationConfigCrudIT.kt @@ -28,8 +28,10 @@ package org.opensearch.integtest.config import org.junit.Assert +import org.opensearch.commons.notifications.NotificationConstants.FEATURE_ALERTING +import org.opensearch.commons.notifications.NotificationConstants.FEATURE_INDEX_MANAGEMENT +import org.opensearch.commons.notifications.NotificationConstants.FEATURE_REPORTS import org.opensearch.commons.notifications.model.ConfigType -import org.opensearch.commons.notifications.model.Feature import org.opensearch.commons.notifications.model.NotificationConfig import org.opensearch.commons.notifications.model.Webhook import org.opensearch.integtest.PluginRestTestCase @@ -37,7 +39,6 @@ import org.opensearch.notifications.NotificationPlugin.Companion.PLUGIN_BASE_URI import org.opensearch.notifications.verifySingleConfigEquals import org.opensearch.rest.RestRequest import org.opensearch.rest.RestStatus -import java.util.EnumSet class WebhookNotificationConfigCrudIT : PluginRestTestCase() { @@ -51,7 +52,7 @@ class WebhookNotificationConfigCrudIT : PluginRestTestCase() { "this is a sample config name", "this is a sample config description", ConfigType.WEBHOOK, - EnumSet.of(Feature.INDEX_MANAGEMENT, Feature.REPORTS, Feature.ALERTING), + setOf(FEATURE_INDEX_MANAGEMENT, FEATURE_REPORTS, FEATURE_ALERTING), isEnabled = true, configData = sampleWebhook ) @@ -116,7 +117,7 @@ class WebhookNotificationConfigCrudIT : PluginRestTestCase() { "this is a updated config name", "this is a updated config description", ConfigType.WEBHOOK, - EnumSet.of(Feature.INDEX_MANAGEMENT, Feature.REPORTS), + setOf(FEATURE_INDEX_MANAGEMENT, FEATURE_REPORTS), isEnabled = true, configData = updatedWebhook ) @@ -185,7 +186,7 @@ class WebhookNotificationConfigCrudIT : PluginRestTestCase() { "this is a sample config name", "this is a sample config description", ConfigType.WEBHOOK, - EnumSet.of(Feature.INDEX_MANAGEMENT, Feature.REPORTS, Feature.ALERTING), + setOf(FEATURE_INDEX_MANAGEMENT, FEATURE_REPORTS, FEATURE_ALERTING), isEnabled = true, configData = sampleWebhook ) diff --git a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/features/GetNotificationFeatureChannelListIT.kt b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/features/GetNotificationFeatureChannelListIT.kt index 78cebeac..b800a602 100644 --- a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/features/GetNotificationFeatureChannelListIT.kt +++ b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/features/GetNotificationFeatureChannelListIT.kt @@ -13,8 +13,10 @@ package org.opensearch.integtest.features import com.google.gson.JsonObject import org.junit.Assert +import org.opensearch.commons.notifications.NotificationConstants.FEATURE_ALERTING +import org.opensearch.commons.notifications.NotificationConstants.FEATURE_INDEX_MANAGEMENT +import org.opensearch.commons.notifications.NotificationConstants.FEATURE_REPORTS import org.opensearch.commons.notifications.model.ConfigType -import org.opensearch.commons.notifications.model.Feature import org.opensearch.integtest.PluginRestTestCase import org.opensearch.notifications.NotificationPlugin.Companion.PLUGIN_BASE_URI import org.opensearch.rest.RestRequest @@ -28,7 +30,7 @@ class GetNotificationFeatureChannelListIT : PluginRestTestCase() { nameSubstring: String, configType: ConfigType, isEnabled: Boolean, - features: Set, + features: Set, smtpAccountId: String = "", emailGroupId: Set = setOf() ): String { @@ -91,7 +93,7 @@ class GetNotificationFeatureChannelListIT : PluginRestTestCase() { nameSubstring: String = "", configType: ConfigType = ConfigType.SLACK, isEnabled: Boolean = true, - features: Set = setOf(Feature.ALERTING, Feature.INDEX_MANAGEMENT, Feature.REPORTS), + features: Set = setOf(FEATURE_ALERTING, FEATURE_INDEX_MANAGEMENT, FEATURE_REPORTS), smtpAccountId: String = "", emailGroupId: Set = setOf() ): String { @@ -156,15 +158,6 @@ class GetNotificationFeatureChannelListIT : PluginRestTestCase() { ) } - fun `test Get feature channel list should error for invalid feature`() { - executeRequest( - RestRequest.Method.GET.name, - "$PLUGIN_BASE_URI/feature/channels/new_feature", - "", - RestStatus.BAD_REQUEST.status - ) - } - fun `test getFeatureChannelList should return only channels`() { val slackId = createConfig(configType = ConfigType.SLACK) val chimeId = createConfig(configType = ConfigType.CHIME) @@ -191,10 +184,10 @@ class GetNotificationFeatureChannelListIT : PluginRestTestCase() { } fun `test getFeatureChannelList should return only channels corresponding to feature`() { - val alertingOnlyIds: Set = (1..5).map { createConfig(features = setOf(Feature.ALERTING)) }.toSet() - val reportsOnlyIds: Set = (1..5).map { createConfig(features = setOf(Feature.REPORTS)) }.toSet() + val alertingOnlyIds: Set = (1..5).map { createConfig(features = setOf(FEATURE_ALERTING)) }.toSet() + val reportsOnlyIds: Set = (1..5).map { createConfig(features = setOf(FEATURE_REPORTS)) }.toSet() val ismAndAlertingIds: Set = (1..5).map { - createConfig(features = setOf(Feature.ALERTING, Feature.INDEX_MANAGEMENT)) + createConfig(features = setOf(FEATURE_ALERTING, FEATURE_INDEX_MANAGEMENT)) }.toSet() Thread.sleep(1000) val alertingIds = alertingOnlyIds.union(ismAndAlertingIds) diff --git a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/features/GetPluginFeaturesIT.kt b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/features/GetPluginFeaturesIT.kt index 958edeb0..a0946bd9 100644 --- a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/features/GetPluginFeaturesIT.kt +++ b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/features/GetPluginFeaturesIT.kt @@ -42,7 +42,7 @@ class GetPluginFeaturesIT : PluginRestTestCase() { val configTypes = getResponse.get("config_type_list").asJsonArray.map { it.asString } if (configTypes.contains(ConfigType.EMAIL.tag)) { Assert.assertTrue(configTypes.contains(ConfigType.EMAIL_GROUP.tag)) - Assert.assertTrue(configTypes.contains(ConfigType.SMTP_ACCOUNT.tag)) + Assert.assertTrue(configTypes.contains(ConfigType.SMTP_ACCOUNT.tag) || configTypes.contains(ConfigType.SES_ACCOUNT.tag)) } } } diff --git a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/send/SendTestMessageRestHandlerIT.kt b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/send/SendTestMessageRestHandlerIT.kt index 22dd3f20..f7fd71e8 100644 --- a/notifications/notifications/src/test/kotlin/org/opensearch/integtest/send/SendTestMessageRestHandlerIT.kt +++ b/notifications/notifications/src/test/kotlin/org/opensearch/integtest/send/SendTestMessageRestHandlerIT.kt @@ -28,6 +28,8 @@ package org.opensearch.integtest.send import org.junit.Assert +import org.opensearch.commons.notifications.model.MethodType +import org.opensearch.commons.notifications.model.SmtpAccount import org.opensearch.integtest.PluginRestTestCase import org.opensearch.notifications.NotificationPlugin.Companion.PLUGIN_BASE_URI import org.opensearch.rest.RestRequest @@ -143,4 +145,159 @@ internal class SendTestMessageRestHandlerIT : PluginRestTestCase() { Assert.assertNotNull(getResponseItem.get("event").asJsonObject) Thread.sleep(100) } + + @Suppress("EmptyFunctionBlock") + fun `test send custom webhook message`() { + // Create webhook notification config + val createRequestJsonString = """ + { + "config":{ + "name":"this is a sample config name", + "description":"this is a sample config description", + "config_type":"webhook", + "feature_list":[ + "index_management", + "reports", + "alerting" + ], + "is_enabled":true, + "webhook":{ + "url":"https://xxx.com/my-webhook@dev", + "header_params": { + "Content-type": "text/plain" + } + } + } + } + """.trimIndent() + val createResponse = executeRequest( + RestRequest.Method.POST.name, + "$PLUGIN_BASE_URI/configs", + createRequestJsonString, + RestStatus.OK.status + ) + val configId = createResponse.get("config_id").asString + Assert.assertNotNull(configId) + Thread.sleep(1000) + + // send test message + val sendResponse = executeRequest( + RestRequest.Method.GET.name, + "$PLUGIN_BASE_URI/feature/test/$configId?feature=alerting", + "", + RestStatus.OK.status + ) + val eventId = sendResponse.get("event_id").asString + + val getEventResponse = executeRequest( + RestRequest.Method.GET.name, + "$PLUGIN_BASE_URI/events/$eventId", + "", + RestStatus.OK.status + ) + val items = getEventResponse.get("event_list").asJsonArray + Assert.assertEquals(1, items.size()) + val getResponseItem = items[0].asJsonObject + Assert.assertEquals(eventId, getResponseItem.get("event_id").asString) + Assert.assertEquals("", getResponseItem.get("tenant").asString) + Assert.assertNotNull(getResponseItem.get("event").asJsonObject) + Thread.sleep(100) + } + + @Suppress("EmptyFunctionBlock") + fun `test send test smtp email message`() { + val sampleSmtpAccount = SmtpAccount( + "localhost", + 25, + MethodType.NONE, + "szhongna@testemail.com" + ) + // Create smtp account notification config + val smtpAccountCreateRequestJsonString = """ + { + "config":{ + "name":"this is a sample smtp", + "description":"this is a sample smtp description", + "config_type":"smtp_account", + "feature_list":[ + "index_management", + "reports", + "alerting" + ], + "is_enabled":true, + "smtp_account":{ + "host":"${sampleSmtpAccount.host}", + "port":"${sampleSmtpAccount.port}", + "method":"${sampleSmtpAccount.method}", + "from_address":"${sampleSmtpAccount.fromAddress}" + } + } + } + """.trimIndent() + val createResponse = executeRequest( + RestRequest.Method.POST.name, + "$PLUGIN_BASE_URI/configs", + smtpAccountCreateRequestJsonString, + RestStatus.OK.status + ) + val smtpAccountConfigId = createResponse.get("config_id").asString + Assert.assertNotNull(smtpAccountConfigId) + Thread.sleep(1000) + + val emailCreateRequestJsonString = """ + { + "config":{ + "name":"email config name", + "description":"email description", + "config_type":"email", + "feature_list":[ + "index_management", + "reports", + "alerting" + ], + "is_enabled":true, + "email":{ + "email_account_id":"$smtpAccountConfigId", + "recipient_list":[ + "chloe@example.com" + ], + "email_group_id_list":[] + } + } + } + """.trimIndent() + + val emailCreateResponse = executeRequest( + RestRequest.Method.POST.name, + "$PLUGIN_BASE_URI/configs", + emailCreateRequestJsonString, + RestStatus.OK.status + ) + val emailConfigId = emailCreateResponse.get("config_id").asString + Assert.assertNotNull(emailConfigId) + Thread.sleep(1000) + + // send test message + val sendResponse = executeRequest( + RestRequest.Method.GET.name, + "$PLUGIN_BASE_URI/feature/test/$emailConfigId?feature=alerting", + "", + RestStatus.OK.status + ) + val eventId = sendResponse.get("event_id").asString + + val getEventResponse = executeRequest( + RestRequest.Method.GET.name, + "$PLUGIN_BASE_URI/events/$eventId", + "", + RestStatus.OK.status + ) + val items = getEventResponse.get("event_list").asJsonArray + Assert.assertEquals(1, items.size()) + val getResponseItem = items[0].asJsonObject + Assert.assertEquals(eventId, getResponseItem.get("event_id").asString) + Assert.assertEquals("", getResponseItem.get("tenant").asString) + Assert.assertNotNull(getResponseItem.get("event").asJsonObject) + Thread.sleep(100) + } } diff --git a/notifications/notifications/src/test/kotlin/org/opensearch/notifications/NotificationEventIndexTest.kt b/notifications/notifications/src/test/kotlin/org/opensearch/notifications/NotificationEventIndexTest.kt deleted file mode 100644 index 5f315be7..00000000 --- a/notifications/notifications/src/test/kotlin/org/opensearch/notifications/NotificationEventIndexTest.kt +++ /dev/null @@ -1,134 +0,0 @@ -package org.opensearch.notifications.index - -import org.junit.jupiter.api.Test -import org.mockito.Mock -import org.opensearch.action.get.GetRequest -import org.opensearch.client.Client -import org.opensearch.cluster.service.ClusterService -import org.opensearch.notifications.settings.PluginSettings -import org.junit.jupiter.api.BeforeEach -import com.nhaarman.mockitokotlin2.whenever -import com.nhaarman.mockitokotlin2.any -import com.nhaarman.mockitokotlin2.mock -import junit.framework.Assert.assertEquals -import org.mockito.Mockito -import org.mockito.Mockito.* -import org.mockito.MockitoAnnotations -import org.mockito.stubbing.OngoingStubbing -import org.opensearch.action.ActionFuture -import org.opensearch.action.admin.indices.create.CreateIndexResponse -import org.opensearch.action.get.GetResponse -import org.opensearch.action.support.master.AcknowledgedResponse -import org.opensearch.client.AdminClient -import org.opensearch.client.IndicesAdminClient -import org.opensearch.cluster.ClusterState -import org.opensearch.cluster.routing.RoutingTable -import org.opensearch.commons.notifications.model.* -import org.opensearch.notifications.model.DocInfo -import org.opensearch.notifications.model.DocMetadata -import org.opensearch.notifications.model.NotificationEventDoc -import org.opensearch.notifications.model.NotificationEventDocInfo -import java.time.Instant - - -internal class NotificationEventIndexTest{ - - - private lateinit var client: Client - - //@Mock - private val INDEX_NAME = ".opensearch-notifications-event" - - - private lateinit var clusterService: ClusterService - - @BeforeEach - fun setUp() { - client = mock(Client::class.java,"client") - clusterService = mock(ClusterService::class.java, "clusterservice") - NotificationEventIndex.initialize(client, clusterService) - } - - @Test - fun `index operation to get single event` () { - val id = "index-1" - val docInfo = DocInfo("index-1", 1, 1, 1) - //val eventDoc = mock(NotificationEventDoc::class.java) - val lastUpdatedTimeMs = Instant.ofEpochMilli(Instant.now().toEpochMilli()) - val createdTimeMs = lastUpdatedTimeMs.minusSeconds(1000) - val metadata = DocMetadata( - lastUpdatedTimeMs, - createdTimeMs, - "tenant", - listOf("User:user", "Role:sample_role", "BERole:sample_backend_role") - ) - val sampleEventSource = EventSource( - "title", - "reference_id", - Feature.ALERTING, - tags = listOf("tag1", "tag2"), - severity = SeverityType.INFO - ) - val status = EventStatus( - "config_id", - "name", - ConfigType.CHIME, - deliveryStatus = DeliveryStatus("200", "success") - ) - val sampleEvent = NotificationEvent(sampleEventSource, listOf(status)) - val eventDoc = NotificationEventDoc(metadata, sampleEvent) - val expectedEventDocInfo = NotificationEventDocInfo(docInfo, eventDoc) - - val getRequest = GetRequest(INDEX_NAME).id(id) - val mockActionFuture:ActionFuture = mock(ActionFuture::class.java) as ActionFuture - //whenever(NotificationEventIndex.client.get(any())).thenReturn(mockActionFuture) - - whenever(client.get(getRequest)).thenReturn(mockActionFuture) - val clusterState = mock(ClusterState::class.java) - - whenever(clusterService.state()).thenReturn(clusterState) - val mockRoutingTable = mock(RoutingTable::class.java) - val mockHasIndex = mockRoutingTable.hasIndex(INDEX_NAME) - - // print("has index value is $mockHasIndex") - - whenever(clusterState.routingTable).thenReturn(mockRoutingTable) - whenever(mockRoutingTable.hasIndex(INDEX_NAME)).thenReturn(mockHasIndex) - - //val actionFuture = NotificationEventIndex.client.admin().indices().create(request) - - val admin = mock(AdminClient::class.java) - val indices = mock(IndicesAdminClient::class.java) - val mockCreateClient:ActionFuture = mock(ActionFuture::class.java) as ActionFuture - - whenever(client.admin()).thenReturn(admin) - whenever(admin.indices()).thenReturn(indices) - whenever(indices.create(any())).thenReturn(mockCreateClient) - - //val time = PluginSettings.operationTimeoutMs - val mockActionGet = mockCreateClient.actionGet(PluginSettings.operationTimeoutMs) - whenever(mockCreateClient.actionGet(anyLong())).thenReturn(mockActionGet) - println("mockActionGet: $mockActionGet") - println("mockCreateClient: $mockCreateClient") - //println("plugin timout: $time") - - //val mockResponse = mock(AcknowledgedResponse::class.java) - //whenever(response.isAcknowledged).thenReturn(mockResponse) - - val actualEventDocInfo = NotificationEventIndex.getNotificationEvent(id) - verify(clusterService.state(), atLeast(1)) - verify(mockCreateClient.actionGet(), atLeast(1)) - //verifyNoMoreInteractions() - - //val future = mock(client.admin().indices().create(request)) - /* - val mockFuture = mock(ActionFuture::class.java) - whenever(client.get(any())).thenReturn(mockFuture) - */ - - assertEquals(expectedEventDocInfo, actualEventDocInfo) - - } - -} - diff --git a/notifications/notifications/src/test/kotlin/org/opensearch/notifications/ObjectEqualsHelpers.kt b/notifications/notifications/src/test/kotlin/org/opensearch/notifications/ObjectEqualsHelpers.kt index 8c7a73e9..5735a186 100644 --- a/notifications/notifications/src/test/kotlin/org/opensearch/notifications/ObjectEqualsHelpers.kt +++ b/notifications/notifications/src/test/kotlin/org/opensearch/notifications/ObjectEqualsHelpers.kt @@ -33,11 +33,12 @@ import org.opensearch.commons.notifications.model.Chime import org.opensearch.commons.notifications.model.ConfigType import org.opensearch.commons.notifications.model.Email import org.opensearch.commons.notifications.model.EmailGroup -import org.opensearch.commons.notifications.model.Feature import org.opensearch.commons.notifications.model.MethodType import org.opensearch.commons.notifications.model.NotificationConfig +import org.opensearch.commons.notifications.model.SesAccount import org.opensearch.commons.notifications.model.Slack import org.opensearch.commons.notifications.model.SmtpAccount +import org.opensearch.commons.notifications.model.Sns import org.opensearch.commons.notifications.model.Webhook fun verifyEquals(slack: Slack, jsonObject: JsonObject) { @@ -75,6 +76,17 @@ fun verifyEquals(smtpAccount: SmtpAccount, jsonObject: JsonObject) { Assert.assertEquals(smtpAccount.fromAddress, jsonObject.get("from_address").asString) } +fun verifyEquals(sesAccount: SesAccount, jsonObject: JsonObject) { + Assert.assertEquals(sesAccount.awsRegion, jsonObject.get("region").asString) + Assert.assertEquals(sesAccount.roleArn, jsonObject.get("role_arn").asString) + Assert.assertEquals(sesAccount.fromAddress, jsonObject.get("from_address").asString) +} + +fun verifyEquals(sns: Sns, jsonObject: JsonObject) { + Assert.assertEquals(sns.topicArn, jsonObject.get("topic_arn").asString) + Assert.assertEquals(sns.roleArn, jsonObject.get("role_arn").asString) +} + fun verifyEquals(config: NotificationConfig, jsonObject: JsonObject) { Assert.assertEquals(config.name, jsonObject.get("name").asString) Assert.assertEquals(config.description, jsonObject.get("description").asString) @@ -82,7 +94,7 @@ fun verifyEquals(config: NotificationConfig, jsonObject: JsonObject) { Assert.assertEquals(config.isEnabled, jsonObject.get("is_enabled").asBoolean) val features = jsonObject.get("feature_list").asJsonArray Assert.assertEquals(config.features.size, features.size()) - features.forEach { config.features.contains(Feature.fromTagOrDefault(it.asString)) } + features.forEach { config.features.contains(it.asString) } when (config.configType) { ConfigType.SLACK -> verifyEquals((config.configData as Slack), jsonObject.get("slack").asJsonObject) ConfigType.CHIME -> verifyEquals((config.configData as Chime), jsonObject.get("chime").asJsonObject) @@ -92,10 +104,15 @@ fun verifyEquals(config: NotificationConfig, jsonObject: JsonObject) { (config.configData as SmtpAccount), jsonObject.get("smtp_account").asJsonObject ) + ConfigType.SES_ACCOUNT -> verifyEquals( + (config.configData as SesAccount), + jsonObject.get("ses_account").asJsonObject + ) ConfigType.EMAIL_GROUP -> verifyEquals( (config.configData as EmailGroup), jsonObject.get("email_group").asJsonObject ) + ConfigType.SNS -> verifyEquals((config.configData as Sns), jsonObject.get("sns").asJsonObject) else -> Assert.fail("configType:${config.configType} not handled in test") } } diff --git a/notifications/notifications/src/test/kotlin/org/opensearch/notifications/action/PluginActionTests.kt b/notifications/notifications/src/test/kotlin/org/opensearch/notifications/action/PluginActionTests.kt index 1407d07d..b875526c 100644 --- a/notifications/notifications/src/test/kotlin/org/opensearch/notifications/action/PluginActionTests.kt +++ b/notifications/notifications/src/test/kotlin/org/opensearch/notifications/action/PluginActionTests.kt @@ -23,6 +23,7 @@ import org.opensearch.action.ActionListener import org.opensearch.action.support.ActionFilters import org.opensearch.client.Client import org.opensearch.common.xcontent.NamedXContentRegistry +import org.opensearch.commons.destination.response.LegacyDestinationResponse import org.opensearch.commons.notifications.action.BaseResponse import org.opensearch.commons.notifications.action.CreateNotificationConfigRequest import org.opensearch.commons.notifications.action.CreateNotificationConfigResponse @@ -36,6 +37,8 @@ import org.opensearch.commons.notifications.action.GetNotificationEventRequest import org.opensearch.commons.notifications.action.GetNotificationEventResponse import org.opensearch.commons.notifications.action.GetPluginFeaturesRequest import org.opensearch.commons.notifications.action.GetPluginFeaturesResponse +import org.opensearch.commons.notifications.action.LegacyPublishNotificationRequest +import org.opensearch.commons.notifications.action.LegacyPublishNotificationResponse import org.opensearch.commons.notifications.action.SendNotificationRequest import org.opensearch.commons.notifications.action.SendNotificationResponse import org.opensearch.commons.notifications.action.UpdateNotificationConfigRequest @@ -201,6 +204,23 @@ internal class PluginActionTests { sendNotificationAction.execute(task, request, AssertionListener(response)) } + @Test + fun `Publish notification action should call back action listener`() { + val request = mock(LegacyPublishNotificationRequest::class.java) + val response = LegacyPublishNotificationResponse( + LegacyDestinationResponse.Builder().withStatusCode(200).withResponseContent("Hello world").build() + ) + + // Mock singleton's method by mockk framework + mockkObject(SendMessageActionHelper) + every { SendMessageActionHelper.executeLegacyRequest(request) } returns response + + val publishNotificationAction = PublishNotificationAction( + transportService, client, actionFilters, xContentRegistry + ) + publishNotificationAction.execute(task, request, AssertionListener(response)) + } + /** * This listener class is to assert on response rather than verify it called. * The reason why this is required is because it is harder to do the latter diff --git a/notifications/notifications/src/test/kotlin/org/opensearch/notifications/model/NotificationConfigDocTests.kt b/notifications/notifications/src/test/kotlin/org/opensearch/notifications/model/NotificationConfigDocTests.kt index b224c4c0..4e50e97d 100644 --- a/notifications/notifications/src/test/kotlin/org/opensearch/notifications/model/NotificationConfigDocTests.kt +++ b/notifications/notifications/src/test/kotlin/org/opensearch/notifications/model/NotificationConfigDocTests.kt @@ -29,14 +29,13 @@ package org.opensearch.notifications.model import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test +import org.opensearch.commons.notifications.NotificationConstants.FEATURE_REPORTS import org.opensearch.commons.notifications.model.ConfigType -import org.opensearch.commons.notifications.model.Feature import org.opensearch.commons.notifications.model.NotificationConfig import org.opensearch.commons.notifications.model.Slack import org.opensearch.notifications.createObjectFromJsonString import org.opensearch.notifications.getJsonString import java.time.Instant -import java.util.EnumSet internal class NotificationConfigDocTests { @@ -55,7 +54,7 @@ internal class NotificationConfigDocTests { "name", "description", ConfigType.SLACK, - EnumSet.of(Feature.REPORTS), + setOf(FEATURE_REPORTS), configData = sampleSlack ) val configDoc = NotificationConfigDoc(metadata, config) @@ -79,7 +78,7 @@ internal class NotificationConfigDocTests { "name", "description", ConfigType.SLACK, - EnumSet.of(Feature.REPORTS), + setOf(FEATURE_REPORTS), configData = sampleSlack ) val configDoc = NotificationConfigDoc(metadata, config) diff --git a/notifications/notifications/src/test/kotlin/org/opensearch/notifications/model/NotificationEventDocTests.kt b/notifications/notifications/src/test/kotlin/org/opensearch/notifications/model/NotificationEventDocTests.kt index 788fea99..e7a82082 100644 --- a/notifications/notifications/src/test/kotlin/org/opensearch/notifications/model/NotificationEventDocTests.kt +++ b/notifications/notifications/src/test/kotlin/org/opensearch/notifications/model/NotificationEventDocTests.kt @@ -29,11 +29,11 @@ package org.opensearch.notifications.model import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test +import org.opensearch.commons.notifications.NotificationConstants.FEATURE_ALERTING import org.opensearch.commons.notifications.model.ConfigType import org.opensearch.commons.notifications.model.DeliveryStatus import org.opensearch.commons.notifications.model.EventSource import org.opensearch.commons.notifications.model.EventStatus -import org.opensearch.commons.notifications.model.Feature import org.opensearch.commons.notifications.model.NotificationEvent import org.opensearch.commons.notifications.model.SeverityType import org.opensearch.notifications.createObjectFromJsonString @@ -55,7 +55,7 @@ internal class NotificationEventDocTests { val sampleEventSource = EventSource( "title", "reference_id", - Feature.ALERTING, + FEATURE_ALERTING, tags = listOf("tag1", "tag2"), severity = SeverityType.INFO ) @@ -85,7 +85,7 @@ internal class NotificationEventDocTests { val eventSource = EventSource( "title", "reference_id", - Feature.ALERTING, + FEATURE_ALERTING, tags = listOf("tag1", "tag2"), severity = SeverityType.INFO ) diff --git a/notifications/notifications/src/test/kotlin/org/opensearch/notifications/resthandler/SendMessageRestHandlerTests.kt b/notifications/notifications/src/test/kotlin/org/opensearch/notifications/resthandler/SendMessageRestHandlerTests.kt deleted file mode 100644 index af94d50e..00000000 --- a/notifications/notifications/src/test/kotlin/org/opensearch/notifications/resthandler/SendMessageRestHandlerTests.kt +++ /dev/null @@ -1,54 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - * - * Modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -/* - * Copyright 2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"). - * You may not use this file except in compliance with the License. - * A copy of the License is located at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * or in the "license" file accompanying this file. This file is distributed - * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either - * express or implied. See the License for the specific language governing - * permissions and limitations under the License. - * - */ - -package org.opensearch.notifications.resthandler - -import org.junit.jupiter.api.Test -import org.opensearch.rest.RestHandler -import org.opensearch.rest.RestRequest.Method.POST -import org.opensearch.test.OpenSearchTestCase - -internal class SendMessageRestHandlerTests : OpenSearchTestCase() { - - @Test - fun `SendRestHandler name should return send`() { - val restHandler = SendMessageRestHandler() - assertEquals("send_message", restHandler.name) - } - - @Test - fun `SendRestHandler routes should return send url`() { - val restHandler = SendMessageRestHandler() - val routes = restHandler.routes() - val actualRouteSize = routes.size - val actualRoute = routes[0] - val expectedRoute = RestHandler.Route(POST, "/_plugins/_notifications/send") - assertEquals(1, actualRouteSize) - assertEquals(expectedRoute.method, actualRoute.method) - assertEquals(expectedRoute.path, actualRoute.path) - } -} diff --git a/notifications/spi/build.gradle b/notifications/spi/build.gradle index 68226dcf..bcff3811 100644 --- a/notifications/spi/build.gradle +++ b/notifications/spi/build.gradle @@ -89,12 +89,18 @@ configurations.all { resolutionStrategy { force "org.jetbrains.kotlin:kotlin-stdlib:${kotlin_version}" force "org.jetbrains.kotlin:kotlin-stdlib-common:${kotlin_version}" - force "commons-logging:commons-logging:1.2" // resolve for awssdk:ses - force "commons-codec:commons-codec:1.13" // resolve for awssdk:ses - force "io.netty:netty-codec-http:4.1.63.Final" // resolve for awssdk:ses - force "io.netty:netty-handler:4.1.63.Final" // resolve for awssdk:ses - force "org.apache.httpcomponents:httpclient:4.5.10" // resolve for awssdk:ses - force "org.apache.httpcomponents:httpcore:4.4.13" // resolve for awssdk:ses + force "commons-logging:commons-logging:1.2" // resolve for amazonaws + force "commons-codec:commons-codec:1.13" // resolve for amazonaws + force "org.apache.httpcomponents:httpclient:4.5.10" // resolve for amazonaws + force "org.apache.httpcomponents:httpcore:4.4.13" // resolve for amazonaws + force "joda-time:joda-time:2.8.1" // Resolve for amazonaws + force "com.fasterxml.jackson.core:jackson-core:2.12.3" // resolve for amazonaws + force "com.fasterxml.jackson.core:jackson-annotations:2.12.3" // resolve for amazonaws + force "com.fasterxml.jackson.core:jackson-databind:2.12.3" // resolve for amazonaws + force "com.fasterxml.jackson.dataformat:jackson-dataformat-cbor:2.12.3" // resolve for amazonaws + force "com.fasterxml.jackson.dataformat:jackson-dataformat-smile:2.12.3" // resolve for amazonaws + force "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.12.3" // resolve for amazonaws + force "junit:junit:4.12" // resolve for amazonaws } } @@ -104,12 +110,11 @@ dependencies { compileOnly "org.jetbrains.kotlin:kotlin-stdlib-common:${kotlin_version}" compile "org.apache.httpcomponents:httpcore:4.4.5" compile "org.apache.httpcomponents:httpclient:4.5.10" - //TODO: Add it back and remove from main project(to avoid jarhell) when implementing Email functionality -// compile ("software.amazon.awssdk:ses:2.14.16") { -// exclude module: 'annotations' // conflict with org.jetbrains:annotations, integTestRunner fails with error "codebase property already set" -// } + compile "com.amazonaws:aws-java-sdk-sns:${aws_version}" + compile "com.amazonaws:aws-java-sdk-sts:${aws_version}" + compile "com.amazonaws:aws-java-sdk-ses:${aws_version}" compile "com.sun.mail:javax.mail:1.6.2" - + implementation "com.github.seancfoley:ipaddress:5.3.3" testImplementation( 'org.assertj:assertj-core:3.16.1', 'org.junit.jupiter:junit-jupiter-api:5.6.2', @@ -143,7 +148,8 @@ configurations { shadowJar { // fix jarhell by relocating packages - relocate 'com.fasterxml.jackson.core', 'org.opensearch.notifications.repackage.com.fasterxml.jackson.core' + relocate 'org.joda.time', 'org.opensearch.notifications.repackage.org.joda.time' + relocate 'com.fasterxml.jackson', 'org.opensearch.notifications.repackage.com.fasterxml.jackson' relocate 'org.apache.http', 'org.opensearch.notifications.repackage.org.apache.http' relocate 'org.apache.commons.logging', 'org.opensearch.notifications.repackage.org.apache.commons.logging' relocate 'org.apache.commons.codec', 'org.opensearch.notifications.repackage.org.apache.commons.codec' diff --git a/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/NotificationSpi.kt b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/NotificationSpi.kt index e3f9769c..9e57c863 100644 --- a/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/NotificationSpi.kt +++ b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/NotificationSpi.kt @@ -27,11 +27,11 @@ package org.opensearch.notifications.spi -import org.opensearch.notifications.spi.factory.DestinationFactoryProvider import org.opensearch.notifications.spi.model.DestinationMessageResponse import org.opensearch.notifications.spi.model.MessageContent import org.opensearch.notifications.spi.model.destination.BaseDestination import org.opensearch.notifications.spi.setting.PluginSettings +import org.opensearch.notifications.spi.transport.DestinationTransportProvider import java.security.AccessController import java.security.PrivilegedAction @@ -43,14 +43,20 @@ object NotificationSpi { /** * Send the notification message to the corresponding destination * + * @param destination destination configuration for sending message * @param message metadata for message + * @param referenceId referenceId for message * @return ChannelMessageResponse */ - fun sendMessage(destination: BaseDestination, message: MessageContent): DestinationMessageResponse { + fun sendMessage( + destination: BaseDestination, + message: MessageContent, + referenceId: String + ): DestinationMessageResponse { return AccessController.doPrivileged( PrivilegedAction { - val destinationFactory = DestinationFactoryProvider.getFactory(destination.destinationType) - destinationFactory.sendMessage(destination, message) + val destinationFactory = DestinationTransportProvider.getTransport(destination.destinationType) + destinationFactory.sendMessage(destination, message, referenceId) } as PrivilegedAction? ) } diff --git a/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/NotificationSpiPlugin.kt b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/NotificationSpiPlugin.kt index d97f17db..54f695d4 100644 --- a/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/NotificationSpiPlugin.kt +++ b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/NotificationSpiPlugin.kt @@ -16,12 +16,15 @@ import org.opensearch.cluster.metadata.IndexNameExpressionResolver import org.opensearch.cluster.service.ClusterService import org.opensearch.common.io.stream.NamedWriteableRegistry import org.opensearch.common.settings.Setting +import org.opensearch.common.settings.Settings import org.opensearch.common.xcontent.NamedXContentRegistry import org.opensearch.env.Environment import org.opensearch.env.NodeEnvironment import org.opensearch.notifications.spi.setting.PluginSettings +import org.opensearch.notifications.spi.setting.PluginSettings.loadDestinationSettings import org.opensearch.notifications.spi.utils.logger import org.opensearch.plugins.Plugin +import org.opensearch.plugins.ReloadablePlugin import org.opensearch.repositories.RepositoriesService import org.opensearch.script.ScriptService import org.opensearch.threadpool.ThreadPool @@ -31,7 +34,7 @@ import java.util.function.Supplier /** * This is a dummy plugin for SPI to load configurations */ -internal class NotificationSpiPlugin : Plugin() { +internal class NotificationSpiPlugin : ReloadablePlugin, Plugin() { lateinit var clusterService: ClusterService // initialized in createComponents() internal companion object { @@ -69,4 +72,8 @@ internal class NotificationSpiPlugin : Plugin() { PluginSettings.addSettingsUpdateConsumer(clusterService) return listOf() } + + override fun reload(settings: Settings) { + PluginSettings.destinationSettings = loadDestinationSettings(settings) + } } diff --git a/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/client/DestinationClientPool.kt b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/client/DestinationClientPool.kt index a604cee1..dfad122b 100644 --- a/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/client/DestinationClientPool.kt +++ b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/client/DestinationClientPool.kt @@ -27,10 +27,15 @@ package org.opensearch.notifications.spi.client +import org.opensearch.notifications.spi.credentials.oss.SesClientFactoryImpl +import org.opensearch.notifications.spi.credentials.oss.SnsClientFactoryImpl + /** * This class provides Client to the relevant destinations */ internal object DestinationClientPool { val httpClient: DestinationHttpClient = DestinationHttpClient() - val emailClient: DestinationEmailClient = DestinationEmailClient() + val smtpClient: DestinationSmtpClient = DestinationSmtpClient() + val snsClient: DestinationSnsClient = DestinationSnsClient(SnsClientFactoryImpl) + val sesClient: DestinationSesClient = DestinationSesClient(SesClientFactoryImpl) } diff --git a/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/client/DestinationHttpClient.kt b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/client/DestinationHttpClient.kt index 7ebeb85b..3b7c1e92 100644 --- a/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/client/DestinationHttpClient.kt +++ b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/client/DestinationHttpClient.kt @@ -45,6 +45,7 @@ import org.apache.http.util.EntityUtils import org.opensearch.common.xcontent.XContentFactory import org.opensearch.common.xcontent.XContentType import org.opensearch.notifications.spi.model.MessageContent +import org.opensearch.notifications.spi.model.destination.ChimeDestination import org.opensearch.notifications.spi.model.destination.CustomWebhookDestination import org.opensearch.notifications.spi.model.destination.SlackDestination import org.opensearch.notifications.spi.model.destination.WebhookDestination @@ -109,12 +110,14 @@ class DestinationHttpClient { } @Throws(Exception::class) - fun execute(destination: WebhookDestination, message: MessageContent): String { + fun execute(destination: WebhookDestination, message: MessageContent, referenceId: String): String { var response: CloseableHttpResponse? = null return try { response = getHttpResponse(destination, message) validateResponseStatus(response) - getResponseString(response) + val responseString = getResponseString(response) + log.debug("Http response for id $referenceId: $responseString") + responseString } finally { if (response != null) { EntityUtils.consumeQuietly(response.entity) @@ -127,7 +130,7 @@ class DestinationHttpClient { var httpRequest: HttpRequestBase = HttpPost(destination.url) if (destination is CustomWebhookDestination) { - httpRequest = constructHttpRequest(destination.method) + httpRequest = constructHttpRequest(destination.method, destination.url) if (destination.headerParams.isEmpty()) { // set default header httpRequest.setHeader("Content-type", "application/json") @@ -142,11 +145,11 @@ class DestinationHttpClient { return httpClient.execute(httpRequest) } - private fun constructHttpRequest(method: String): HttpRequestBase { + private fun constructHttpRequest(method: String, url: String): HttpRequestBase { return when (method) { - HttpPost.METHOD_NAME -> HttpPost() - HttpPut.METHOD_NAME -> HttpPut() - HttpPatch.METHOD_NAME -> HttpPatch() + HttpPost.METHOD_NAME -> HttpPost(url) + HttpPut.METHOD_NAME -> HttpPut(url) + HttpPatch.METHOD_NAME -> HttpPatch(url) else -> throw IllegalArgumentException( "Invalid or empty method supplied. Only POST, PUT and PATCH are allowed" ) @@ -156,9 +159,7 @@ class DestinationHttpClient { @Throws(IOException::class) fun getResponseString(response: CloseableHttpResponse): String { val entity: HttpEntity = response.entity ?: return "{}" - val responseString: String = EntityUtils.toString(entity) - log.debug("Http response: $responseString") - return responseString + return EntityUtils.toString(entity) } @Throws(IOException::class) @@ -171,11 +172,20 @@ class DestinationHttpClient { fun buildRequestBody(destination: WebhookDestination, message: MessageContent): String { val builder = XContentFactory.contentBuilder(XContentType.JSON) - var keyName = "Content" - // Slack webhook request body has required "text" as key name https://api.slack.com/messaging/webhooks - if (destination is SlackDestination) keyName = "text" + val keyName = when (destination) { + // Slack webhook request body has required "text" as key name https://api.slack.com/messaging/webhooks + // Chime webhook request body has required "Content" as key name + // Customer webhook allows input as json or plain text, so we just return the message as it is + is SlackDestination -> "text" + is ChimeDestination -> "Content" + is CustomWebhookDestination -> return message.textDescription + else -> throw IllegalArgumentException( + "Invalid destination type is provided, Only Slack, Chime and CustomWebhook are allowed" + ) + } + builder.startObject() - .field(keyName, message.buildWebhookMessage()) + .field(keyName, message.buildMessageWithTitle()) .endObject() return builder.string() } diff --git a/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/client/DestinationSesClient.kt b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/client/DestinationSesClient.kt new file mode 100644 index 00000000..819bc233 --- /dev/null +++ b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/client/DestinationSesClient.kt @@ -0,0 +1,158 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.notifications.spi.client + +import com.amazonaws.AmazonServiceException +import com.amazonaws.SdkBaseException +import com.amazonaws.services.simpleemail.model.AccountSendingPausedException +import com.amazonaws.services.simpleemail.model.AmazonSimpleEmailServiceException +import com.amazonaws.services.simpleemail.model.ConfigurationSetDoesNotExistException +import com.amazonaws.services.simpleemail.model.ConfigurationSetSendingPausedException +import com.amazonaws.services.simpleemail.model.MailFromDomainNotVerifiedException +import com.amazonaws.services.simpleemail.model.MessageRejectedException +import com.amazonaws.services.simpleemail.model.RawMessage +import com.amazonaws.services.simpleemail.model.SendRawEmailRequest +import org.opensearch.notifications.spi.NotificationSpiPlugin.Companion.LOG_PREFIX +import org.opensearch.notifications.spi.credentials.SesClientFactory +import org.opensearch.notifications.spi.model.DestinationMessageResponse +import org.opensearch.notifications.spi.model.MessageContent +import org.opensearch.notifications.spi.model.destination.SesDestination +import org.opensearch.notifications.spi.setting.PluginSettings +import org.opensearch.notifications.spi.utils.SecurityAccess +import org.opensearch.notifications.spi.utils.logger +import org.opensearch.rest.RestStatus +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer +import java.util.Properties +import javax.mail.Session +import javax.mail.internet.MimeMessage + +/** + * This class handles the connections to the given Destination. + */ +class DestinationSesClient(private val sesClientFactory: SesClientFactory) { + + companion object { + private val log by logger(DestinationSesClient::class.java) + } + + /** + * {@inheritDoc} + */ + private fun prepareSession(): Session { + val prop = Properties() + prop["mail.transport.protocol"] = "smtp" + return Session.getInstance(prop) + } + + @Throws(Exception::class) + fun execute( + sesDestination: SesDestination, + message: MessageContent, + referenceId: String + ): DestinationMessageResponse { + if (EmailMessageValidator.isMessageSizeOverLimit(message)) { + return DestinationMessageResponse( + RestStatus.REQUEST_ENTITY_TOO_LARGE.status, + "Email size larger than ${PluginSettings.emailSizeLimit}" + ) + } + + // prepare session + val session = prepareSession() + // prepare mimeMessage + val mimeMessage = EmailMimeProvider.prepareMimeMessage( + session, + sesDestination.fromAddress, + sesDestination.recipient, + message + ) + // send Mime Message + return sendMimeMessage(referenceId, sesDestination.awsRegion, sesDestination.roleArn, mimeMessage) + } + + /** + * {@inheritDoc} + */ + private fun sendMimeMessage( + referenceId: String, + sesAwsRegion: String, + roleArn: String?, + mimeMessage: MimeMessage + ): DestinationMessageResponse { + return try { + log.debug("$LOG_PREFIX:Sending Email-SES:$referenceId") + val client = sesClientFactory.createSesClient(sesAwsRegion, roleArn) + val outputStream = ByteArrayOutputStream() + SecurityAccess.doPrivileged { mimeMessage.writeTo(outputStream) } + val emailSize = outputStream.size() + if (emailSize <= PluginSettings.emailSizeLimit) { + val rawMessage = RawMessage(ByteBuffer.wrap(outputStream.toByteArray())) + val rawEmailRequest = SendRawEmailRequest(rawMessage) + val response = SecurityAccess.doPrivileged { client.sendRawEmail(rawEmailRequest) } + log.info("$LOG_PREFIX:Email-SES:$referenceId status:$response") + DestinationMessageResponse(RestStatus.OK.status, "Success:${response.messageId}") + } else { + DestinationMessageResponse( + RestStatus.REQUEST_ENTITY_TOO_LARGE.status, + "Email size($emailSize) larger than ${PluginSettings.emailSizeLimit}" + ) + } + } catch (exception: MessageRejectedException) { + DestinationMessageResponse(RestStatus.SERVICE_UNAVAILABLE.status, getSesExceptionText(exception)) + } catch (exception: MailFromDomainNotVerifiedException) { + DestinationMessageResponse(RestStatus.FORBIDDEN.status, getSesExceptionText(exception)) + } catch (exception: ConfigurationSetDoesNotExistException) { + DestinationMessageResponse(RestStatus.NOT_IMPLEMENTED.status, getSesExceptionText(exception)) + } catch (exception: ConfigurationSetSendingPausedException) { + DestinationMessageResponse(RestStatus.SERVICE_UNAVAILABLE.status, getSesExceptionText(exception)) + } catch (exception: AccountSendingPausedException) { + DestinationMessageResponse(RestStatus.INSUFFICIENT_STORAGE.status, getSesExceptionText(exception)) + } catch (exception: AmazonSimpleEmailServiceException) { + DestinationMessageResponse(RestStatus.FAILED_DEPENDENCY.status, getSesExceptionText(exception)) + } catch (exception: AmazonServiceException) { + DestinationMessageResponse(RestStatus.FAILED_DEPENDENCY.status, getServiceExceptionText(exception)) + } catch (exception: SdkBaseException) { + DestinationMessageResponse(RestStatus.FAILED_DEPENDENCY.status, getSdkExceptionText(exception)) + } + } + + /** + * Create error string from Amazon SES Exceptions + * @param exception SES Exception + * @return generated error string + */ + private fun getSesExceptionText(exception: AmazonSimpleEmailServiceException): String { + log.info("$LOG_PREFIX:SesException $exception") + return "sendEmail Error, SES status:${exception.errorMessage}" + } + + /** + * Create error string from Amazon Service Exceptions + * @param exception Amazon Service Exception + * @return generated error string + */ + private fun getServiceExceptionText(exception: AmazonServiceException): String { + log.info("$LOG_PREFIX:SesException $exception") + return "sendEmail Error(${exception.statusCode}), SES status:(${exception.errorType.name})${exception.errorCode}:${exception.errorMessage}" + } + + /** + * Create error string from Amazon SDK Exceptions + * @param exception SDK Exception + * @return generated error string + */ + private fun getSdkExceptionText(exception: SdkBaseException): String { + log.info("$LOG_PREFIX:SdkException $exception") + return "sendEmail Error, SDK status:${exception.message}" + } +} diff --git a/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/client/DestinationEmailClient.kt b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/client/DestinationSmtpClient.kt similarity index 52% rename from notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/client/DestinationEmailClient.kt rename to notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/client/DestinationSmtpClient.kt index 2ab2d45f..490146f8 100644 --- a/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/client/DestinationEmailClient.kt +++ b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/client/DestinationSmtpClient.kt @@ -12,16 +12,20 @@ package org.opensearch.notifications.spi.client import com.sun.mail.util.MailConnectException +import org.opensearch.common.settings.SecureString import org.opensearch.notifications.spi.model.DestinationMessageResponse import org.opensearch.notifications.spi.model.MessageContent -import org.opensearch.notifications.spi.model.destination.EmailDestination +import org.opensearch.notifications.spi.model.SecureDestinationSettings +import org.opensearch.notifications.spi.model.destination.SmtpDestination import org.opensearch.notifications.spi.setting.PluginSettings import org.opensearch.notifications.spi.utils.SecurityAccess import org.opensearch.notifications.spi.utils.logger import org.opensearch.rest.RestStatus import java.util.Properties +import javax.mail.Authenticator import javax.mail.Message import javax.mail.MessagingException +import javax.mail.PasswordAuthentication import javax.mail.SendFailedException import javax.mail.Session import javax.mail.Transport @@ -30,15 +34,19 @@ import javax.mail.internet.MimeMessage /** * This class handles the connections to the given Destination. */ -class DestinationEmailClient { +class DestinationSmtpClient { companion object { - private val log by logger(DestinationEmailClient::class.java) + private val log by logger(DestinationSmtpClient::class.java) } @Throws(Exception::class) - fun execute(emailDestination: EmailDestination, message: MessageContent): DestinationMessageResponse { - if (isMessageSizeOverLimit(message)) { + fun execute( + smtpDestination: SmtpDestination, + message: MessageContent, + referenceId: String + ): DestinationMessageResponse { + if (EmailMessageValidator.isMessageSizeOverLimit(message)) { return DestinationMessageResponse( RestStatus.REQUEST_ENTITY_TOO_LARGE.status, "Email size larger than ${PluginSettings.emailSizeLimit}" @@ -47,32 +55,68 @@ class DestinationEmailClient { val prop = Properties() prop["mail.transport.protocol"] = "smtp" - prop["mail.smtp.host"] = emailDestination.host - prop["mail.smtp.port"] = emailDestination.port - val session = Session.getInstance(prop) + prop["mail.smtp.host"] = smtpDestination.host + prop["mail.smtp.port"] = smtpDestination.port + var session = Session.getInstance(prop) - when (emailDestination.method) { + when (smtpDestination.method) { "ssl" -> prop["mail.smtp.ssl.enable"] = true "start_tls" -> prop["mail.smtp.starttls.enable"] = true - "none" -> {} + "none" -> { + } else -> throw IllegalArgumentException("Invalid method supplied") } + if (smtpDestination.method != "none") { + val secureDestinationSetting = getSecureDestinationSetting(smtpDestination) + if (secureDestinationSetting != null) { + prop["mail.smtp.auth"] = true + session = Session.getInstance( + prop, + object : Authenticator() { + override fun getPasswordAuthentication(): PasswordAuthentication { + return PasswordAuthentication( + secureDestinationSetting.emailUsername.toString(), + secureDestinationSetting.emailPassword.toString() + ) + } + } + ) + } + } + // prepare mimeMessage - val mimeMessage = EmailMimeProvider.prepareMimeMessage(session, emailDestination, message) + val mimeMessage = EmailMimeProvider.prepareMimeMessage( + session, + smtpDestination.fromAddress, + smtpDestination.recipient, + message + ) // send Mime Message - return sendMimeMessage(mimeMessage) + return sendMimeMessage(mimeMessage, referenceId) + } + + fun getSecureDestinationSetting(SmtpDestination: SmtpDestination): SecureDestinationSettings? { + val emailUsername: SecureString? = + PluginSettings.destinationSettings[SmtpDestination.accountName]?.emailUsername + val emailPassword: SecureString? = + PluginSettings.destinationSettings[SmtpDestination.accountName]?.emailPassword + return if (emailUsername == null || emailPassword == null) { + null + } else { + SecureDestinationSettings(emailUsername, emailPassword) + } } /** * {@inheritDoc} */ - private fun sendMimeMessage(mimeMessage: MimeMessage): DestinationMessageResponse { + private fun sendMimeMessage(mimeMessage: MimeMessage, referenceId: String): DestinationMessageResponse { return try { - log.debug("Sending Email-SMTP") + log.debug("Sending Email-SMTP for $referenceId") SecurityAccess.doPrivileged { sendMessage(mimeMessage) } - log.info("Email-SMTP sent") + log.info("Email-SMTP sent for $referenceId") DestinationMessageResponse(RestStatus.OK.status, "Success") } catch (exception: SendFailedException) { DestinationMessageResponse(RestStatus.BAD_GATEWAY.status, getMessagingExceptionText(exception)) @@ -100,20 +144,4 @@ class DestinationEmailClient { log.info("EmailException $exception") return "sendEmail Error, status:${exception.message}" } - - private fun isMessageSizeOverLimit(message: MessageContent): Boolean { - val approxAttachmentLength = if (message.fileData != null && message.fileName != null) { - PluginSettings.emailMinimumHeaderLength + message.fileData.length + message.fileName.length - } else { - 0 - } - - val approxEmailLength = PluginSettings.emailMinimumHeaderLength + - message.title.length + - message.textDescription.length + - (message.htmlDescription?.length ?: 0) + - approxAttachmentLength - - return approxEmailLength > PluginSettings.emailSizeLimit - } } diff --git a/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/client/DestinationSnsClient.kt b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/client/DestinationSnsClient.kt new file mode 100644 index 00000000..65ac3bcd --- /dev/null +++ b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/client/DestinationSnsClient.kt @@ -0,0 +1,29 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.notifications.spi.client + +import com.amazonaws.services.sns.AmazonSNS +import org.opensearch.notifications.spi.credentials.SnsClientFactory +import org.opensearch.notifications.spi.model.MessageContent +import org.opensearch.notifications.spi.model.destination.SnsDestination + +/** + * This class handles the SNS connections to the given Destination. + */ +class DestinationSnsClient(private val snsClientFactory: SnsClientFactory) { + + fun execute(destination: SnsDestination, message: MessageContent, referenceId: String): String { + val amazonSNS: AmazonSNS = snsClientFactory.createSnsClient(destination.region, destination.roleArn) + val result = amazonSNS.publish(destination.topicArn, message.textDescription, message.title) + return result.messageId + } +} diff --git a/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/client/EmailMessageValidator.kt b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/client/EmailMessageValidator.kt new file mode 100644 index 00000000..3b35a899 --- /dev/null +++ b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/client/EmailMessageValidator.kt @@ -0,0 +1,38 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.notifications.spi.client + +import org.opensearch.notifications.spi.model.MessageContent +import org.opensearch.notifications.spi.setting.PluginSettings +import org.opensearch.notifications.spi.utils.logger + +/** + * This class handles the connections to the given Destination. + */ +internal object EmailMessageValidator { + private val log by logger(EmailMessageValidator::class.java) + fun isMessageSizeOverLimit(message: MessageContent): Boolean { + val approxAttachmentLength = if (message.fileData != null && message.fileName != null) { + PluginSettings.emailMinimumHeaderLength + message.fileData.length + message.fileName.length + } else { + 0 + } + + val approxEmailLength = PluginSettings.emailMinimumHeaderLength + + message.title.length + + message.textDescription.length + + (message.htmlDescription?.length ?: 0) + + approxAttachmentLength + + return approxEmailLength > PluginSettings.emailSizeLimit + } +} diff --git a/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/client/EmailMimeProvider.kt b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/client/EmailMimeProvider.kt index fdc3141b..8e445f16 100644 --- a/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/client/EmailMimeProvider.kt +++ b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/client/EmailMimeProvider.kt @@ -28,7 +28,6 @@ package org.opensearch.notifications.spi.client import org.opensearch.notifications.spi.model.MessageContent -import org.opensearch.notifications.spi.model.destination.EmailDestination import java.util.Base64 import javax.activation.DataHandler import javax.mail.Message @@ -45,23 +44,25 @@ internal object EmailMimeProvider { /** * Create and prepare mime mimeMessage to send mail * @param session The mail session to use to create mime mimeMessage - * @param emailDestination - * @param mimeMessage The mimeMessage to send notification + * @param fromAddress The sender email address + * @param recipient The recipient email address + * @param messageContent The mimeMessage to send notification * @return The created and prepared mime mimeMessage object */ fun prepareMimeMessage( session: Session, - emailDestination: EmailDestination, + fromAddress: String, + recipient: String, messageContent: MessageContent ): MimeMessage { // Create a new MimeMessage object val mimeMessage = MimeMessage(session) // Add from: - mimeMessage.setFrom(emailDestination.fromAddress) + mimeMessage.setFrom(fromAddress) // Add to: - mimeMessage.setRecipients(Message.RecipientType.TO, emailDestination.recipient) + mimeMessage.setRecipients(Message.RecipientType.TO, recipient) // Add Subject: mimeMessage.setSubject(messageContent.title, "UTF-8") @@ -119,7 +120,7 @@ internal object EmailMimeProvider { /** * Create a binary attachment part from channel attachment mimeMessage - * @param attachment channel attachment mimeMessage + * @param messageContent channel attachment mimeMessage * @return created mime body part for binary attachment */ private fun createBinaryAttachmentPart(messageContent: MessageContent): MimeBodyPart { @@ -135,7 +136,7 @@ internal object EmailMimeProvider { /** * Create a text attachment part from channel attachment mimeMessage - * @param attachment channel attachment mimeMessage + * @param messageContent channel attachment mimeMessage * @return created mime body part for text attachment */ private fun createTextAttachmentPart(messageContent: MessageContent): MimeBodyPart { diff --git a/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/credentials/CredentialsProvider.kt b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/credentials/CredentialsProvider.kt new file mode 100644 index 00000000..7ebe5dc6 --- /dev/null +++ b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/credentials/CredentialsProvider.kt @@ -0,0 +1,27 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.notifications.spi.credentials + +import com.amazonaws.auth.AWSCredentialsProvider + +/** + * AWS Credential provider using region and/or role + */ +interface CredentialsProvider { + /** + * create/get AWS Credential provider using region and/or role + * @param region AWS region + * @param roleArn optional role ARN + * @return AWSCredentialsProvider + */ + fun getCredentialsProvider(region: String, roleArn: String?): AWSCredentialsProvider +} diff --git a/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/credentials/SesClientFactory.kt b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/credentials/SesClientFactory.kt new file mode 100644 index 00000000..df437ff5 --- /dev/null +++ b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/credentials/SesClientFactory.kt @@ -0,0 +1,20 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ +package org.opensearch.notifications.spi.credentials + +import com.amazonaws.services.simpleemail.AmazonSimpleEmailService + +/** + * Interface for creating SES client + */ +interface SesClientFactory { + fun createSesClient(region: String, roleArn: String?): AmazonSimpleEmailService +} diff --git a/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/credentials/SnsClientFactory.kt b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/credentials/SnsClientFactory.kt new file mode 100644 index 00000000..0f2c7f15 --- /dev/null +++ b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/credentials/SnsClientFactory.kt @@ -0,0 +1,21 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.notifications.spi.credentials + +import com.amazonaws.services.sns.AmazonSNS + +/** + * Interface for creating SNS client + */ +interface SnsClientFactory { + fun createSnsClient(region: String, roleArn: String?): AmazonSNS +} diff --git a/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/credentials/oss/CredentialsProviderFactory.kt b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/credentials/oss/CredentialsProviderFactory.kt new file mode 100644 index 00000000..fb06254f --- /dev/null +++ b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/credentials/oss/CredentialsProviderFactory.kt @@ -0,0 +1,50 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.notifications.spi.credentials.oss + +import com.amazonaws.auth.AWSCredentialsProvider +import com.amazonaws.auth.AWSStaticCredentialsProvider +import com.amazonaws.auth.BasicSessionCredentials +import com.amazonaws.auth.DefaultAWSCredentialsProviderChain +import com.amazonaws.auth.profile.ProfileCredentialsProvider +import com.amazonaws.services.securitytoken.AWSSecurityTokenServiceClientBuilder +import com.amazonaws.services.securitytoken.model.AssumeRoleRequest +import org.opensearch.notifications.spi.credentials.CredentialsProvider + +class CredentialsProviderFactory : CredentialsProvider { + override fun getCredentialsProvider(region: String, roleArn: String?): AWSCredentialsProvider { + return if (roleArn != null) { + getCredentialsProviderByIAMRole(region, roleArn) + } else { + DefaultAWSCredentialsProviderChain() + } + } + + private fun getCredentialsProviderByIAMRole(region: String, roleArn: String?): AWSCredentialsProvider { + // TODO cache credentials by role ARN? + val stsClient = AWSSecurityTokenServiceClientBuilder.standard() + .withCredentials(ProfileCredentialsProvider()) + .withRegion(region) + .build() + val roleRequest = AssumeRoleRequest() + .withRoleArn(roleArn) + .withRoleSessionName("opensearch-notifications") + val roleResponse = stsClient.assumeRole(roleRequest) + val sessionCredentials = roleResponse.credentials + val awsCredentials = BasicSessionCredentials( + sessionCredentials.accessKeyId, + sessionCredentials.secretAccessKey, + sessionCredentials.sessionToken + ) + return AWSStaticCredentialsProvider(awsCredentials) + } +} diff --git a/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/credentials/oss/SesClientFactoryImpl.kt b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/credentials/oss/SesClientFactoryImpl.kt new file mode 100644 index 00000000..341b0e91 --- /dev/null +++ b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/credentials/oss/SesClientFactoryImpl.kt @@ -0,0 +1,32 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ +package org.opensearch.notifications.spi.credentials.oss + +import com.amazonaws.services.simpleemail.AmazonSimpleEmailService +import com.amazonaws.services.simpleemail.AmazonSimpleEmailServiceClientBuilder +import org.opensearch.notifications.spi.credentials.SesClientFactory +import org.opensearch.notifications.spi.utils.SecurityAccess + +/** + * Factory for creating SES client + */ +object SesClientFactoryImpl : SesClientFactory { + override fun createSesClient(region: String, roleArn: String?): AmazonSimpleEmailService { + return SecurityAccess.doPrivileged { + val credentials = + CredentialsProviderFactory().getCredentialsProvider(region, roleArn) + AmazonSimpleEmailServiceClientBuilder.standard() + .withRegion(region) + .withCredentials(credentials) + .build() + } + } +} diff --git a/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/credentials/oss/SnsClientFactoryImpl.kt b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/credentials/oss/SnsClientFactoryImpl.kt new file mode 100644 index 00000000..93003570 --- /dev/null +++ b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/credentials/oss/SnsClientFactoryImpl.kt @@ -0,0 +1,33 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.notifications.spi.credentials.oss + +import com.amazonaws.services.sns.AmazonSNS +import com.amazonaws.services.sns.AmazonSNSClientBuilder +import org.opensearch.notifications.spi.credentials.SnsClientFactory +import org.opensearch.notifications.spi.utils.SecurityAccess + +/** + * Factory for creating SNS client + */ +object SnsClientFactoryImpl : SnsClientFactory { + override fun createSnsClient(region: String, roleArn: String?): AmazonSNS { + return SecurityAccess.doPrivileged { + val credentials = + CredentialsProviderFactory().getCredentialsProvider(region, roleArn) + AmazonSNSClientBuilder.standard() + .withRegion(region) + .withCredentials(credentials) + .build() + } + } +} diff --git a/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/factory/DestinationFactoryProvider.kt b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/factory/DestinationFactoryProvider.kt deleted file mode 100644 index 0523c2b9..00000000 --- a/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/factory/DestinationFactoryProvider.kt +++ /dev/null @@ -1,41 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - * - * Modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -package org.opensearch.notifications.spi.factory - -import org.opensearch.notifications.spi.model.destination.BaseDestination -import org.opensearch.notifications.spi.model.destination.DestinationType - -/** - * This class helps in fetching the right destination factory based on type - * A Destination could be Email, Webhook etc - */ -internal object DestinationFactoryProvider { - - var destinationFactoryMap = mapOf( - // TODO Add other destinations, ses, sns - DestinationType.SLACK to WebhookDestinationFactory(), - DestinationType.CHIME to WebhookDestinationFactory(), - DestinationType.CUSTOMWEBHOOK to WebhookDestinationFactory(), - DestinationType.SMTP to SmtpEmailDestinationFactory() - ) - - /** - * Fetches the right destination factory based on the type - * - * @param destinationType [{@link DestinationType}] - * @return DestinationFactory factory object for above destination type - */ - fun getFactory(destinationType: DestinationType): DestinationFactory { - require(destinationFactoryMap.containsKey(destinationType)) { "Invalid channel type" } - return destinationFactoryMap[destinationType] as DestinationFactory - } -} diff --git a/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/model/MessageContent.kt b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/model/MessageContent.kt index 64711ef0..9e9889cb 100644 --- a/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/model/MessageContent.kt +++ b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/model/MessageContent.kt @@ -48,7 +48,7 @@ class MessageContent( require(!Strings.isNullOrEmpty(textDescription)) { "text message part is null or empty" } } - fun buildWebhookMessage(): String { + fun buildMessageWithTitle(): String { return "$title\n\n$textDescription" } } diff --git a/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/model/SecureDestinationSettings.kt b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/model/SecureDestinationSettings.kt new file mode 100644 index 00000000..4cfb11cd --- /dev/null +++ b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/model/SecureDestinationSettings.kt @@ -0,0 +1,16 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.notifications.spi.model + +import org.opensearch.common.settings.SecureString + +data class SecureDestinationSettings(val emailUsername: SecureString, val emailPassword: SecureString) diff --git a/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/model/destination/CustomWebhookDestination.kt b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/model/destination/CustomWebhookDestination.kt index 90650f69..9b27fbfa 100644 --- a/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/model/destination/CustomWebhookDestination.kt +++ b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/model/destination/CustomWebhookDestination.kt @@ -20,7 +20,7 @@ class CustomWebhookDestination( url: String, val headerParams: Map, val method: String -) : WebhookDestination(url, DestinationType.CUSTOMWEBHOOK) { +) : WebhookDestination(url, DestinationType.CUSTOM_WEBHOOK) { init { validateMethod(method) diff --git a/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/model/destination/DestinationType.kt b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/model/destination/DestinationType.kt index 99c89c9f..fa5ae7dc 100644 --- a/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/model/destination/DestinationType.kt +++ b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/model/destination/DestinationType.kt @@ -14,5 +14,5 @@ package org.opensearch.notifications.spi.model.destination * Supported notification destinations */ enum class DestinationType { - CHIME, SLACK, CUSTOMWEBHOOK, SMTP, SES, SNS + CHIME, SLACK, CUSTOM_WEBHOOK, SMTP, SES, SNS } diff --git a/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/model/destination/SesDestination.kt b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/model/destination/SesDestination.kt new file mode 100644 index 00000000..73c6aa41 --- /dev/null +++ b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/model/destination/SesDestination.kt @@ -0,0 +1,35 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.notifications.spi.model.destination + +import com.amazonaws.regions.Regions +import org.opensearch.common.Strings +import org.opensearch.notifications.spi.utils.validateEmail + +/** + * This class holds the contents of ses destination + */ +class SesDestination( + val accountName: String, + val awsRegion: String, + val roleArn: String?, + val fromAddress: String, + val recipient: String +) : BaseDestination(DestinationType.SES) { + + init { + require(!Strings.isNullOrEmpty(awsRegion)) { "aws region should be provided" } + require(Regions.values().any { it.name == awsRegion }) { "aws region is not valid" } + validateEmail(fromAddress) + validateEmail(recipient) + } +} diff --git a/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/model/destination/EmailDestination.kt b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/model/destination/SmtpDestination.kt similarity index 67% rename from notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/model/destination/EmailDestination.kt rename to notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/model/destination/SmtpDestination.kt index bf5fbf14..ac76bf71 100644 --- a/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/model/destination/EmailDestination.kt +++ b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/model/destination/SmtpDestination.kt @@ -31,26 +31,21 @@ import org.opensearch.common.Strings import org.opensearch.notifications.spi.utils.validateEmail /** - * This class holds the contents of email destination + * This class holds the contents of smtp destination */ -class EmailDestination( +class SmtpDestination( + val accountName: String, val host: String, val port: Int, val method: String, val fromAddress: String, - val recipient: String, - destinationType: DestinationType, // smtp or ses -) : BaseDestination(destinationType) { + val recipient: String +) : BaseDestination(DestinationType.SMTP) { init { - when (destinationType) { - DestinationType.SMTP -> { - require(!Strings.isNullOrEmpty(host)) { "Host name should be provided" } - require(port > 0) { "Port should be positive value" } - validateEmail(fromAddress) - validateEmail(recipient) - } - // TODO Add ses here - } + require(!Strings.isNullOrEmpty(host)) { "Host name should be provided" } + require(port > 0) { "Port should be positive value" } + validateEmail(fromAddress) + validateEmail(recipient) } } diff --git a/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/model/destination/SnsDestination.kt b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/model/destination/SnsDestination.kt new file mode 100644 index 00000000..19432b13 --- /dev/null +++ b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/model/destination/SnsDestination.kt @@ -0,0 +1,22 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ +package org.opensearch.notifications.spi.model.destination + +/** + * This class holds the contents of SNS destination + */ +data class SnsDestination( + val topicArn: String, + val roleArn: String? = null, +) : BaseDestination(DestinationType.SNS) { + // sample topic arn -> arn:aws:sns:us-west-2:075315751589:test-notification + val region: String = topicArn.split(":".toRegex()).toTypedArray()[3] +} diff --git a/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/model/destination/WebhookDestination.kt b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/model/destination/WebhookDestination.kt index c64cea21..f0ab5cf0 100644 --- a/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/model/destination/WebhookDestination.kt +++ b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/model/destination/WebhookDestination.kt @@ -11,10 +11,8 @@ package org.opensearch.notifications.spi.model.destination -import org.apache.http.client.utils.URIBuilder +import org.opensearch.notifications.spi.setting.PluginSettings import org.opensearch.notifications.spi.utils.validateUrl -import java.net.URI -import java.net.URISyntaxException /** * This class holds the contents of generic webbook destination @@ -25,16 +23,7 @@ abstract class WebhookDestination( ) : BaseDestination(destinationType) { init { - validateUrl(url) - } - - @SuppressWarnings("SwallowedException") - internal fun buildUri(): URI { - return try { - URIBuilder(url).build() - } catch (exception: URISyntaxException) { - throw IllegalStateException("Error creating URI") - } + validateUrl(url, PluginSettings.hostDenyList) } override fun toString(): String { diff --git a/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/setting/PluginSettings.kt b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/setting/PluginSettings.kt index 9b3b86f8..23101d46 100644 --- a/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/setting/PluginSettings.kt +++ b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/setting/PluginSettings.kt @@ -13,12 +13,15 @@ package org.opensearch.notifications.spi.setting import org.opensearch.bootstrap.BootstrapInfo import org.opensearch.cluster.service.ClusterService +import org.opensearch.common.settings.SecureSetting +import org.opensearch.common.settings.SecureString import org.opensearch.common.settings.Setting import org.opensearch.common.settings.Setting.Property.Dynamic import org.opensearch.common.settings.Setting.Property.NodeScope import org.opensearch.common.settings.Settings import org.opensearch.notifications.spi.NotificationSpiPlugin.Companion.LOG_PREFIX import org.opensearch.notifications.spi.NotificationSpiPlugin.Companion.PLUGIN_NAME +import org.opensearch.notifications.spi.model.SecureDestinationSettings import org.opensearch.notifications.spi.utils.logger import java.io.IOException import java.nio.file.Path @@ -34,6 +37,11 @@ internal object PluginSettings { */ private const val EMAIL_KEY_PREFIX = "$KEY_PREFIX.email" + /** + * Settings Key prefix for Email. + */ + private const val EMAIL_DESTINATION_SETTING_PREFIX = "$KEY_PREFIX.email." + /** * Settings Key prefix for http connection. */ @@ -74,6 +82,16 @@ internal object PluginSettings { */ private const val ALLOWED_CONFIG_TYPE_KEY = "$KEY_PREFIX.allowedConfigTypes" + /** + * Setting to enable tooltip in UI + */ + private const val TOOLTIP_SUPPORT_KEY = "$KEY_PREFIX.tooltip_support" + + /** + * Setting to enable tooltip in UI + */ + private const val HOST_DENY_LIST_KEY = "$EMAIL_KEY_PREFIX.host_deny_list" + /** * Default email size limit as 10MB. */ @@ -117,10 +135,27 @@ internal object PluginSettings { "chime", "webhook", "email", + "sns", + "ses_account", "smtp_account", "email_group" ) + /** + * Default email host deny list + */ + private val DEFAULT_HOST_DENY_LIST = emptyList() + + /** + * Default disable tooltip support + */ + private const val DEFAULT_TOOLTIP_SUPPORT = false + + /** + * Default destination settings + */ + private val DEFAULT_DESTINATION_SETTINGS = emptyMap() + /** * list of allowed config types. */ @@ -163,6 +198,24 @@ internal object PluginSettings { @Volatile var socketTimeout: Int + /** + * Tooltip support + */ + @Volatile + var tooltipSupport: Boolean + + /** + * list of allowed config types. + */ + @Volatile + var hostDenyList: List + + /** + * Destination Settings + */ + @Volatile + var destinationSettings: Map + private const val DECIMAL_RADIX: Int = 10 private val log by logger(javaClass) @@ -190,6 +243,9 @@ internal object PluginSettings { ?: DEFAULT_CONNECTION_TIMEOUT_MILLISECONDS socketTimeout = (settings?.get(SOCKET_TIMEOUT_MILLISECONDS_KEY)?.toInt()) ?: DEFAULT_SOCKET_TIMEOUT_MILLISECONDS allowedConfigTypes = settings?.getAsList(ALLOWED_CONFIG_TYPE_KEY, null) ?: DEFAULT_ALLOWED_CONFIG_TYPES + tooltipSupport = settings?.getAsBoolean(TOOLTIP_SUPPORT_KEY, false) ?: DEFAULT_TOOLTIP_SUPPORT + hostDenyList = settings?.getAsList(HOST_DENY_LIST_KEY, null) ?: DEFAULT_HOST_DENY_LIST + destinationSettings = if (settings != null) loadDestinationSettings(settings) else DEFAULT_DESTINATION_SETTINGS defaultSettings = mapOf( EMAIL_SIZE_LIMIT_KEY to emailSizeLimit.toString(DECIMAL_RADIX), @@ -197,7 +253,8 @@ internal object PluginSettings { MAX_CONNECTIONS_KEY to maxConnections.toString(DECIMAL_RADIX), MAX_CONNECTIONS_PER_ROUTE_KEY to maxConnectionsPerRoute.toString(DECIMAL_RADIX), CONNECTION_TIMEOUT_MILLISECONDS_KEY to connectionTimeout.toString(DECIMAL_RADIX), - SOCKET_TIMEOUT_MILLISECONDS_KEY to socketTimeout.toString(DECIMAL_RADIX) + SOCKET_TIMEOUT_MILLISECONDS_KEY to socketTimeout.toString(DECIMAL_RADIX), + TOOLTIP_SUPPORT_KEY to tooltipSupport.toString() ) } @@ -245,6 +302,31 @@ internal object PluginSettings { NodeScope, Dynamic ) + private val TOOLTIP_SUPPORT: Setting = Setting.boolSetting( + TOOLTIP_SUPPORT_KEY, + defaultSettings[TOOLTIP_SUPPORT_KEY]!!.toBoolean(), + NodeScope, Dynamic + ) + + private val HOST_DENY_LIST: Setting> = Setting.listSetting( + HOST_DENY_LIST_KEY, + DEFAULT_HOST_DENY_LIST, + { it }, + NodeScope, Dynamic + ) + + private val EMAIL_USERNAME: Setting.AffixSetting = Setting.affixKeySetting( + EMAIL_DESTINATION_SETTING_PREFIX, + "username", + { key: String -> SecureSetting.secureString(key, null) } + ) + + private val EMAIL_PASSWORD: Setting.AffixSetting = Setting.affixKeySetting( + EMAIL_DESTINATION_SETTING_PREFIX, + "password", + { key: String -> SecureSetting.secureString(key, null) } + ) + /** * Returns list of additional settings available specific to this plugin. * @@ -258,7 +340,11 @@ internal object PluginSettings { MAX_CONNECTIONS_PER_ROUTE, CONNECTION_TIMEOUT_MILLISECONDS, SOCKET_TIMEOUT_MILLISECONDS, - ALLOWED_CONFIG_TYPES + ALLOWED_CONFIG_TYPES, + TOOLTIP_SUPPORT, + HOST_DENY_LIST, + EMAIL_USERNAME, + EMAIL_PASSWORD ) } /** @@ -273,6 +359,8 @@ internal object PluginSettings { maxConnectionsPerRoute = MAX_CONNECTIONS_PER_ROUTE.get(clusterService.settings) connectionTimeout = CONNECTION_TIMEOUT_MILLISECONDS.get(clusterService.settings) socketTimeout = SOCKET_TIMEOUT_MILLISECONDS.get(clusterService.settings) + tooltipSupport = TOOLTIP_SUPPORT.get(clusterService.settings) + hostDenyList = HOST_DENY_LIST.get(clusterService.settings) } /** @@ -311,10 +399,20 @@ internal object PluginSettings { log.debug("$LOG_PREFIX:$SOCKET_TIMEOUT_MILLISECONDS_KEY -autoUpdatedTo-> $clusterSocketTimeout") socketTimeout = clusterSocketTimeout } - val clusterallowedConfigTypes = clusterService.clusterSettings.get(ALLOWED_CONFIG_TYPES) - if (clusterallowedConfigTypes != null) { - log.debug("$LOG_PREFIX:$ALLOWED_CONFIG_TYPE_KEY -autoUpdatedTo-> $clusterallowedConfigTypes") - allowedConfigTypes = clusterallowedConfigTypes + val clusterAllowedConfigTypes = clusterService.clusterSettings.get(ALLOWED_CONFIG_TYPES) + if (clusterAllowedConfigTypes != null) { + log.debug("$LOG_PREFIX:$ALLOWED_CONFIG_TYPE_KEY -autoUpdatedTo-> $clusterAllowedConfigTypes") + allowedConfigTypes = clusterAllowedConfigTypes + } + val clusterTooltipSupport = clusterService.clusterSettings.get(TOOLTIP_SUPPORT) + if (clusterTooltipSupport != null) { + log.debug("$LOG_PREFIX:$TOOLTIP_SUPPORT_KEY -autoUpdatedTo-> $clusterAllowedConfigTypes") + tooltipSupport = clusterTooltipSupport + } + val clusterHostDenyList = clusterService.clusterSettings.get(HOST_DENY_LIST) + if (clusterHostDenyList != null) { + log.debug("$LOG_PREFIX:$HOST_DENY_LIST_KEY -autoUpdatedTo-> $clusterHostDenyList") + hostDenyList = clusterHostDenyList } } @@ -356,5 +454,46 @@ internal object PluginSettings { socketTimeout = it log.info("$LOG_PREFIX:$SOCKET_TIMEOUT_MILLISECONDS_KEY -updatedTo-> $it") } + clusterService.clusterSettings.addSettingsUpdateConsumer(TOOLTIP_SUPPORT) { + tooltipSupport = it + log.info("$LOG_PREFIX:$TOOLTIP_SUPPORT_KEY -updatedTo-> $it") + } + clusterService.clusterSettings.addSettingsUpdateConsumer(HOST_DENY_LIST) { + hostDenyList = it + log.info("$LOG_PREFIX:$HOST_DENY_LIST_KEY -updatedTo-> $it") + } + } + + fun loadDestinationSettings(settings: Settings): Map { + // Only loading Email Destination settings for now since those are the only secure settings needed. + // If this logic needs to be expanded to support other Destinations, different groups can be retrieved similar + // to emailAccountNames based on the setting namespace and SecureDestinationSettings should be expanded to support + // these new settings. + val emailAccountNames: Set = settings.getGroups(EMAIL_DESTINATION_SETTING_PREFIX).keys + val emailAccounts: MutableMap = mutableMapOf() + for (emailAccountName in emailAccountNames) { + // Only adding the settings if they exist + getSecureDestinationSettings(settings, emailAccountName)?.let { + emailAccounts[emailAccountName] = it + } + } + + return emailAccounts + } + + private fun getSecureDestinationSettings(settings: Settings, emailAccountName: String): SecureDestinationSettings? { + // Using 'use' to emulate Java's try-with-resources on multiple closeable resources. + // Values are cloned so that we maintain a SecureString, the original SecureStrings will be closed after + // they have left the scope of this function. + return getEmailSettingValue(settings, emailAccountName, EMAIL_USERNAME)?.use { emailUsername -> + getEmailSettingValue(settings, emailAccountName, EMAIL_PASSWORD)?.use { emailPassword -> + SecureDestinationSettings(emailUsername = emailUsername.clone(), emailPassword = emailPassword.clone()) + } + } + } + + private fun getEmailSettingValue(settings: Settings, emailAccountName: String, emailSetting: Setting.AffixSetting): T? { + val concreteSetting = emailSetting.getConcreteSettingForNamespace(emailAccountName) + return concreteSetting.get(settings) } } diff --git a/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/factory/DestinationFactory.kt b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/transport/DestinationTransport.kt similarity index 75% rename from notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/factory/DestinationFactory.kt rename to notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/transport/DestinationTransport.kt index a97e39fa..5ca1ebe2 100644 --- a/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/factory/DestinationFactory.kt +++ b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/transport/DestinationTransport.kt @@ -9,7 +9,7 @@ * GitHub history for details. */ -package org.opensearch.notifications.spi.factory +package org.opensearch.notifications.spi.transport import org.opensearch.notifications.spi.model.DestinationMessageResponse import org.opensearch.notifications.spi.model.MessageContent @@ -20,15 +20,18 @@ import org.opensearch.notifications.spi.model.destination.BaseDestination * * @param message object of type [{@link DestinationType}] */ -internal interface DestinationFactory { +internal interface DestinationTransport { /** * Sending notification message over this channel. * + * @param destination destination configuration for sending message * @param message The message to send notification + * @param referenceId referenceId for message * @return Channel message response */ fun sendMessage( destination: T, - message: MessageContent + message: MessageContent, + referenceId: String ): DestinationMessageResponse } diff --git a/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/transport/DestinationTransportProvider.kt b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/transport/DestinationTransportProvider.kt new file mode 100644 index 00000000..b1c538a1 --- /dev/null +++ b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/transport/DestinationTransportProvider.kt @@ -0,0 +1,50 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.notifications.spi.transport + +import org.opensearch.notifications.spi.model.destination.BaseDestination +import org.opensearch.notifications.spi.model.destination.DestinationType +import org.opensearch.notifications.spi.utils.OpenForTesting + +/** + * This class helps in fetching the right destination transport based on type + * A Destination could be SMTP, Webhook etc + */ +internal object DestinationTransportProvider { + + private val webhookDestinationTransport = WebhookDestinationTransport() + private val smtpDestinationTransport = SmtpDestinationTransport() + private val snsDestinationTransport = SnsDestinationTransport() + private val sesDestinationTransport = SesDestinationTransport() + + @OpenForTesting + var destinationTransportMap = mapOf( + DestinationType.SLACK to webhookDestinationTransport, + DestinationType.CHIME to webhookDestinationTransport, + DestinationType.CUSTOM_WEBHOOK to webhookDestinationTransport, + DestinationType.SMTP to smtpDestinationTransport, + DestinationType.SNS to snsDestinationTransport, + DestinationType.SES to sesDestinationTransport + ) + + /** + * Fetches the right destination transport based on the type + * + * @param destinationType [{@link DestinationType}] + * @return DestinationTransport transport object for above destination type + */ + @Suppress("UNCHECKED_CAST") + fun getTransport(destinationType: DestinationType): DestinationTransport { + val retVal = destinationTransportMap[destinationType] ?: throw IllegalArgumentException("Invalid channel type") + return retVal as DestinationTransport + } +} diff --git a/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/factory/SmtpEmailDestinationFactory.kt b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/transport/SesDestinationTransport.kt similarity index 73% rename from notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/factory/SmtpEmailDestinationFactory.kt rename to notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/transport/SesDestinationTransport.kt index b0eec98d..cc891d11 100644 --- a/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/factory/SmtpEmailDestinationFactory.kt +++ b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/transport/SesDestinationTransport.kt @@ -9,13 +9,13 @@ * GitHub history for details. */ -package org.opensearch.notifications.spi.factory +package org.opensearch.notifications.spi.transport import org.opensearch.notifications.spi.client.DestinationClientPool -import org.opensearch.notifications.spi.client.DestinationEmailClient +import org.opensearch.notifications.spi.client.DestinationSesClient import org.opensearch.notifications.spi.model.DestinationMessageResponse import org.opensearch.notifications.spi.model.MessageContent -import org.opensearch.notifications.spi.model.destination.EmailDestination +import org.opensearch.notifications.spi.model.destination.SesDestination import org.opensearch.notifications.spi.utils.OpenForTesting import org.opensearch.notifications.spi.utils.logger import org.opensearch.rest.RestStatus @@ -26,23 +26,27 @@ import javax.mail.internet.AddressException /** * This class handles the client responsible for submitting the messages to all types of email destinations. */ -internal class SmtpEmailDestinationFactory : DestinationFactory { +internal class SesDestinationTransport : DestinationTransport { - private val log by logger(SmtpEmailDestinationFactory::class.java) - private val destinationEmailClient: DestinationEmailClient + private val log by logger(SesDestinationTransport::class.java) + private val destinationEmailClient: DestinationSesClient constructor() { - this.destinationEmailClient = DestinationClientPool.emailClient + this.destinationEmailClient = DestinationClientPool.sesClient } @OpenForTesting - constructor(destinationEmailClient: DestinationEmailClient) { - this.destinationEmailClient = destinationEmailClient + constructor(destinationSesClient: DestinationSesClient) { + this.destinationEmailClient = destinationSesClient } - override fun sendMessage(destination: EmailDestination, message: MessageContent): DestinationMessageResponse { + override fun sendMessage( + destination: SesDestination, + message: MessageContent, + referenceId: String + ): DestinationMessageResponse { return try { - destinationEmailClient.execute(destination, message) + destinationEmailClient.execute(destination, message, referenceId) } catch (addressException: AddressException) { log.error("Error sending Email: recipient parsing failed with status:${addressException.message}") DestinationMessageResponse( @@ -66,7 +70,7 @@ internal class SmtpEmailDestinationFactory : DestinationFactory { + + private val log by logger(SmtpDestinationTransport::class.java) + private val destinationEmailClient: DestinationSmtpClient + + constructor() { + this.destinationEmailClient = DestinationClientPool.smtpClient + } + + @OpenForTesting + constructor(destinationSmtpClient: DestinationSmtpClient) { + this.destinationEmailClient = destinationSmtpClient + } + + override fun sendMessage( + destination: SmtpDestination, + message: MessageContent, + referenceId: String + ): DestinationMessageResponse { + return try { + destinationEmailClient.execute(destination, message, referenceId) + } catch (addressException: AddressException) { + log.error("Error sending Email: recipient parsing failed with status:${addressException.message}") + DestinationMessageResponse( + RestStatus.BAD_REQUEST.status, + "recipient parsing failed with status:${addressException.message}" + ) + } catch (messagingException: MessagingException) { + log.error("Error sending Email: Email message creation failed with status:${messagingException.message}") + DestinationMessageResponse( + RestStatus.FAILED_DEPENDENCY.status, + "Email message creation failed with status:${messagingException.message}" + ) + } catch (ioException: IOException) { + log.error("Error sending Email: Email message creation failed with status:${ioException.message}") + DestinationMessageResponse( + RestStatus.FAILED_DEPENDENCY.status, + "Email message creation failed with status:${ioException.message}" + ) + } catch (illegalArgumentException: IllegalArgumentException) { + log.error( + "Error sending Email: Email message creation failed with status:${illegalArgumentException.message}" + ) + DestinationMessageResponse( + RestStatus.BAD_REQUEST.status, + "Email message creation failed with status:${illegalArgumentException.message}" + ) + } + } +} diff --git a/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/transport/SnsDestinationTransport.kt b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/transport/SnsDestinationTransport.kt new file mode 100644 index 00000000..5cffeb91 --- /dev/null +++ b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/transport/SnsDestinationTransport.kt @@ -0,0 +1,57 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.notifications.spi.transport + +import org.opensearch.notifications.spi.client.DestinationClientPool +import org.opensearch.notifications.spi.client.DestinationSnsClient +import org.opensearch.notifications.spi.model.DestinationMessageResponse +import org.opensearch.notifications.spi.model.MessageContent +import org.opensearch.notifications.spi.model.destination.SnsDestination +import org.opensearch.notifications.spi.utils.OpenForTesting +import org.opensearch.notifications.spi.utils.logger +import org.opensearch.rest.RestStatus +import java.io.IOException + +/** + * This class handles the client responsible for submitting the messages to SNS destinations. + */ +internal class SnsDestinationTransport : DestinationTransport { + + private val log by logger(SnsDestinationTransport::class.java) + private val destinationSNSClient: DestinationSnsClient + + constructor() { + this.destinationSNSClient = DestinationClientPool.snsClient + } + + @OpenForTesting + constructor(destinationSmtpClient: DestinationSnsClient) { + this.destinationSNSClient = destinationSmtpClient + } + + override fun sendMessage( + destination: SnsDestination, + message: MessageContent, + referenceId: String + ): DestinationMessageResponse { + return try { + val response = destinationSNSClient.execute(destination, message, referenceId) + DestinationMessageResponse(RestStatus.OK.status, "Success, message id: $response") + } catch (exception: IOException) { // TODO:Add specific SNS exception and throw corresponding errors + log.error("Exception sending message id $referenceId", exception) + DestinationMessageResponse( + RestStatus.INTERNAL_SERVER_ERROR.status, + "Failed to send message ${exception.message}" + ) + } + } +} diff --git a/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/factory/WebhookDestinationFactory.kt b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/transport/WebhookDestinationTransport.kt similarity index 77% rename from notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/factory/WebhookDestinationFactory.kt rename to notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/transport/WebhookDestinationTransport.kt index 89a2077d..4225065c 100644 --- a/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/factory/WebhookDestinationFactory.kt +++ b/notifications/spi/src/main/kotlin/org/opensearch/notifications/spi/transport/WebhookDestinationTransport.kt @@ -9,7 +9,7 @@ * GitHub history for details. */ -package org.opensearch.notifications.spi.factory +package org.opensearch.notifications.spi.transport import org.opensearch.notifications.spi.client.DestinationClientPool import org.opensearch.notifications.spi.client.DestinationHttpClient @@ -24,9 +24,9 @@ import java.io.IOException /** * This class handles the client responsible for submitting the messages to all types of webhook destinations. */ -internal class WebhookDestinationFactory : DestinationFactory { +internal class WebhookDestinationTransport : DestinationTransport { - private val log by logger(WebhookDestinationFactory::class.java) + private val log by logger(WebhookDestinationTransport::class.java) private val destinationHttpClient: DestinationHttpClient constructor() { @@ -38,12 +38,16 @@ internal class WebhookDestinationFactory : DestinationFactory) { require(!Strings.isNullOrEmpty(urlString)) { "url is null or empty" } require(isValidUrl(urlString)) { "Invalid URL or unsupported" } - val url = URL(urlString) - require("https" == url.protocol) // Support only HTTPS. HTTP and other protocols not supported - // TODO : Add hosts deny list + require(!isHostInDenylist(urlString, hostDenyList)) { + "Host of url is denied, based on plugin setting [notification.spi.email.host_deny_list]" + } } fun validateEmail(email: String) { @@ -48,8 +49,22 @@ fun validateEmail(email: String) { fun isValidUrl(urlString: String): Boolean { val url = URL(urlString) // throws MalformedURLException if URL is invalid - // TODO : Add hosts deny list - return ("https" == url.protocol) // Support only HTTPS. HTTP and other protocols not supported + return ("https" == url.protocol || "http" == url.protocol) // Support only http/https, other protocols not supported +} + +fun isHostInDenylist(urlString: String, hostDenyList: List): Boolean { + val url = URL(urlString) + if (url.host != null) { + val ipStr = IPAddressString(url.host) + for (network in hostDenyList) { + val netStr = IPAddressString(network) + if (netStr.contains(ipStr)) { + return true + } + } + } + + return false } /** diff --git a/notifications/spi/src/test/kotlin/org/opensearch/notifications/spi/ChimeDestinationTests.kt b/notifications/spi/src/test/kotlin/org/opensearch/notifications/spi/ChimeDestinationTests.kt index b5efc43d..2eba8650 100644 --- a/notifications/spi/src/test/kotlin/org/opensearch/notifications/spi/ChimeDestinationTests.kt +++ b/notifications/spi/src/test/kotlin/org/opensearch/notifications/spi/ChimeDestinationTests.kt @@ -33,19 +33,20 @@ import org.apache.http.entity.StringEntity import org.apache.http.impl.client.CloseableHttpClient import org.apache.http.message.BasicStatusLine import org.easymock.EasyMock -import org.junit.Test +import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource import org.opensearch.notifications.spi.client.DestinationHttpClient -import org.opensearch.notifications.spi.factory.DestinationFactoryProvider -import org.opensearch.notifications.spi.factory.WebhookDestinationFactory import org.opensearch.notifications.spi.model.DestinationMessageResponse import org.opensearch.notifications.spi.model.MessageContent import org.opensearch.notifications.spi.model.destination.ChimeDestination import org.opensearch.notifications.spi.model.destination.DestinationType +import org.opensearch.notifications.spi.transport.DestinationTransportProvider +import org.opensearch.notifications.spi.transport.WebhookDestinationTransport import org.opensearch.rest.RestStatus import java.net.MalformedURLException import java.util.stream.Stream @@ -64,7 +65,6 @@ internal class ChimeDestinationTests { } @Test - @Throws(Exception::class) fun `test chime message null entity response`() { val mockHttpClient: CloseableHttpClient = EasyMock.createMock(CloseableHttpClient::class.java) @@ -83,26 +83,25 @@ internal class ChimeDestinationTests { EasyMock.replay(mockStatusLine) val httpClient = DestinationHttpClient(mockHttpClient) - val webhookDestinationFactory = WebhookDestinationFactory(httpClient) - DestinationFactoryProvider.destinationFactoryMap = mapOf(DestinationType.CHIME to webhookDestinationFactory) + val webhookDestinationTransport = WebhookDestinationTransport(httpClient) + DestinationTransportProvider.destinationTransportMap = mapOf(DestinationType.CHIME to webhookDestinationTransport) val title = "test Chime" val messageText = "Message gughjhjlkh Body emoji test: :) :+1: " + - "link test: http://sample.com email test: marymajor@example.com All member callout: " + - "@All All Present member callout: @Present" + "link test: http://sample.com email test: marymajor@example.com All member call out: " + + "@All All Present member call out: @Present" val url = "https://abc/com" val destination = ChimeDestination(url) val message = MessageContent(title, messageText) - val actualChimeResponse: DestinationMessageResponse = NotificationSpi.sendMessage(destination, message) + val actualChimeResponse: DestinationMessageResponse = NotificationSpi.sendMessage(destination, message, "referenceId") assertEquals(expectedWebhookResponse.statusText, actualChimeResponse.statusText) assertEquals(expectedWebhookResponse.statusCode, actualChimeResponse.statusCode) } @Test - @Throws(Exception::class) fun `test chime message empty entity response`() { val mockHttpClient: CloseableHttpClient = EasyMock.createMock(CloseableHttpClient::class.java) val expectedWebhookResponse = DestinationMessageResponse(RestStatus.OK.status, "") @@ -118,26 +117,25 @@ internal class ChimeDestinationTests { EasyMock.replay(mockStatusLine) val httpClient = DestinationHttpClient(mockHttpClient) - val webhookDestinationFactory = WebhookDestinationFactory(httpClient) - DestinationFactoryProvider.destinationFactoryMap = mapOf(DestinationType.CHIME to webhookDestinationFactory) + val webhookDestinationTransport = WebhookDestinationTransport(httpClient) + DestinationTransportProvider.destinationTransportMap = mapOf(DestinationType.CHIME to webhookDestinationTransport) val title = "test Chime" val messageText = "{\"Content\":\"Message gughjhjlkh Body emoji test: :) :+1: " + - "link test: http://sample.com email test: marymajor@example.com All member callout: " + - "@All All Present member callout: @Present\"}" + "link test: http://sample.com email test: marymajor@example.com All member call out: " + + "@All All Present member call out: @Present\"}" val url = "https://abc/com" val destination = ChimeDestination(url) val message = MessageContent(title, messageText) - val actualChimeResponse: DestinationMessageResponse = NotificationSpi.sendMessage(destination, message) + val actualChimeResponse: DestinationMessageResponse = NotificationSpi.sendMessage(destination, message, "referenceId") assertEquals(expectedWebhookResponse.statusText, actualChimeResponse.statusText) assertEquals(expectedWebhookResponse.statusCode, actualChimeResponse.statusCode) } @Test - @Throws(Exception::class) fun `test chime message non-empty entity response`() { val responseContent = "It worked!" val mockHttpClient: CloseableHttpClient = EasyMock.createMock(CloseableHttpClient::class.java) @@ -154,32 +152,30 @@ internal class ChimeDestinationTests { EasyMock.replay(mockStatusLine) val httpClient = DestinationHttpClient(mockHttpClient) - val webhookDestinationFactory = WebhookDestinationFactory(httpClient) - DestinationFactoryProvider.destinationFactoryMap = mapOf(DestinationType.CHIME to webhookDestinationFactory) + val webhookDestinationTransport = WebhookDestinationTransport(httpClient) + DestinationTransportProvider.destinationTransportMap = mapOf(DestinationType.CHIME to webhookDestinationTransport) val title = "test Chime" val messageText = "{\"Content\":\"Message gughjhjlkh Body emoji test: :) :+1: " + - "link test: http://sample.com email test: marymajor@example.com All member callout: " + - "@All All Present member callout: @Present\"}" + "link test: http://sample.com email test: marymajor@example.com All member call out: " + + "@All All Present member call out: @Present\"}" val url = "https://abc/com" val destination = ChimeDestination(url) val message = MessageContent(title, messageText) - val actualChimeResponse: DestinationMessageResponse = NotificationSpi.sendMessage(destination, message) + val actualChimeResponse: DestinationMessageResponse = NotificationSpi.sendMessage(destination, message, "referenceId") assertEquals(expectedWebhookResponse.statusText, actualChimeResponse.statusText) assertEquals(expectedWebhookResponse.statusCode, actualChimeResponse.statusCode) } - @Test(expected = IllegalArgumentException::class) - fun testUrlMissingMessage() { - try { + @Test + fun `test url missing should throw IllegalArgumentException with message`() { + val exception = Assertions.assertThrows(IllegalArgumentException::class.java) { ChimeDestination("") - } catch (ex: Exception) { - assertEquals("url is null or empty", ex.message) - throw ex } + assertEquals("url is null or empty", exception.message) } @Test @@ -189,14 +185,12 @@ internal class ChimeDestinationTests { } } - @Test(expected = IllegalArgumentException::class) - fun testContentMissingMessage() { - try { + @Test + fun `test content missing content should throw IllegalArgumentException`() { + val exception = Assertions.assertThrows(IllegalArgumentException::class.java) { MessageContent("title", "") - } catch (ex: Exception) { - assertEquals("text message part is null or empty", ex.message) - throw ex } + assertEquals("text message part is null or empty", exception.message) } @ParameterizedTest diff --git a/notifications/spi/src/test/kotlin/org/opensearch/notifications/spi/CustomWebhookDestinationTests.kt b/notifications/spi/src/test/kotlin/org/opensearch/notifications/spi/CustomWebhookDestinationTests.kt index c474837d..9361560d 100644 --- a/notifications/spi/src/test/kotlin/org/opensearch/notifications/spi/CustomWebhookDestinationTests.kt +++ b/notifications/spi/src/test/kotlin/org/opensearch/notifications/spi/CustomWebhookDestinationTests.kt @@ -36,19 +36,20 @@ import org.apache.http.entity.StringEntity import org.apache.http.impl.client.CloseableHttpClient import org.apache.http.message.BasicStatusLine import org.easymock.EasyMock -import org.junit.Test +import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource import org.opensearch.notifications.spi.client.DestinationHttpClient -import org.opensearch.notifications.spi.factory.DestinationFactoryProvider -import org.opensearch.notifications.spi.factory.WebhookDestinationFactory import org.opensearch.notifications.spi.model.DestinationMessageResponse import org.opensearch.notifications.spi.model.MessageContent import org.opensearch.notifications.spi.model.destination.CustomWebhookDestination import org.opensearch.notifications.spi.model.destination.DestinationType +import org.opensearch.notifications.spi.transport.DestinationTransportProvider +import org.opensearch.notifications.spi.transport.WebhookDestinationTransport import org.opensearch.rest.RestStatus import java.net.MalformedURLException import java.util.stream.Stream @@ -94,9 +95,9 @@ internal class CustomWebhookDestinationTests { EasyMock.replay(mockStatusLine) val httpClient = DestinationHttpClient(mockHttpClient) - val webhookDestinationFactory = WebhookDestinationFactory(httpClient) - DestinationFactoryProvider.destinationFactoryMap = mapOf( - DestinationType.CUSTOMWEBHOOK to webhookDestinationFactory + val webhookDestinationTransport = WebhookDestinationTransport(httpClient) + DestinationTransportProvider.destinationTransportMap = mapOf( + DestinationType.CUSTOM_WEBHOOK to webhookDestinationTransport ) val title = "test custom webhook" @@ -108,7 +109,7 @@ internal class CustomWebhookDestinationTests { val destination = CustomWebhookDestination(url, mapOf("headerKey" to "headerValue"), method) val message = MessageContent(title, messageText) - val actualCustomWebhookResponse: DestinationMessageResponse = NotificationSpi.sendMessage(destination, message) + val actualCustomWebhookResponse: DestinationMessageResponse = NotificationSpi.sendMessage(destination, message, "ref") assertEquals(expectedWebhookResponse.statusText, actualCustomWebhookResponse.statusText) assertEquals(expectedWebhookResponse.statusCode, actualCustomWebhookResponse.statusCode) @@ -116,7 +117,6 @@ internal class CustomWebhookDestinationTests { @ParameterizedTest(name = "method {0} should return corresponding type of Http request object {1}") @MethodSource("methodToHttpRequestType") - @Throws(Exception::class) fun `test custom webhook message empty entity response`(method: String, expectedHttpClass: Class) { val mockHttpClient: CloseableHttpClient = EasyMock.createMock(CloseableHttpClient::class.java) val expectedWebhookResponse = DestinationMessageResponse(RestStatus.OK.status, "") @@ -134,9 +134,9 @@ internal class CustomWebhookDestinationTests { EasyMock.replay(mockStatusLine) val httpClient = DestinationHttpClient(mockHttpClient) - val webhookDestinationFactory = WebhookDestinationFactory(httpClient) - DestinationFactoryProvider.destinationFactoryMap = mapOf( - DestinationType.CUSTOMWEBHOOK to webhookDestinationFactory + val webhookDestinationTransport = WebhookDestinationTransport(httpClient) + DestinationTransportProvider.destinationTransportMap = mapOf( + DestinationType.CUSTOM_WEBHOOK to webhookDestinationTransport ) val title = "test custom webhook" @@ -148,7 +148,7 @@ internal class CustomWebhookDestinationTests { val destination = CustomWebhookDestination(url, mapOf("headerKey" to "headerValue"), method) val message = MessageContent(title, messageText) - val actualCustomWebhookResponse: DestinationMessageResponse = NotificationSpi.sendMessage(destination, message) + val actualCustomWebhookResponse: DestinationMessageResponse = NotificationSpi.sendMessage(destination, message, "ref") assertEquals(expectedWebhookResponse.statusText, actualCustomWebhookResponse.statusText) assertEquals(expectedWebhookResponse.statusCode, actualCustomWebhookResponse.statusCode) @@ -156,7 +156,6 @@ internal class CustomWebhookDestinationTests { @ParameterizedTest(name = "method {0} should return corresponding type of Http request object {1}") @MethodSource("methodToHttpRequestType") - @Throws(Exception::class) fun `test custom webhook message non-empty entity response`( method: String, expectedHttpClass: Class @@ -177,9 +176,9 @@ internal class CustomWebhookDestinationTests { EasyMock.replay(mockStatusLine) val httpClient = DestinationHttpClient(mockHttpClient) - val webhookDestinationFactory = WebhookDestinationFactory(httpClient) - DestinationFactoryProvider.destinationFactoryMap = mapOf( - DestinationType.CUSTOMWEBHOOK to webhookDestinationFactory + val webhookDestinationTransport = WebhookDestinationTransport(httpClient) + DestinationTransportProvider.destinationTransportMap = mapOf( + DestinationType.CUSTOM_WEBHOOK to webhookDestinationTransport ) val title = "test custom webhook" @@ -191,53 +190,55 @@ internal class CustomWebhookDestinationTests { val destination = CustomWebhookDestination(url, mapOf("headerKey" to "headerValue"), method) val message = MessageContent(title, messageText) - val actualCustomWebhookResponse: DestinationMessageResponse = NotificationSpi.sendMessage(destination, message) + val actualCustomWebhookResponse: DestinationMessageResponse = NotificationSpi.sendMessage(destination, message, "ref") assertEquals(expectedWebhookResponse.statusText, actualCustomWebhookResponse.statusText) assertEquals(expectedWebhookResponse.statusCode, actualCustomWebhookResponse.statusCode) } - @Test(expected = IllegalArgumentException::class) + @ParameterizedTest(name = "method {0} should return corresponding type of Http request object {1}") + @MethodSource("methodToHttpRequestType") fun `Test missing url will throw exception`(method: String) { - try { + val exception = Assertions.assertThrows(IllegalArgumentException::class.java) { CustomWebhookDestination("", mapOf("headerKey" to "headerValue"), method) - } catch (ex: Exception) { - assertEquals("url is null or empty", ex.message) - throw ex } + assertEquals("url is null or empty", exception.message) } - @Test - fun testUrlInvalidMessage(method: String) { + @ParameterizedTest(name = "method {0} should return corresponding type of Http request object {1}") + @MethodSource("methodToHttpRequestType") + fun `Custom webhook should throw exception if url is invalid`(method: String) { assertThrows { CustomWebhookDestination("invalidUrl", mapOf("headerKey" to "headerValue"), method) } } - @Test(expected = IllegalArgumentException::class) + @ParameterizedTest(name = "method {0} should return corresponding type of Http request object {1}") + @MethodSource("methodToHttpRequestType") + fun `Custom webhook should throw exception if url protocol is not http or https`(method: String) { + val exception = Assertions.assertThrows(IllegalArgumentException::class.java) { + CustomWebhookDestination("ftp://abc/com", mapOf("headerKey" to "headerValue"), method) + } + assertEquals("Invalid URL or unsupported", exception.message) + } + + @Test fun `Test invalid method type will throw exception`() { - try { + val exception = Assertions.assertThrows(IllegalArgumentException::class.java) { CustomWebhookDestination("https://abc/com", mapOf("headerKey" to "headerValue"), "GET") - } catch (ex: Exception) { - assertEquals("Invalid method supplied. Only POST, PUT and PATCH are allowed", ex.message) - throw ex } + assertEquals("Invalid method supplied. Only POST, PUT and PATCH are allowed", exception.message) } - @ParameterizedTest - @MethodSource("escapeSequenceToRaw") - fun `test build request body for custom webhook should have title included and prevent escape`( - escapeSequence: String, - rawString: String - ) { + @Test + fun `test build request body for custom webhook`() { val httpClient = DestinationHttpClient() val title = "test custom webhook" - val messageText = "line1${escapeSequence}line2" + val messageText = "{\"Customized Key\":\"some content\"}" val url = "https://abc/com" - val expectedRequestBody = """{"Content":"$title\n\nline1${rawString}line2"}""" val destination = CustomWebhookDestination(url, mapOf("headerKey" to "headerValue"), "POST") val message = MessageContent(title, messageText) val actualRequestBody = httpClient.buildRequestBody(destination, message) - assertEquals(expectedRequestBody, actualRequestBody) + assertEquals(messageText, actualRequestBody) } } diff --git a/notifications/spi/src/test/kotlin/org/opensearch/notifications/spi/EmailDestinationTests.kt b/notifications/spi/src/test/kotlin/org/opensearch/notifications/spi/EmailDestinationTests.kt deleted file mode 100644 index 8e5f252f..00000000 --- a/notifications/spi/src/test/kotlin/org/opensearch/notifications/spi/EmailDestinationTests.kt +++ /dev/null @@ -1,120 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - * - * Modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -package org.opensearch.notifications.spi - -import io.mockk.every -import io.mockk.spyk -import junit.framework.Assert.assertEquals -import org.junit.Assert -import org.junit.Test -import org.junit.jupiter.api.extension.ExtendWith -import org.mockito.junit.jupiter.MockitoExtension -import org.opensearch.notifications.spi.client.DestinationEmailClient -import org.opensearch.notifications.spi.factory.DestinationFactoryProvider -import org.opensearch.notifications.spi.factory.SmtpEmailDestinationFactory -import org.opensearch.notifications.spi.model.DestinationMessageResponse -import org.opensearch.notifications.spi.model.MessageContent -import org.opensearch.notifications.spi.model.destination.DestinationType -import org.opensearch.notifications.spi.model.destination.EmailDestination -import org.opensearch.rest.RestStatus -import javax.mail.MessagingException - -@ExtendWith(MockitoExtension::class) -internal class EmailDestinationTests { - - @Test - @Throws(Exception::class) - fun testSmtpEmailMessage() { - val expectedEmailResponse = DestinationMessageResponse(RestStatus.OK.status, "Success") - val emailClient = spyk() - every { emailClient.sendMessage(any()) } returns Unit - - val smtpEmailDestinationFactory = SmtpEmailDestinationFactory(emailClient) - DestinationFactoryProvider.destinationFactoryMap = mapOf(DestinationType.SMTP to smtpEmailDestinationFactory) - - val subject = "Test SMTP Email subject" - val messageText = "{Message gughjhjlkh Body emoji test: :) :+1: " + - "link test: http://sample.com email test: marymajor@example.com All member callout: " + - "@All All Present member callout: @Present}" - val message = MessageContent(subject, messageText) - val destination = EmailDestination("abc", 465, "ssl", "test@abc.com", "to@abc.com", DestinationType.SMTP) - - val actualEmailResponse: DestinationMessageResponse = NotificationSpi.sendMessage(destination, message) - assertEquals(expectedEmailResponse.statusCode, actualEmailResponse.statusCode) - assertEquals(expectedEmailResponse.statusText, actualEmailResponse.statusText) - } - - @Test - @Throws(Exception::class) - fun testSmtpFailingEmailMessage() { - val expectedEmailResponse = DestinationMessageResponse( - RestStatus.FAILED_DEPENDENCY.status, - "Couldn't connect to host, port: localhost, 55555; timeout -1" - ) - val emailClient = spyk() - every { emailClient.sendMessage(any()) } throws MessagingException( - "Couldn't connect to host, port: localhost, 55555; timeout -1" - ) - - val smtpEmailDestinationFactory = SmtpEmailDestinationFactory(emailClient) - DestinationFactoryProvider.destinationFactoryMap = mapOf(DestinationType.SMTP to smtpEmailDestinationFactory) - - val subject = "Test SMTP Email subject" - val messageText = "{Vamshi Message gughjhjlkh Body emoji test: :) :+1: " + - "link test: http://sample.com email test: marymajor@example.com All member callout: " + - "@All All Present member callout: @Present}" - val message = MessageContent(subject, messageText) - val destination = EmailDestination( - "localhost", - 55555, - "none", - "test@abc.com", - "to@abc.com", - DestinationType.SMTP - ) - - val actualEmailResponse: DestinationMessageResponse = NotificationSpi.sendMessage(destination, message) - - assertEquals(expectedEmailResponse.statusCode, actualEmailResponse.statusCode) - assertEquals("sendEmail Error, status:${expectedEmailResponse.statusText}", actualEmailResponse.statusText) - } - - @Test(expected = IllegalArgumentException::class) - fun testHostMissingEmailDestination() { - try { - EmailDestination("", 465, "ssl", "from@test.com", "to@test.com", DestinationType.SMTP) - } catch (exception: Exception) { - Assert.assertEquals("Host name should be provided", exception.message) - throw exception - } - } - - @Test(expected = IllegalArgumentException::class) - fun testInvalidPortEmailDestination() { - try { - EmailDestination("localhost", -1, "ssl", "from@test.com", "to@test.com", DestinationType.SMTP) - } catch (exception: Exception) { - Assert.assertEquals("Port should be positive value", exception.message) - throw exception - } - } - - @Test(expected = IllegalArgumentException::class) - fun testMissingFromOrRecipientEmailDestination() { - try { - EmailDestination("localhost", 465, "ssl", "", "to@test.com", DestinationType.SMTP) - } catch (exception: Exception) { - Assert.assertEquals("FromAddress and recipient should be provided", exception.message) - throw exception - } - } -} diff --git a/notifications/spi/src/test/kotlin/org/opensearch/notifications/spi/SlackDestinationTests.kt b/notifications/spi/src/test/kotlin/org/opensearch/notifications/spi/SlackDestinationTests.kt index a6ff6a93..028e2f5c 100644 --- a/notifications/spi/src/test/kotlin/org/opensearch/notifications/spi/SlackDestinationTests.kt +++ b/notifications/spi/src/test/kotlin/org/opensearch/notifications/spi/SlackDestinationTests.kt @@ -33,20 +33,21 @@ import org.apache.http.entity.StringEntity import org.apache.http.impl.client.CloseableHttpClient import org.apache.http.message.BasicStatusLine import org.easymock.EasyMock -import org.junit.Test +import org.junit.jupiter.api.Assertions import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource import org.opensearch.notifications.spi.client.DestinationHttpClient -import org.opensearch.notifications.spi.factory.DestinationFactoryProvider -import org.opensearch.notifications.spi.factory.WebhookDestinationFactory import org.opensearch.notifications.spi.model.DestinationMessageResponse import org.opensearch.notifications.spi.model.MessageContent import org.opensearch.notifications.spi.model.destination.ChimeDestination import org.opensearch.notifications.spi.model.destination.DestinationType import org.opensearch.notifications.spi.model.destination.SlackDestination +import org.opensearch.notifications.spi.transport.DestinationTransportProvider +import org.opensearch.notifications.spi.transport.WebhookDestinationTransport import org.opensearch.rest.RestStatus import java.net.MalformedURLException import java.util.stream.Stream @@ -65,7 +66,6 @@ internal class SlackDestinationTests { } @Test - @Throws(Exception::class) fun `test Slack message null entity response`() { val mockHttpClient: CloseableHttpClient = EasyMock.createMock(CloseableHttpClient::class.java) @@ -84,8 +84,8 @@ internal class SlackDestinationTests { EasyMock.replay(mockStatusLine) val httpClient = DestinationHttpClient(mockHttpClient) - val webhookDestinationFactory = WebhookDestinationFactory(httpClient) - DestinationFactoryProvider.destinationFactoryMap = mapOf(DestinationType.SLACK to webhookDestinationFactory) + val webhookDestinationTransport = WebhookDestinationTransport(httpClient) + DestinationTransportProvider.destinationTransportMap = mapOf(DestinationType.SLACK to webhookDestinationTransport) val title = "test Slack" val messageText = "Message gughjhjlkh Body emoji test: :) :+1: " + @@ -96,14 +96,13 @@ internal class SlackDestinationTests { val destination = SlackDestination(url) val message = MessageContent(title, messageText) - val actualSlackResponse: DestinationMessageResponse = NotificationSpi.sendMessage(destination, message) + val actualSlackResponse: DestinationMessageResponse = NotificationSpi.sendMessage(destination, message, "ref") assertEquals(expectedWebhookResponse.statusText, actualSlackResponse.statusText) assertEquals(expectedWebhookResponse.statusCode, actualSlackResponse.statusCode) } @Test - @Throws(Exception::class) fun `test Slack message empty entity response`() { val mockHttpClient: CloseableHttpClient = EasyMock.createMock(CloseableHttpClient::class.java) val expectedWebhookResponse = DestinationMessageResponse(RestStatus.OK.status, "") @@ -119,8 +118,8 @@ internal class SlackDestinationTests { EasyMock.replay(mockStatusLine) val httpClient = DestinationHttpClient(mockHttpClient) - val webhookDestinationFactory = WebhookDestinationFactory(httpClient) - DestinationFactoryProvider.destinationFactoryMap = mapOf(DestinationType.SLACK to webhookDestinationFactory) + val webhookDestinationTransport = WebhookDestinationTransport(httpClient) + DestinationTransportProvider.destinationTransportMap = mapOf(DestinationType.SLACK to webhookDestinationTransport) val title = "test Slack" val messageText = "{\"Content\":\"Message gughjhjlkh Body emoji test: :) :+1: " + @@ -131,14 +130,13 @@ internal class SlackDestinationTests { val destination = SlackDestination(url) val message = MessageContent(title, messageText) - val actualSlackResponse: DestinationMessageResponse = NotificationSpi.sendMessage(destination, message) + val actualSlackResponse: DestinationMessageResponse = NotificationSpi.sendMessage(destination, message, "ref") assertEquals(expectedWebhookResponse.statusText, actualSlackResponse.statusText) assertEquals(expectedWebhookResponse.statusCode, actualSlackResponse.statusCode) } @Test - @Throws(Exception::class) fun `test Slack message non-empty entity response`() { val responseContent = "It worked!" val mockHttpClient: CloseableHttpClient = EasyMock.createMock(CloseableHttpClient::class.java) @@ -155,8 +153,8 @@ internal class SlackDestinationTests { EasyMock.replay(mockStatusLine) val httpClient = DestinationHttpClient(mockHttpClient) - val webhookDestinationFactory = WebhookDestinationFactory(httpClient) - DestinationFactoryProvider.destinationFactoryMap = mapOf(DestinationType.SLACK to webhookDestinationFactory) + val webhookDestinationTransport = WebhookDestinationTransport(httpClient) + DestinationTransportProvider.destinationTransportMap = mapOf(DestinationType.SLACK to webhookDestinationTransport) val title = "test Slack" val messageText = "{\"Content\":\"Message gughjhjlkh Body emoji test: :) :+1: " + @@ -167,20 +165,18 @@ internal class SlackDestinationTests { val destination = SlackDestination(url) val message = MessageContent(title, messageText) - val actualSlackResponse: DestinationMessageResponse = NotificationSpi.sendMessage(destination, message) + val actualSlackResponse: DestinationMessageResponse = NotificationSpi.sendMessage(destination, message, "ref") assertEquals(expectedWebhookResponse.statusText, actualSlackResponse.statusText) assertEquals(expectedWebhookResponse.statusCode, actualSlackResponse.statusCode) } - @Test(expected = IllegalArgumentException::class) - fun testUrlMissingMessage() { - try { + @Test + fun `test url missing should throw IllegalArgumentException with message`() { + val exception = Assertions.assertThrows(IllegalArgumentException::class.java) { SlackDestination("") - } catch (ex: Exception) { - assertEquals("url is null or empty", ex.message) - throw ex } + assertEquals("url is null or empty", exception.message) } @Test diff --git a/notifications/spi/src/test/kotlin/org/opensearch/notifications/spi/SmtpDestinationTests.kt b/notifications/spi/src/test/kotlin/org/opensearch/notifications/spi/SmtpDestinationTests.kt new file mode 100644 index 00000000..f4470321 --- /dev/null +++ b/notifications/spi/src/test/kotlin/org/opensearch/notifications/spi/SmtpDestinationTests.kt @@ -0,0 +1,139 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.notifications.spi + +import io.mockk.every +import io.mockk.spyk +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.junit.jupiter.MockitoExtension +import org.opensearch.common.settings.SecureString +import org.opensearch.notifications.spi.client.DestinationSmtpClient +import org.opensearch.notifications.spi.model.DestinationMessageResponse +import org.opensearch.notifications.spi.model.MessageContent +import org.opensearch.notifications.spi.model.SecureDestinationSettings +import org.opensearch.notifications.spi.model.destination.DestinationType +import org.opensearch.notifications.spi.model.destination.SmtpDestination +import org.opensearch.notifications.spi.transport.DestinationTransportProvider +import org.opensearch.notifications.spi.transport.SmtpDestinationTransport +import org.opensearch.rest.RestStatus +import javax.mail.MessagingException + +@ExtendWith(MockitoExtension::class) +internal class SmtpDestinationTests { + + @Test + fun testSmtpEmailMessage() { + val expectedEmailResponse = DestinationMessageResponse(RestStatus.OK.status, "Success") + val emailClient = spyk() + every { emailClient.sendMessage(any()) } returns Unit + + val smtpEmailDestinationTransport = SmtpDestinationTransport(emailClient) + DestinationTransportProvider.destinationTransportMap = mapOf(DestinationType.SMTP to smtpEmailDestinationTransport) + + val subject = "Test SMTP Email subject" + val messageText = "{Message gughjhjlkh Body emoji test: :) :+1: " + + "link test: http://sample.com email test: marymajor@example.com All member callout: " + + "@All All Present member callout: @Present}" + val message = MessageContent(subject, messageText) + val destination = SmtpDestination("testAccountName", "abc", 465, "ssl", "test@abc.com", "to@abc.com") + + val actualEmailResponse: DestinationMessageResponse = NotificationSpi.sendMessage(destination, message, "referenceId") + assertEquals(expectedEmailResponse.statusCode, actualEmailResponse.statusCode) + assertEquals(expectedEmailResponse.statusText, actualEmailResponse.statusText) + } + + @Test + fun `test auth email`() { + val expectedEmailResponse = DestinationMessageResponse(RestStatus.OK.status, "Success") + val emailClient = spyk() + every { emailClient.sendMessage(any()) } returns Unit + + val username = SecureString("user1".toCharArray()) + val password = SecureString("password".toCharArray()) + every { emailClient.getSecureDestinationSetting(any()) } returns SecureDestinationSettings(username, password) + + val smtpDestinationTransport = SmtpDestinationTransport(emailClient) + DestinationTransportProvider.destinationTransportMap = mapOf(DestinationType.SMTP to smtpDestinationTransport) + + val subject = "Test SMTP Email subject" + val messageText = "{Message gughjhjlkh Body emoji test: :) :+1: " + + "link test: http://sample.com email test: marymajor@example.com All member callout: " + + "@All All Present member callout: @Present}" + val message = MessageContent(subject, messageText) + val destination = SmtpDestination("testAccountName", "abc", 465, "ssl", "test@abc.com", "to@abc.com") + + val actualEmailResponse: DestinationMessageResponse = NotificationSpi.sendMessage(destination, message, "referenceId") + assertEquals(expectedEmailResponse.statusCode, actualEmailResponse.statusCode) + assertEquals(expectedEmailResponse.statusText, actualEmailResponse.statusText) + } + + @Test + fun testSmtpFailingEmailMessage() { + val expectedEmailResponse = DestinationMessageResponse( + RestStatus.FAILED_DEPENDENCY.status, + "Couldn't connect to host, port: localhost, 55555; timeout -1" + ) + val emailClient = spyk() + every { emailClient.sendMessage(any()) } throws MessagingException( + "Couldn't connect to host, port: localhost, 55555; timeout -1" + ) + + val smtpEmailDestinationTransport = SmtpDestinationTransport(emailClient) + DestinationTransportProvider.destinationTransportMap = mapOf(DestinationType.SMTP to smtpEmailDestinationTransport) + + val subject = "Test SMTP Email subject" + val messageText = "{Vamshi Message gughjhjlkh Body emoji test: :) :+1: " + + "link test: http://sample.com email test: marymajor@example.com All member callout: " + + "@All All Present member callout: @Present}" + val message = MessageContent(subject, messageText) + val destination = SmtpDestination( + "testAccountName", + "localhost", + 55555, + "none", + "test@abc.com", + "to@abc.com" + ) + + val actualEmailResponse: DestinationMessageResponse = NotificationSpi.sendMessage(destination, message, "referenceId") + + assertEquals(expectedEmailResponse.statusCode, actualEmailResponse.statusCode) + assertEquals("sendEmail Error, status:${expectedEmailResponse.statusText}", actualEmailResponse.statusText) + } + + @Test + fun testHostMissingEmailDestination() { + val exception = Assertions.assertThrows(IllegalArgumentException::class.java) { + SmtpDestination("testAccountName", "", 465, "ssl", "from@test.com", "to@test.com") + } + assertEquals("Host name should be provided", exception.message) + } + + @Test + fun testInvalidPortEmailDestination() { + val exception = Assertions.assertThrows(IllegalArgumentException::class.java) { + SmtpDestination("testAccountName", "localhost", -1, "ssl", "from@test.com", "to@test.com") + } + assertEquals("Port should be positive value", exception.message) + } + + @Test + fun testMissingFromOrRecipientEmailDestination() { + val exception = Assertions.assertThrows(IllegalArgumentException::class.java) { + SmtpDestination("testAccountName", "localhost", 465, "ssl", "", "to@test.com") + } + assertEquals("FromAddress and recipient should be provided", exception.message) + } +} diff --git a/notifications/spi/src/test/kotlin/org/opensearch/notifications/spi/integTest/SmtpEmailIT.kt b/notifications/spi/src/test/kotlin/org/opensearch/notifications/spi/integTest/SmtpEmailIT.kt index 4fa77e40..4ad2be5e 100644 --- a/notifications/spi/src/test/kotlin/org/opensearch/notifications/spi/integTest/SmtpEmailIT.kt +++ b/notifications/spi/src/test/kotlin/org/opensearch/notifications/spi/integTest/SmtpEmailIT.kt @@ -14,8 +14,7 @@ package org.opensearch.notifications.spi.integTest import org.junit.After import org.opensearch.notifications.spi.NotificationSpi import org.opensearch.notifications.spi.model.MessageContent -import org.opensearch.notifications.spi.model.destination.DestinationType -import org.opensearch.notifications.spi.model.destination.EmailDestination +import org.opensearch.notifications.spi.model.destination.SmtpDestination import org.opensearch.rest.RestStatus import org.opensearch.test.rest.OpenSearchRestTestCase import org.springframework.integration.test.mail.TestMailServer @@ -36,13 +35,13 @@ internal class SmtpEmailIT : OpenSearchRestTestCase() { } fun `test send email to one recipient over smtp server`() { - val emailDestination = EmailDestination( + val smtpDestination = SmtpDestination( + "testAccountName", "localhost", smtpPort, "none", "from@email.com", - "test@localhost.com", - DestinationType.SMTP + "test@localhost.com" ) val message = MessageContent( "Test smtp email title", @@ -53,19 +52,19 @@ internal class SmtpEmailIT : OpenSearchRestTestCase() { "VGVzdCBtZXNzYWdlCgo=", "application/octet-stream", ) - val response = NotificationSpi.sendMessage(emailDestination, message) + val response = NotificationSpi.sendMessage(smtpDestination, message, "ref") assertEquals("Success", response.statusText) assertEquals(RestStatus.OK.status, response.statusCode) } fun `test send email with non-available host`() { - val emailDestination = EmailDestination( + val smtpDestination = SmtpDestination( + "testAccountName", "invalidHost", smtpPort, "none", "from@email.com", - "test@localhost.com", - DestinationType.SMTP + "test@localhost.com" ) val message = MessageContent( "Test smtp email title", @@ -76,7 +75,7 @@ internal class SmtpEmailIT : OpenSearchRestTestCase() { "VGVzdCBtZXNzYWdlCgo=", "application/octet-stream", ) - val response = NotificationSpi.sendMessage(emailDestination, message) + val response = NotificationSpi.sendMessage(smtpDestination, message, "ref") assertEquals( "sendEmail Error, status:Couldn't connect to host, port: invalidHost, $smtpPort; timeout -1", response.statusText diff --git a/notifications/spi/src/test/kotlin/org/opensearch/notifications/spi/utils/ValidationHelpersTests.kt b/notifications/spi/src/test/kotlin/org/opensearch/notifications/spi/utils/ValidationHelpersTests.kt new file mode 100644 index 00000000..5198bae2 --- /dev/null +++ b/notifications/spi/src/test/kotlin/org/opensearch/notifications/spi/utils/ValidationHelpersTests.kt @@ -0,0 +1,51 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +package org.opensearch.notifications.spi.utils + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +internal class ValidationHelpersTests { + + private val hostDentyList = listOf( + "127.0.0.0/8", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "0.0.0.0/8", + "9.9.9.9" // ip + ) + + @Test + fun `test ips in denylist`() { + val ips = listOf( + "127.0.0.1", // 127.0.0.0/8 + "10.0.0.1", // 10.0.0.0/8 + "10.11.12.13", // 10.0.0.0/8 + "172.16.0.1", // "172.16.0.0/12" + "192.168.0.1", // 192.168.0.0/16" + "0.0.0.1", // 0.0.0.0/8 + "9.9.9.9" + ) + for (ip in ips) { + assertEquals(true, isHostInDenylist("https://$ip", hostDentyList)) + } + } + + @Test + fun `test url in denylist`() { + val urls = listOf("https://www.amazon.com", "https://mytest.com", "https://mytest.com") + for (url in urls) { + assertEquals(false, isHostInDenylist(url, hostDentyList)) + } + } +}