From 3d435dd9e04af90fcf59b3c5fc48786eec223520 Mon Sep 17 00:00:00 2001 From: Souvik Bose Date: Thu, 27 Jun 2024 17:55:59 -0700 Subject: [PATCH 1/8] Example CDK project for CloudWatch logs ingestion using OSI and CW subscription filter. --- .../osis_cwlogs_ingestion/.gitignore | 8 + .../osis_cwlogs_ingestion/.npmignore | 6 + .../bin/osis_cwlogs_ingestion.ts | 16 ++ .../opensearch/osis_cwlogs_ingestion/cdk.json | 55 ++++ .../lib/osis_cwlogs_subscription_stack.ts | 98 +++++++ .../lib/osis_os_setup_stack.ts | 240 ++++++++++++++++++ .../osis_cwlogs_ingestion/package.json | 32 +++ .../lambda/cw_subscription_filter/handler.py | 57 +++++ .../layers/requirements.txt | 3 + .../resources/lambda/log_emitter/handler.py | 15 ++ .../resources/pipeline/configuration.yaml | 15 ++ .../osis_cwlogs_ingestion/tsconfig.json | 23 ++ 12 files changed, 568 insertions(+) create mode 100644 typescript/opensearch/osis_cwlogs_ingestion/.gitignore create mode 100644 typescript/opensearch/osis_cwlogs_ingestion/.npmignore create mode 100644 typescript/opensearch/osis_cwlogs_ingestion/bin/osis_cwlogs_ingestion.ts create mode 100644 typescript/opensearch/osis_cwlogs_ingestion/cdk.json create mode 100644 typescript/opensearch/osis_cwlogs_ingestion/lib/osis_cwlogs_subscription_stack.ts create mode 100644 typescript/opensearch/osis_cwlogs_ingestion/lib/osis_os_setup_stack.ts create mode 100644 typescript/opensearch/osis_cwlogs_ingestion/package.json create mode 100644 typescript/opensearch/osis_cwlogs_ingestion/resources/lambda/cw_subscription_filter/handler.py create mode 100644 typescript/opensearch/osis_cwlogs_ingestion/resources/lambda/cw_subscription_filter/layers/requirements.txt create mode 100644 typescript/opensearch/osis_cwlogs_ingestion/resources/lambda/log_emitter/handler.py create mode 100644 typescript/opensearch/osis_cwlogs_ingestion/resources/pipeline/configuration.yaml create mode 100644 typescript/opensearch/osis_cwlogs_ingestion/tsconfig.json diff --git a/typescript/opensearch/osis_cwlogs_ingestion/.gitignore b/typescript/opensearch/osis_cwlogs_ingestion/.gitignore new file mode 100644 index 0000000000..f60797b6a9 --- /dev/null +++ b/typescript/opensearch/osis_cwlogs_ingestion/.gitignore @@ -0,0 +1,8 @@ +*.js +!jest.config.js +*.d.ts +node_modules + +# CDK asset staging directory +.cdk.staging +cdk.out diff --git a/typescript/opensearch/osis_cwlogs_ingestion/.npmignore b/typescript/opensearch/osis_cwlogs_ingestion/.npmignore new file mode 100644 index 0000000000..c1d6d45dcf --- /dev/null +++ b/typescript/opensearch/osis_cwlogs_ingestion/.npmignore @@ -0,0 +1,6 @@ +*.ts +!*.d.ts + +# CDK asset staging directory +.cdk.staging +cdk.out diff --git a/typescript/opensearch/osis_cwlogs_ingestion/bin/osis_cwlogs_ingestion.ts b/typescript/opensearch/osis_cwlogs_ingestion/bin/osis_cwlogs_ingestion.ts new file mode 100644 index 0000000000..efd098e21e --- /dev/null +++ b/typescript/opensearch/osis_cwlogs_ingestion/bin/osis_cwlogs_ingestion.ts @@ -0,0 +1,16 @@ +#!/usr/bin/env node +import "source-map-support/register"; +import { OsisOpenSearchSetupStack } from "../lib/osis_os_setup_stack"; +import { OsisCWLogsSubscriptionStack } from "../lib/osis_cwlogs_subscription_stack"; +import { App } from "aws-cdk-lib"; + +const app = new App(); +const osis_os_stack = new OsisOpenSearchSetupStack( + app, + "OsisOpenSearchSetupStack", + {}, +); + +new OsisCWLogsSubscriptionStack(app, "OsisCWLogsSubscriptionFilterStack", { + ingestionEndpointURL: osis_os_stack.ingestionEndPointURL, +}); \ No newline at end of file diff --git a/typescript/opensearch/osis_cwlogs_ingestion/cdk.json b/typescript/opensearch/osis_cwlogs_ingestion/cdk.json new file mode 100644 index 0000000000..bd125f4096 --- /dev/null +++ b/typescript/opensearch/osis_cwlogs_ingestion/cdk.json @@ -0,0 +1,55 @@ +{ + "app": "npx ts-node --prefer-ts-exts bin/osis_cwlogs_ingestion.ts", + "watch": { + "include": ["**"], + "exclude": [ + "README.md", + "cdk*.json", + "**/*.d.ts", + "**/*.js", + "tsconfig.json", + "package*.json", + "yarn.lock", + "node_modules", + "test" + ] + }, + "context": { + "@aws-cdk/aws-lambda:recognizeLayerVersion": true, + "@aws-cdk/core:checkSecretUsage": true, + "@aws-cdk/core:target-partitions": ["aws", "aws-cn"], + "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, + "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, + "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, + "@aws-cdk/aws-iam:minimizePolicies": true, + "@aws-cdk/core:validateSnapshotRemovalPolicy": true, + "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, + "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, + "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, + "@aws-cdk/aws-apigateway:disableCloudWatchRole": true, + "@aws-cdk/core:enablePartitionLiterals": true, + "@aws-cdk/aws-events:eventsTargetQueueSameAccount": true, + "@aws-cdk/aws-iam:standardizedServicePrincipals": true, + "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true, + "@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true, + "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true, + "@aws-cdk/aws-route53-patters:useCertificate": true, + "@aws-cdk/customresources:installLatestAwsSdkDefault": false, + "@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true, + "@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true, + "@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true, + "@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true, + "@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true, + "@aws-cdk/aws-redshift:columnId": true, + "@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2": true, + "@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true, + "@aws-cdk/aws-apigateway:requestValidatorUniqueId": true, + "@aws-cdk/aws-kms:aliasNameRef": true, + "@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": true, + "@aws-cdk/core:includePrefixInUniqueNameGeneration": true, + "@aws-cdk/aws-efs:denyAnonymousAccess": true, + "@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby": true, + "@aws-cdk/aws-lambda-nodejs:useLatestRuntimeVersion": true, + "@aws-cdk/aws-efs:mountTargetOrderInsensitiveLogicalId": true + } +} diff --git a/typescript/opensearch/osis_cwlogs_ingestion/lib/osis_cwlogs_subscription_stack.ts b/typescript/opensearch/osis_cwlogs_ingestion/lib/osis_cwlogs_subscription_stack.ts new file mode 100644 index 0000000000..73963e1d93 --- /dev/null +++ b/typescript/opensearch/osis_cwlogs_ingestion/lib/osis_cwlogs_subscription_stack.ts @@ -0,0 +1,98 @@ +import { Duration, Stack, StackProps } from 'aws-cdk-lib'; +import { Effect, PolicyStatement } from 'aws-cdk-lib/aws-iam'; +import { Code, Runtime, Function, Alias } from 'aws-cdk-lib/aws-lambda'; +import { Construct } from 'constructs'; +import { PythonLayerVersion } from '@aws-cdk/aws-lambda-python-alpha'; +import path = require('path'); +import { FilterPattern, LogGroup, RetentionDays, SubscriptionFilter } from 'aws-cdk-lib/aws-logs'; +import { Rule, Schedule } from 'aws-cdk-lib/aws-events'; +import { LambdaFunction } from 'aws-cdk-lib/aws-events-targets'; +import { LambdaDestination } from 'aws-cdk-lib/aws-logs-destinations'; + +export interface OsisCWLogsSubscriptionStackProps extends StackProps { + ingestionEndpointURL: string +} + +export class OsisCWLogsSubscriptionStack extends Stack { + + private readonly STACK_NAMING_PREFIX: string = 'osis-cwlogs-subscription'; + + constructor(scope: Construct, id: string, props: OsisCWLogsSubscriptionStackProps) { + super(scope, id, props); + + ///////////////////////////////////////////////////////////////////////////////// + // + // Create the Log Emitter Lambda resources + // + ///////////////////////////////////////////////////////////////////////////////// + const logGroup = new LogGroup(this, `EventBridgeTriggeredLambdaLogGroup`, { + retention: RetentionDays.ONE_WEEK, + }); + + // Lambda Function to publish message to SNS + const lambdaFn = new Function(this, 'Singleton', { + code: Code.fromAsset(path.join(__dirname, '../resources/lambda/log_emitter')), + handler: 'handler.log_emitter', + timeout: Duration.seconds(300), + runtime: Runtime.PYTHON_3_9, + logGroup: logGroup + }); + + // Run the eventbridge every 5 minute interval to generate logs + const rule = new Rule(this, 'Rule', { + schedule: Schedule.rate(Duration.minutes(5)) + }); + + // Add the lambda function as a target to the eventbridge + rule.addTarget(new LambdaFunction(lambdaFn)); + + + ///////////////////////////////////////////////////////////////////////////////// + // + // Create the CloudWatch Log group subscription filter resources + // + ///////////////////////////////////////////////////////////////////////////////// + + const lambdaLayer = new PythonLayerVersion(this, `${this.STACK_NAMING_PREFIX}LambdaLayer`, { + entry: path.join(__dirname, "../resources/lambda/cw_subscription_filter/layers"), + compatibleRuntimes: [ + Runtime.PYTHON_3_9, + Runtime.PYTHON_3_8, + ], + description: "A layer that contains the required modules", + license: "MIT License", + }); + + const lambdaFunction = new Function(this, `${this.STACK_NAMING_PREFIX}LambdaFunction`, { + runtime: Runtime.PYTHON_3_9, + code: Code.fromAsset(path.join(__dirname, '../resources/lambda/cw_subscription_filter')), + handler: 'handler.cw_subscription_handler', + layers: [lambdaLayer], + environment: { + OSI_INGESTION_ENDPOINT: props.ingestionEndpointURL, + }, + } + ); + + new Alias(this, `${this.STACK_NAMING_PREFIX}LambdaFunctionAlias`, { + aliasName: 'live', + version: lambdaFunction.currentVersion, + }); + + lambdaFunction.addToRolePolicy( + new PolicyStatement({ + effect: Effect.ALLOW, + resources: ['*'], + actions: ['osis:ingest'], + }), + ); + + // Create a Lambda Subscription Filter on the specific log group created above + const subscriptionFilter = new SubscriptionFilter(this, `${this.STACK_NAMING_PREFIX}LogSubscription`, { + logGroup: logGroup, + destination: new LambdaDestination(lambdaFunction), + filterPattern: FilterPattern.allEvents(), + }); + subscriptionFilter.node.addDependency(lambdaFunction); + } +} \ No newline at end of file diff --git a/typescript/opensearch/osis_cwlogs_ingestion/lib/osis_os_setup_stack.ts b/typescript/opensearch/osis_cwlogs_ingestion/lib/osis_os_setup_stack.ts new file mode 100644 index 0000000000..e5663420ff --- /dev/null +++ b/typescript/opensearch/osis_cwlogs_ingestion/lib/osis_os_setup_stack.ts @@ -0,0 +1,240 @@ +import { Fn, Stack, StackProps } from 'aws-cdk-lib'; +import { Peer, Port, SecurityGroup, Vpc } from 'aws-cdk-lib/aws-ec2'; +import { Effect, Policy, PolicyDocument, PolicyStatement, Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam'; +import { CfnAccessPolicy, CfnCollection, CfnSecurityPolicy, CfnVpcEndpoint } from 'aws-cdk-lib/aws-opensearchserverless'; +import { CfnPipeline } from 'aws-cdk-lib/aws-osis'; +import { Construct } from 'constructs'; +import { readFileSync } from "fs"; + +export class OsisOpenSearchSetupStack extends Stack { + + private readonly STACK_NAMING_PREFIX: string = 'osis-os-setup'; + private readonly COLLECTION_NAME: string = `${this.STACK_NAMING_PREFIX}-col`; + private readonly DATA_ACCESS_POLICY_NAME: string = `${this.STACK_NAMING_PREFIX}-data-pol`; + private readonly NETWORK_POLICY_NAME: string = `${this.STACK_NAMING_PREFIX}-net-pol`; + private readonly ENCRYPTION_POLICY_NAME: string = `${this.STACK_NAMING_PREFIX}-enc-pol`; + private readonly VPC_ENDPOINT_NAME: string = `${this.STACK_NAMING_PREFIX}-vpc`; + private readonly PIPELINE_NAME: string = `${this.STACK_NAMING_PREFIX}-pipe`; + + public readonly ingestionEndPointURL: string; + + constructor(scope: Construct, id: string, props?: StackProps) { + super(scope, id, props); + + // Create VPC + const vpc = new Vpc(this, `${this.STACK_NAMING_PREFIX}-vpc`); + + // Create Security Group + const securityGroup = new SecurityGroup(this, `${this.STACK_NAMING_PREFIX}-security-group`, { + description: 'Security group for OpenSearch', + vpc: vpc, + allowAllOutbound: true, + }); + + securityGroup.addIngressRule(Peer.anyIpv4(), Port.tcp(443)); + securityGroup.connections.allowFrom( + securityGroup, + Port.allTraffic(), + 'Allow ingress from the same SecurityGroup', + ); + + // Create VPC Endpoint + const vpcEndpoint = new CfnVpcEndpoint(this, `${this.STACK_NAMING_PREFIX}VpcEndpoint`, { + name: this.VPC_ENDPOINT_NAME, + vpcId: vpc.vpcId, + subnetIds: vpc.privateSubnets.map((subnet) => subnet.subnetId), + securityGroupIds: [securityGroup.securityGroupId], + }); + + // Create OpenSearch Serverless network security policy + const cfnNetworkAccessPolicy = new CfnSecurityPolicy(this, `${this.STACK_NAMING_PREFIX}NetworkPolicy`, { + name: this.NETWORK_POLICY_NAME, + type: 'network', + policy: JSON.stringify([ + { + AllowFromPublic: false, + Rules: [ + { + ResourceType: 'collection', + Resource: [`collection/${this.COLLECTION_NAME}`], + }, + ], + SourceVPCEs: [vpcEndpoint.attrId], + }, + { + AllowFromPublic: true, + Rules: [ + { + ResourceType: 'dashboard', + Resource: [`collection/${this.COLLECTION_NAME}`], + }, + ], + }, + ]), + }); + + // Create OpenSearch Serverless encryption policy + const cfnEncryptionPolicy = new CfnSecurityPolicy(this, `${this.STACK_NAMING_PREFIX}EncryptionPolicy`, { + name: this.ENCRYPTION_POLICY_NAME, + type: 'encryption', + policy: JSON.stringify({ + Rules: [ + { + ResourceType: 'collection', + Resource: [`collection/${this.COLLECTION_NAME}`], + } + ], + AWSOwnedKey: true + }) + }); + + // Create OpenSearch Serverless collection + const cfnCollection = new CfnCollection( + this, + `${this.COLLECTION_NAME}Collection`, + { + name: this.COLLECTION_NAME, + description: 'OpenSearch serverless collection to be used for search from CDK', + type: 'SEARCH', + }, + ); + + cfnCollection.addDependency(cfnEncryptionPolicy); + cfnCollection.addDependency(cfnNetworkAccessPolicy); + + // Create IAM role for OpenSearch Ingestion pipeline + const pipelineRole = new Role(this, `${this.STACK_NAMING_PREFIX}PipelineRole`, { + roleName: `${this.STACK_NAMING_PREFIX}PipelineRole`, + assumedBy: new ServicePrincipal('osis-pipelines.amazonaws.com'), + inlinePolicies: { + 'OSISPipelineRolePolicy': this.pipelinePolicies(cfnCollection.attrArn) + } + }); + + // Create OpenSearch Ingestion pipeline + const cfnPipeline = new CfnPipeline(this, `${this.STACK_NAMING_PREFIX}Pipeline`, { + maxUnits: 4, + minUnits: 1, + pipelineConfigurationBody: this.getPipelineConfiguration(pipelineRole.roleArn, cfnCollection.attrCollectionEndpoint), + pipelineName: this.PIPELINE_NAME, + }); + + this.ingestionEndPointURL = Fn.select(0, cfnPipeline.attrIngestEndpointUrls); + + cfnPipeline.addDependency(cfnCollection); + + // Create a dashboard access role + const dashboardAccessRole = new Role(this, `${this.STACK_NAMING_PREFIX}DashboardAccessRole`, { + assumedBy: new ServicePrincipal('ec2.amazonaws.com'), + }); + + dashboardAccessRole.attachInlinePolicy( + new Policy(this, `${this.STACK_NAMING_PREFIX}DashboardAccessPolicy`, { + statements: [ + new PolicyStatement({ + effect: Effect.ALLOW, + resources: ['*'], + actions: ['aoss:*'], + }), + ], + }), + ); + + // Create OpenSearch Serverless data access policy + const data_access_policy_arns: string[] = [pipelineRole.roleArn, dashboardAccessRole.roleArn]; + + const cfnDataAccessPolicy = new CfnAccessPolicy(this, `${this.STACK_NAMING_PREFIX}AccessPolicy`, { + name: this.DATA_ACCESS_POLICY_NAME, + type: 'data', + policy: JSON.stringify([ + { + Rules: [ + { + ResourceType: 'index', + Resource: [`index/${this.COLLECTION_NAME}/*`], + Permission: [ + 'aoss:CreateIndex', + 'aoss:DescribeIndex', + 'aoss:ReadDocument', + 'aoss:WriteDocument', + 'aoss:UpdateIndex', + 'aoss:DeleteIndex', + ], + }, + { + ResourceType: 'collection', + Resource: [`collection/${this.COLLECTION_NAME}`], + Permission: [ + 'aoss:CreateCollectionItems', + 'aoss:DeleteCollectionItems', + 'aoss:UpdateCollectionItems', + 'aoss:DescribeCollectionItems', + ], + }, + ], + Principal: data_access_policy_arns, + }, + ]), + }); + + cfnDataAccessPolicy.addDependency(cfnCollection); + cfnDataAccessPolicy.addDependency(cfnPipeline); + } + + pipelinePolicies(collectionArn: string) { + const policyDocument = new PolicyDocument(); + policyDocument.addStatements( + new PolicyStatement({ + 'effect': Effect.ALLOW, + "resources": ["*"], + "actions": [ + "aoss:BatchGetCollection" + ] + })); + policyDocument.addStatements( + new PolicyStatement({ + 'effect': Effect.ALLOW, + "resources": [collectionArn], + "actions": [ + "aoss:APIAccessAll" + ] + })); + policyDocument.addStatements( + new PolicyStatement({ + 'effect': Effect.ALLOW, + "resources": [`arn:aws:aoss:*:${this.account}:dashboards/default`], + "actions": [ + "aoss:DashboardsAccessAll" + ] + })); + policyDocument.addStatements( + new PolicyStatement({ + 'effect': Effect.ALLOW, + "resources": ["*"], + "actions": [ + "aoss:CreateSecurityPolicy", + "aoss:GetSecurityPolicy", + "aoss:UpdateSecurityPolicy" + ], + "conditions": { + "StringEquals": { + "aoss:collection": this.COLLECTION_NAME + } + } + })); + return policyDocument; + } + + getPipelineConfiguration(roleArn: string, collectionEndPoint: string) { + let pipelineConfigurationTemplate = readFileSync('resources/pipeline/configuration.yaml').toString() + + const formattedPipelineConfiguration = + pipelineConfigurationTemplate + .replace('', collectionEndPoint) + .replace('', roleArn) + .replace('', this.region) + .replace('', this.NETWORK_POLICY_NAME); + + return formattedPipelineConfiguration; + } +} \ No newline at end of file diff --git a/typescript/opensearch/osis_cwlogs_ingestion/package.json b/typescript/opensearch/osis_cwlogs_ingestion/package.json new file mode 100644 index 0000000000..be42d0125a --- /dev/null +++ b/typescript/opensearch/osis_cwlogs_ingestion/package.json @@ -0,0 +1,32 @@ +{ + "name": "osis_cwlogs_ingestion", + "version": "0.1.0", + "bin": { + "osis_cwlogs_ingestion": "bin/osis_cwlogs_ingestion.js" + }, + "scripts": { + "clean": "rm -rf build dist cdk.out node_modules", + "build": "tsc", + "watch": "tsc -w", + "cdk": "cdk" + }, + "devDependencies": { + "@types/jest": "^29.5.4", + "@types/node": "20.5.9", + "aws-cdk": "^2.14.0", + "aws-cdk-lib": "^2.14.0", + "constructs": "^10.2.43", + "globals": "^15.6.0", + "jest": "^29.6.4", + "ts-jest": "^29.1.1", + "ts-node": "^10.9.1", + "typescript": "~5.2.2" + }, + "dependencies": { + "@aws-cdk/aws-lambda-python-alpha": "*", + "aws-cdk": "^2.102.0", + "aws-cdk-lib": "^2.102.0", + "constructs": "^10.0.0", + "source-map-support": "^0.5.21" + } +} diff --git a/typescript/opensearch/osis_cwlogs_ingestion/resources/lambda/cw_subscription_filter/handler.py b/typescript/opensearch/osis_cwlogs_ingestion/resources/lambda/cw_subscription_filter/handler.py new file mode 100644 index 0000000000..b0a4090e33 --- /dev/null +++ b/typescript/opensearch/osis_cwlogs_ingestion/resources/lambda/cw_subscription_filter/handler.py @@ -0,0 +1,57 @@ +import base64 +import gzip +import logging +import json +import jmespath +import requests +import os +from datetime import datetime +from requests_auth_aws_sigv4 import AWSSigV4 + +def cw_subscription_handler(event, context): + + """Extract the data from the event""" + data = jmespath.search("awslogs.data", event) + + """Decompress the logs""" + cwLogs = decompress_json_data(data) + + """Construct the payload to send to OpenSearch Ingestion""" + payload = prepare_payload(cwLogs) + + """Ingest the set of events to the pipeline""" + response = ingestData(payload) + + return { + 'statusCode': 200, + 'execute-api': { + 'status_code': response.status_code, + 'response': response.text + } + } +def decompress_json_data(data): + compressed_data = base64.b64decode(data) + uncompressed_data = gzip.decompress(compressed_data) + return json.loads(uncompressed_data) + +def prepare_payload(cwLogs): + payload = [] + logEvents = cwLogs['logEvents'] + for logEvent in logEvents: + request = {} + request['@id'] = logEvent['id'] + request['@timestamp'] = str(datetime.now().year) + '0' + str(datetime.now().month) + '0' + str(datetime.now().day) + request['@message'] = logEvent['message'] + request['@owner'] = cwLogs['owner'] + request['@log_group'] = cwLogs['logGroup'] + request['@log_stream'] = cwLogs['logStream'] + + payload.append(request) + return payload + +def ingestData(payload): + ingestionEndpoint = os.environ["OSI_INGESTION_ENDPOINT"] + endpoint = 'https://' + ingestionEndpoint + response = requests.request('POST', f'{endpoint}/logs/ingest', data=json.dumps(payload), auth=AWSSigV4('osis')) + print('Response received: ' + response.text) + return response \ No newline at end of file diff --git a/typescript/opensearch/osis_cwlogs_ingestion/resources/lambda/cw_subscription_filter/layers/requirements.txt b/typescript/opensearch/osis_cwlogs_ingestion/resources/lambda/cw_subscription_filter/layers/requirements.txt new file mode 100644 index 0000000000..7bfeb86a6c --- /dev/null +++ b/typescript/opensearch/osis_cwlogs_ingestion/resources/lambda/cw_subscription_filter/layers/requirements.txt @@ -0,0 +1,3 @@ +boto3 +requests +requests_auth_aws_sigv4 \ No newline at end of file diff --git a/typescript/opensearch/osis_cwlogs_ingestion/resources/lambda/log_emitter/handler.py b/typescript/opensearch/osis_cwlogs_ingestion/resources/lambda/log_emitter/handler.py new file mode 100644 index 0000000000..586b0fb44b --- /dev/null +++ b/typescript/opensearch/osis_cwlogs_ingestion/resources/lambda/log_emitter/handler.py @@ -0,0 +1,15 @@ +from datetime import datetime +import json +from random import randrange + +def log_emitter(event, context): + source = {} + id = str(randrange(10000)) + source['id'] = id + source['timestamp'] = datetime.now() + source['message'] = 'Hello world' + source['owner'] = 'aws-osi' + + print(json.dumps(source)) + + diff --git a/typescript/opensearch/osis_cwlogs_ingestion/resources/pipeline/configuration.yaml b/typescript/opensearch/osis_cwlogs_ingestion/resources/pipeline/configuration.yaml new file mode 100644 index 0000000000..286eaf010e --- /dev/null +++ b/typescript/opensearch/osis_cwlogs_ingestion/resources/pipeline/configuration.yaml @@ -0,0 +1,15 @@ +version: "2" +cwlogs-ingestion-http-pipeline: + source: + http: + path: /logs/ingest + sink: + - opensearch: + hosts: [""] + aws: + sts_role_arn: "" + region: "" + serverless: true + serverless_options: + network_policy_name: "" + index: "cwl-%{yyyy-MM-dd}" diff --git a/typescript/opensearch/osis_cwlogs_ingestion/tsconfig.json b/typescript/opensearch/osis_cwlogs_ingestion/tsconfig.json new file mode 100644 index 0000000000..464ed774ba --- /dev/null +++ b/typescript/opensearch/osis_cwlogs_ingestion/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["es2020", "dom"], + "declaration": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": false, + "inlineSourceMap": true, + "inlineSources": true, + "experimentalDecorators": true, + "strictPropertyInitialization": false, + "typeRoots": ["./node_modules/@types"] + }, + "exclude": ["node_modules", "cdk.out"] +} From 9e9394415e1efc9d351a5fbf436fa4a4b6da9735 Mon Sep 17 00:00:00 2001 From: Souvik Bose Date: Mon, 1 Jul 2024 10:49:36 -0700 Subject: [PATCH 2/8] Rename the resources and add README Signed-off-by: Souvik Bose --- .../.gitignore | 0 .../.npmignore | 0 .../opensearch/cwlogs_ingestion/README.md | 70 +++++++++++++ .../bin/cwlogs_ingestion_stack.ts | 16 +++ .../cdk.json | 2 +- .../cwlogs_ingestion/docs/architecture.png | Bin 0 -> 35957 bytes .../cwlogs_ingestion/docs/architecture.xml | 1 + .../lib/cwlogs_subscription_stack.ts | 99 ++++++++++++++++++ .../lib/os_setup_stack.ts} | 30 +++--- .../package.json | 6 +- .../lambda/cw_subscription_filter/handler.py | 0 .../layers/requirements.txt | 0 .../resources/lambda/log_emitter/handler.py | 2 +- .../resources/pipeline/configuration.yaml | 0 .../tsconfig.json | 0 .../bin/osis_cwlogs_ingestion.ts | 16 --- .../lib/osis_cwlogs_subscription_stack.ts | 98 ----------------- 17 files changed, 208 insertions(+), 132 deletions(-) rename typescript/opensearch/{osis_cwlogs_ingestion => cwlogs_ingestion}/.gitignore (100%) rename typescript/opensearch/{osis_cwlogs_ingestion => cwlogs_ingestion}/.npmignore (100%) create mode 100644 typescript/opensearch/cwlogs_ingestion/README.md create mode 100644 typescript/opensearch/cwlogs_ingestion/bin/cwlogs_ingestion_stack.ts rename typescript/opensearch/{osis_cwlogs_ingestion => cwlogs_ingestion}/cdk.json (97%) create mode 100644 typescript/opensearch/cwlogs_ingestion/docs/architecture.png create mode 100644 typescript/opensearch/cwlogs_ingestion/docs/architecture.xml create mode 100644 typescript/opensearch/cwlogs_ingestion/lib/cwlogs_subscription_stack.ts rename typescript/opensearch/{osis_cwlogs_ingestion/lib/osis_os_setup_stack.ts => cwlogs_ingestion/lib/os_setup_stack.ts} (90%) rename typescript/opensearch/{osis_cwlogs_ingestion => cwlogs_ingestion}/package.json (73%) rename typescript/opensearch/{osis_cwlogs_ingestion => cwlogs_ingestion}/resources/lambda/cw_subscription_filter/handler.py (100%) rename typescript/opensearch/{osis_cwlogs_ingestion => cwlogs_ingestion}/resources/lambda/cw_subscription_filter/layers/requirements.txt (100%) rename typescript/opensearch/{osis_cwlogs_ingestion => cwlogs_ingestion}/resources/lambda/log_emitter/handler.py (86%) rename typescript/opensearch/{osis_cwlogs_ingestion => cwlogs_ingestion}/resources/pipeline/configuration.yaml (100%) rename typescript/opensearch/{osis_cwlogs_ingestion => cwlogs_ingestion}/tsconfig.json (100%) delete mode 100644 typescript/opensearch/osis_cwlogs_ingestion/bin/osis_cwlogs_ingestion.ts delete mode 100644 typescript/opensearch/osis_cwlogs_ingestion/lib/osis_cwlogs_subscription_stack.ts diff --git a/typescript/opensearch/osis_cwlogs_ingestion/.gitignore b/typescript/opensearch/cwlogs_ingestion/.gitignore similarity index 100% rename from typescript/opensearch/osis_cwlogs_ingestion/.gitignore rename to typescript/opensearch/cwlogs_ingestion/.gitignore diff --git a/typescript/opensearch/osis_cwlogs_ingestion/.npmignore b/typescript/opensearch/cwlogs_ingestion/.npmignore similarity index 100% rename from typescript/opensearch/osis_cwlogs_ingestion/.npmignore rename to typescript/opensearch/cwlogs_ingestion/.npmignore diff --git a/typescript/opensearch/cwlogs_ingestion/README.md b/typescript/opensearch/cwlogs_ingestion/README.md new file mode 100644 index 0000000000..0c660dafeb --- /dev/null +++ b/typescript/opensearch/cwlogs_ingestion/README.md @@ -0,0 +1,70 @@ +## Amazon OpenSearch Ingestion + +## + +![Stability: Stable](https://img.shields.io/badge/stability-Stable-success.svg?style=for-the-badge) + +> **This is a stable example. It should successfully build out of the box** +> +> This example is built on Construct Libraries marked "Stable" and does not have any infrastructure prerequisites to build. + +--- + + + +## Overview + +[Amazon OpenSearch Ingestion](https://docs.aws.amazon.com/opensearch-service/latest/developerguide/ingestion.html) is a fully managed, serverless data collector that delivers real-time log, metric, and trace data to [Amazon OpenSearch Service domains](https://docs.aws.amazon.com/opensearch-service/latest/developerguide/what-is.html) and [Amazon OpenSearch Serverless collections](https://docs.aws.amazon.com/opensearch-service/latest/developerguide/serverless.html). + +In this example, we show how to use the [AWS Cloud Development Kit (CDK)](https://docs.aws.amazon.com/cdk/v2/guide/home.html) to set up an Amazon OpenSearch Ingestion Pipeline to ingest CloudWatch logs using a [CloudWatch subscription filter]() and write to Amazon OpenSearch Serverless collection in an [Amazon Virtual Private Cloud (VPC)](https://aws.amazon.com/vpc/). The pipeline is setup to receive log events on `/logs/ingest` path using the OpenSearch Ingestion pipeline API endpoint. + +![](docs/architecture.png) +_figure1.Architecture Diagram of loading CloudWatch logs into an OpenSearch Serverless collection using an OpenSearch Ingestion pipeline_ + +The CDK stack sets up roles and permissions to enable all of the services to communicate with one-another. It further provides access for the deploying user's IAM identity. Finally, the stack sets up a VPC, and creates a VPC endpoint for communication to OpenSearch Serverless (see below for configuration). + +### Configuration of the code + +To configure the solution for your account, visit [cwlogs_ingestion_stack](./cwlogs_ingestion/bin/cwlogs_ingestion_stack.ts). There are 2 stacks that are created here: +- `OpenSearchSetupStack` +- `CWLogsSubscriptionStack` + +You can modify the names of the resources in `OpenSearchSetupStack` stack. Below are default values: + +``` +STACK_NAMING_PREFIX = 'cw-to-os' + +STACK_RESOURCE_NAMING_PREFIX = 'OpenSearchSetup' + +COLLECTION_NAME = '${this.STACK_NAMING_PREFIX}-col' +PIPELINE_NAME = '${this.STACK_NAMING_PREFIX}-pipe' +NETWORK_POLICY_NAME = '${this.STACK_NAMING_PREFIX}-net-pol' +ENCRYPTION_POLICY_NAME = '${this.STACK_NAMING_PREFIX}-enc-pol' +DATA_ACCESS_POLICY_NAME = '${this.STACK_NAMING_PREFIX}-data-pol' +``` + +For, `CWLogsSubscriptionStack` stack, visit [cwlogs_subscription_stack](./cwlogs_ingestion/lib/cwlogs_subscription_stack.ts). This stack deploys the following resources: + +- `CWLogsSubscriptionLogEmitter` lambda to publish logs to CloudWatch which is invoked every 5 minutes triggered using an EventBridge timer. +- Lambda Subscription Filter for CloudWatch logs published by the `CWLogsSubscriptionLogEmitter` lambda resource + +To view data in your OpenSearch Serverless Collection navigate to the Amazon OpenSearch Service console. In the left navigation pane click the reveal triangle if it's not already open. Click **Collections**. Click **cw-to-os-col** (or find your **COLLECTION_NAME** if you changed it). Scroll down until you see the **Endpoint** section, and click the URL under **OpenSearch Dashboards URL**. This will launch OpenSearch Dashboards. + +In OpenSearch Dashboards, dismiss the initial splash screen. In the upper right, find the **Manage** link, and click it. Click **Index Patterns** in the left navigation pane. Click **Create index pattern**. and type `ddb-to-aoss-*` into the **Index pattern name** text box. Click **Next step**. Drop down the **Time field** menu, and select `@timestamp`. Click **Create index pattern**. + +## Build and Deploy + +The `cdk.json` file tells the CDK Toolkit how to execute your app. + +## Synthesize Cloudformation Template + +To see the Cloudformation template generated by the CDK, run `cdk synth`, then check the output file in the "cdk.out" directory. + +## Deploy + +- Run `cdk deploy OpenSearchSetupStack` to deploy OpenSearch Serverless collection, OpenSearch Ingestion Pipeline resources to your personal account. +- Run `cdk deploy CWLogsSubscriptionStack` to deploy CloudWatch logs subscription filter lambda resources to your personal account. + +## CDK Destroy + +If no longer want the stack to be running, you can destroy the stack by running `cdk destroy` diff --git a/typescript/opensearch/cwlogs_ingestion/bin/cwlogs_ingestion_stack.ts b/typescript/opensearch/cwlogs_ingestion/bin/cwlogs_ingestion_stack.ts new file mode 100644 index 0000000000..5c02d2b2c4 --- /dev/null +++ b/typescript/opensearch/cwlogs_ingestion/bin/cwlogs_ingestion_stack.ts @@ -0,0 +1,16 @@ +#!/usr/bin/env node +import "source-map-support/register"; +import { OpenSearchSetupStack } from "../lib/os_setup_stack"; +import { CWLogsSubscriptionStack } from "../lib/cwlogs_subscription_stack"; +import { App } from "aws-cdk-lib"; + +const app = new App(); +const opensearch_stack = new OpenSearchSetupStack( + app, + "OpenSearchSetupStack", + {}, +); + +new CWLogsSubscriptionStack(app, "CWLogsSubscriptionFilterStack", { + ingestionEndpointURL: opensearch_stack.ingestionEndPointURL, +}); \ No newline at end of file diff --git a/typescript/opensearch/osis_cwlogs_ingestion/cdk.json b/typescript/opensearch/cwlogs_ingestion/cdk.json similarity index 97% rename from typescript/opensearch/osis_cwlogs_ingestion/cdk.json rename to typescript/opensearch/cwlogs_ingestion/cdk.json index bd125f4096..ee1a49130f 100644 --- a/typescript/opensearch/osis_cwlogs_ingestion/cdk.json +++ b/typescript/opensearch/cwlogs_ingestion/cdk.json @@ -1,5 +1,5 @@ { - "app": "npx ts-node --prefer-ts-exts bin/osis_cwlogs_ingestion.ts", + "app": "npx ts-node --prefer-ts-exts bin/cwlogs_ingestion_stack.ts", "watch": { "include": ["**"], "exclude": [ diff --git a/typescript/opensearch/cwlogs_ingestion/docs/architecture.png b/typescript/opensearch/cwlogs_ingestion/docs/architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..090fe68442ecac51a8a5a38866d879409b01f2ce GIT binary patch literal 35957 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 IA^QIR4-|K~>;M1& literal 0 HcmV?d00001 diff --git a/typescript/opensearch/cwlogs_ingestion/docs/architecture.xml b/typescript/opensearch/cwlogs_ingestion/docs/architecture.xml new file mode 100644 index 0000000000..6bf0520a0b --- /dev/null +++ b/typescript/opensearch/cwlogs_ingestion/docs/architecture.xml @@ -0,0 +1 @@ +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 \ No newline at end of file diff --git a/typescript/opensearch/cwlogs_ingestion/lib/cwlogs_subscription_stack.ts b/typescript/opensearch/cwlogs_ingestion/lib/cwlogs_subscription_stack.ts new file mode 100644 index 0000000000..067a2db8cf --- /dev/null +++ b/typescript/opensearch/cwlogs_ingestion/lib/cwlogs_subscription_stack.ts @@ -0,0 +1,99 @@ +import { PythonLayerVersion } from '@aws-cdk/aws-lambda-python-alpha'; +import { Duration, Stack, StackProps } from 'aws-cdk-lib'; +import { Rule, Schedule } from 'aws-cdk-lib/aws-events'; +import { LambdaFunction } from 'aws-cdk-lib/aws-events-targets'; +import { Effect, PolicyStatement } from 'aws-cdk-lib/aws-iam'; +import { Code, Runtime, Function, Alias } from 'aws-cdk-lib/aws-lambda'; +import { FilterPattern, LogGroup, RetentionDays, SubscriptionFilter } from 'aws-cdk-lib/aws-logs'; +import { LambdaDestination } from 'aws-cdk-lib/aws-logs-destinations'; +import { Construct } from 'constructs'; +import path = require('path'); + +export interface CWLogsSubscriptionStackProps extends StackProps { + ingestionEndpointURL: string +} + +export class CWLogsSubscriptionStack extends Stack { + + private readonly STACK_NAMING_PREFIX: string = 'cwlogs-subscription'; + + constructor(scope: Construct, id: string, props: CWLogsSubscriptionStackProps) { + super(scope, id, props); + + ///////////////////////////////////////////////////////////////////////////////// + // + // Create the Log Emitter Lambda resources + // + ///////////////////////////////////////////////////////////////////////////////// + const logGroup = new LogGroup(this, `EventBridgeTriggeredLambdaLogGroup`, { + retention: RetentionDays.ONE_WEEK, + }); + + // Lambda Function to publish message + const lambdaFn = new Function(this, 'EventBridgeTriggeredLambdaFunction', { + code: Code.fromAsset(path.join(__dirname, '../resources/lambda/log_emitter')), + handler: 'handler.log_emitter', + timeout: Duration.seconds(300), + runtime: Runtime.PYTHON_3_9, + logGroup: logGroup + }); + + // Run the eventbridge every 5 minute interval to generate logs + const rule = new Rule(this, 'Rule', { + schedule: Schedule.rate(Duration.minutes(5)) + }); + + // Add the lambda function as a target to the eventbridge + rule.addTarget(new LambdaFunction(lambdaFn)); + + + ///////////////////////////////////////////////////////////////////////////////// + // + // Create the CloudWatch Log group subscription filter resources + // + ///////////////////////////////////////////////////////////////////////////////// + + const lambdaLayer = new PythonLayerVersion(this, `${this.STACK_NAMING_PREFIX}LambdaLayer`, { + entry: path.join(__dirname, "../resources/lambda/cw_subscription_filter/layers"), + compatibleRuntimes: [ + Runtime.PYTHON_3_9, + Runtime.PYTHON_3_8, + ], + description: "A layer that contains the required modules", + license: "MIT License", + }); + + const lambdaFunction = new Function(this, `${this.STACK_NAMING_PREFIX}LambdaFunction`, { + runtime: Runtime.PYTHON_3_9, + code: Code.fromAsset(path.join(__dirname, '../resources/lambda/cw_subscription_filter')), + handler: 'handler.cw_subscription_handler', + layers: [lambdaLayer], + environment: { + OSI_INGESTION_ENDPOINT: props.ingestionEndpointURL, + }, + } + ); + + new Alias(this, `${this.STACK_NAMING_PREFIX}LambdaFunctionAlias`, { + aliasName: 'live', + version: lambdaFunction.currentVersion, + }); + + lambdaFunction.addToRolePolicy( + new PolicyStatement({ + effect: Effect.ALLOW, + resources: ['*'], + actions: ['osis:ingest'], + }), + ); + + // Create a Lambda Subscription Filter on the specific log group created above + const subscriptionFilter = new SubscriptionFilter(this, `${this.STACK_NAMING_PREFIX}LogSubscription`, { + logGroup: logGroup, + destination: new LambdaDestination(lambdaFunction), + filterPattern: FilterPattern.allEvents(), + }); + subscriptionFilter.node.addDependency(lambdaFunction); + } + +} \ No newline at end of file diff --git a/typescript/opensearch/osis_cwlogs_ingestion/lib/osis_os_setup_stack.ts b/typescript/opensearch/cwlogs_ingestion/lib/os_setup_stack.ts similarity index 90% rename from typescript/opensearch/osis_cwlogs_ingestion/lib/osis_os_setup_stack.ts rename to typescript/opensearch/cwlogs_ingestion/lib/os_setup_stack.ts index e5663420ff..266c2200d4 100644 --- a/typescript/opensearch/osis_cwlogs_ingestion/lib/osis_os_setup_stack.ts +++ b/typescript/opensearch/cwlogs_ingestion/lib/os_setup_stack.ts @@ -6,9 +6,10 @@ import { CfnPipeline } from 'aws-cdk-lib/aws-osis'; import { Construct } from 'constructs'; import { readFileSync } from "fs"; -export class OsisOpenSearchSetupStack extends Stack { +export class OpenSearchSetupStack extends Stack { - private readonly STACK_NAMING_PREFIX: string = 'osis-os-setup'; + private readonly STACK_NAMING_PREFIX: string = 'cw-to-os'; + private readonly STACK_RESOURCE_NAMING_PREFIX: string = 'OpenSearchSetup'; private readonly COLLECTION_NAME: string = `${this.STACK_NAMING_PREFIX}-col`; private readonly DATA_ACCESS_POLICY_NAME: string = `${this.STACK_NAMING_PREFIX}-data-pol`; private readonly NETWORK_POLICY_NAME: string = `${this.STACK_NAMING_PREFIX}-net-pol`; @@ -22,10 +23,10 @@ export class OsisOpenSearchSetupStack extends Stack { super(scope, id, props); // Create VPC - const vpc = new Vpc(this, `${this.STACK_NAMING_PREFIX}-vpc`); + const vpc = new Vpc(this, `${this.STACK_RESOURCE_NAMING_PREFIX}-vpc`); // Create Security Group - const securityGroup = new SecurityGroup(this, `${this.STACK_NAMING_PREFIX}-security-group`, { + const securityGroup = new SecurityGroup(this, `${this.STACK_RESOURCE_NAMING_PREFIX}-security-group`, { description: 'Security group for OpenSearch', vpc: vpc, allowAllOutbound: true, @@ -39,7 +40,7 @@ export class OsisOpenSearchSetupStack extends Stack { ); // Create VPC Endpoint - const vpcEndpoint = new CfnVpcEndpoint(this, `${this.STACK_NAMING_PREFIX}VpcEndpoint`, { + const vpcEndpoint = new CfnVpcEndpoint(this, `${this.STACK_RESOURCE_NAMING_PREFIX}VpcEndpoint`, { name: this.VPC_ENDPOINT_NAME, vpcId: vpc.vpcId, subnetIds: vpc.privateSubnets.map((subnet) => subnet.subnetId), @@ -47,7 +48,7 @@ export class OsisOpenSearchSetupStack extends Stack { }); // Create OpenSearch Serverless network security policy - const cfnNetworkAccessPolicy = new CfnSecurityPolicy(this, `${this.STACK_NAMING_PREFIX}NetworkPolicy`, { + const cfnNetworkAccessPolicy = new CfnSecurityPolicy(this, `${this.STACK_RESOURCE_NAMING_PREFIX}NetworkPolicy`, { name: this.NETWORK_POLICY_NAME, type: 'network', policy: JSON.stringify([ @@ -74,7 +75,7 @@ export class OsisOpenSearchSetupStack extends Stack { }); // Create OpenSearch Serverless encryption policy - const cfnEncryptionPolicy = new CfnSecurityPolicy(this, `${this.STACK_NAMING_PREFIX}EncryptionPolicy`, { + const cfnEncryptionPolicy = new CfnSecurityPolicy(this, `${this.STACK_RESOURCE_NAMING_PREFIX}EncryptionPolicy`, { name: this.ENCRYPTION_POLICY_NAME, type: 'encryption', policy: JSON.stringify({ @@ -103,8 +104,8 @@ export class OsisOpenSearchSetupStack extends Stack { cfnCollection.addDependency(cfnNetworkAccessPolicy); // Create IAM role for OpenSearch Ingestion pipeline - const pipelineRole = new Role(this, `${this.STACK_NAMING_PREFIX}PipelineRole`, { - roleName: `${this.STACK_NAMING_PREFIX}PipelineRole`, + const pipelineRole = new Role(this, `${this.STACK_RESOURCE_NAMING_PREFIX}PipelineRole`, { + roleName: `${this.STACK_RESOURCE_NAMING_PREFIX}PipelineRole`, assumedBy: new ServicePrincipal('osis-pipelines.amazonaws.com'), inlinePolicies: { 'OSISPipelineRolePolicy': this.pipelinePolicies(cfnCollection.attrArn) @@ -112,9 +113,12 @@ export class OsisOpenSearchSetupStack extends Stack { }); // Create OpenSearch Ingestion pipeline - const cfnPipeline = new CfnPipeline(this, `${this.STACK_NAMING_PREFIX}Pipeline`, { + const cfnPipeline = new CfnPipeline(this, `${this.STACK_RESOURCE_NAMING_PREFIX}Pipeline`, { maxUnits: 4, - minUnits: 1, + minUnits: 2, + bufferOptions: { + persistentBufferEnabled: true + }, pipelineConfigurationBody: this.getPipelineConfiguration(pipelineRole.roleArn, cfnCollection.attrCollectionEndpoint), pipelineName: this.PIPELINE_NAME, }); @@ -124,12 +128,12 @@ export class OsisOpenSearchSetupStack extends Stack { cfnPipeline.addDependency(cfnCollection); // Create a dashboard access role - const dashboardAccessRole = new Role(this, `${this.STACK_NAMING_PREFIX}DashboardAccessRole`, { + const dashboardAccessRole = new Role(this, `${this.STACK_RESOURCE_NAMING_PREFIX}DashboardAccessRole`, { assumedBy: new ServicePrincipal('ec2.amazonaws.com'), }); dashboardAccessRole.attachInlinePolicy( - new Policy(this, `${this.STACK_NAMING_PREFIX}DashboardAccessPolicy`, { + new Policy(this, `${this.STACK_RESOURCE_NAMING_PREFIX}DashboardAccessPolicy`, { statements: [ new PolicyStatement({ effect: Effect.ALLOW, diff --git a/typescript/opensearch/osis_cwlogs_ingestion/package.json b/typescript/opensearch/cwlogs_ingestion/package.json similarity index 73% rename from typescript/opensearch/osis_cwlogs_ingestion/package.json rename to typescript/opensearch/cwlogs_ingestion/package.json index be42d0125a..fe2ddaaa8d 100644 --- a/typescript/opensearch/osis_cwlogs_ingestion/package.json +++ b/typescript/opensearch/cwlogs_ingestion/package.json @@ -1,11 +1,11 @@ { - "name": "osis_cwlogs_ingestion", + "name": "cwlogs_ingestion", "version": "0.1.0", "bin": { - "osis_cwlogs_ingestion": "bin/osis_cwlogs_ingestion.js" + "cwlogs_ingestion": "bin/cwlogs_ingestion.js" }, "scripts": { - "clean": "rm -rf build dist cdk.out node_modules", + "clean": "rm -rf build dist cdk.out node_modules && find . -name \"*.js\" -type f -delete && find . -name \"*.d.ts\" -type f -delete", "build": "tsc", "watch": "tsc -w", "cdk": "cdk" diff --git a/typescript/opensearch/osis_cwlogs_ingestion/resources/lambda/cw_subscription_filter/handler.py b/typescript/opensearch/cwlogs_ingestion/resources/lambda/cw_subscription_filter/handler.py similarity index 100% rename from typescript/opensearch/osis_cwlogs_ingestion/resources/lambda/cw_subscription_filter/handler.py rename to typescript/opensearch/cwlogs_ingestion/resources/lambda/cw_subscription_filter/handler.py diff --git a/typescript/opensearch/osis_cwlogs_ingestion/resources/lambda/cw_subscription_filter/layers/requirements.txt b/typescript/opensearch/cwlogs_ingestion/resources/lambda/cw_subscription_filter/layers/requirements.txt similarity index 100% rename from typescript/opensearch/osis_cwlogs_ingestion/resources/lambda/cw_subscription_filter/layers/requirements.txt rename to typescript/opensearch/cwlogs_ingestion/resources/lambda/cw_subscription_filter/layers/requirements.txt diff --git a/typescript/opensearch/osis_cwlogs_ingestion/resources/lambda/log_emitter/handler.py b/typescript/opensearch/cwlogs_ingestion/resources/lambda/log_emitter/handler.py similarity index 86% rename from typescript/opensearch/osis_cwlogs_ingestion/resources/lambda/log_emitter/handler.py rename to typescript/opensearch/cwlogs_ingestion/resources/lambda/log_emitter/handler.py index 586b0fb44b..d66d37bbba 100644 --- a/typescript/opensearch/osis_cwlogs_ingestion/resources/lambda/log_emitter/handler.py +++ b/typescript/opensearch/cwlogs_ingestion/resources/lambda/log_emitter/handler.py @@ -6,7 +6,7 @@ def log_emitter(event, context): source = {} id = str(randrange(10000)) source['id'] = id - source['timestamp'] = datetime.now() + source['timestamp'] = str(datetime.now()) source['message'] = 'Hello world' source['owner'] = 'aws-osi' diff --git a/typescript/opensearch/osis_cwlogs_ingestion/resources/pipeline/configuration.yaml b/typescript/opensearch/cwlogs_ingestion/resources/pipeline/configuration.yaml similarity index 100% rename from typescript/opensearch/osis_cwlogs_ingestion/resources/pipeline/configuration.yaml rename to typescript/opensearch/cwlogs_ingestion/resources/pipeline/configuration.yaml diff --git a/typescript/opensearch/osis_cwlogs_ingestion/tsconfig.json b/typescript/opensearch/cwlogs_ingestion/tsconfig.json similarity index 100% rename from typescript/opensearch/osis_cwlogs_ingestion/tsconfig.json rename to typescript/opensearch/cwlogs_ingestion/tsconfig.json diff --git a/typescript/opensearch/osis_cwlogs_ingestion/bin/osis_cwlogs_ingestion.ts b/typescript/opensearch/osis_cwlogs_ingestion/bin/osis_cwlogs_ingestion.ts deleted file mode 100644 index efd098e21e..0000000000 --- a/typescript/opensearch/osis_cwlogs_ingestion/bin/osis_cwlogs_ingestion.ts +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env node -import "source-map-support/register"; -import { OsisOpenSearchSetupStack } from "../lib/osis_os_setup_stack"; -import { OsisCWLogsSubscriptionStack } from "../lib/osis_cwlogs_subscription_stack"; -import { App } from "aws-cdk-lib"; - -const app = new App(); -const osis_os_stack = new OsisOpenSearchSetupStack( - app, - "OsisOpenSearchSetupStack", - {}, -); - -new OsisCWLogsSubscriptionStack(app, "OsisCWLogsSubscriptionFilterStack", { - ingestionEndpointURL: osis_os_stack.ingestionEndPointURL, -}); \ No newline at end of file diff --git a/typescript/opensearch/osis_cwlogs_ingestion/lib/osis_cwlogs_subscription_stack.ts b/typescript/opensearch/osis_cwlogs_ingestion/lib/osis_cwlogs_subscription_stack.ts deleted file mode 100644 index 73963e1d93..0000000000 --- a/typescript/opensearch/osis_cwlogs_ingestion/lib/osis_cwlogs_subscription_stack.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { Duration, Stack, StackProps } from 'aws-cdk-lib'; -import { Effect, PolicyStatement } from 'aws-cdk-lib/aws-iam'; -import { Code, Runtime, Function, Alias } from 'aws-cdk-lib/aws-lambda'; -import { Construct } from 'constructs'; -import { PythonLayerVersion } from '@aws-cdk/aws-lambda-python-alpha'; -import path = require('path'); -import { FilterPattern, LogGroup, RetentionDays, SubscriptionFilter } from 'aws-cdk-lib/aws-logs'; -import { Rule, Schedule } from 'aws-cdk-lib/aws-events'; -import { LambdaFunction } from 'aws-cdk-lib/aws-events-targets'; -import { LambdaDestination } from 'aws-cdk-lib/aws-logs-destinations'; - -export interface OsisCWLogsSubscriptionStackProps extends StackProps { - ingestionEndpointURL: string -} - -export class OsisCWLogsSubscriptionStack extends Stack { - - private readonly STACK_NAMING_PREFIX: string = 'osis-cwlogs-subscription'; - - constructor(scope: Construct, id: string, props: OsisCWLogsSubscriptionStackProps) { - super(scope, id, props); - - ///////////////////////////////////////////////////////////////////////////////// - // - // Create the Log Emitter Lambda resources - // - ///////////////////////////////////////////////////////////////////////////////// - const logGroup = new LogGroup(this, `EventBridgeTriggeredLambdaLogGroup`, { - retention: RetentionDays.ONE_WEEK, - }); - - // Lambda Function to publish message to SNS - const lambdaFn = new Function(this, 'Singleton', { - code: Code.fromAsset(path.join(__dirname, '../resources/lambda/log_emitter')), - handler: 'handler.log_emitter', - timeout: Duration.seconds(300), - runtime: Runtime.PYTHON_3_9, - logGroup: logGroup - }); - - // Run the eventbridge every 5 minute interval to generate logs - const rule = new Rule(this, 'Rule', { - schedule: Schedule.rate(Duration.minutes(5)) - }); - - // Add the lambda function as a target to the eventbridge - rule.addTarget(new LambdaFunction(lambdaFn)); - - - ///////////////////////////////////////////////////////////////////////////////// - // - // Create the CloudWatch Log group subscription filter resources - // - ///////////////////////////////////////////////////////////////////////////////// - - const lambdaLayer = new PythonLayerVersion(this, `${this.STACK_NAMING_PREFIX}LambdaLayer`, { - entry: path.join(__dirname, "../resources/lambda/cw_subscription_filter/layers"), - compatibleRuntimes: [ - Runtime.PYTHON_3_9, - Runtime.PYTHON_3_8, - ], - description: "A layer that contains the required modules", - license: "MIT License", - }); - - const lambdaFunction = new Function(this, `${this.STACK_NAMING_PREFIX}LambdaFunction`, { - runtime: Runtime.PYTHON_3_9, - code: Code.fromAsset(path.join(__dirname, '../resources/lambda/cw_subscription_filter')), - handler: 'handler.cw_subscription_handler', - layers: [lambdaLayer], - environment: { - OSI_INGESTION_ENDPOINT: props.ingestionEndpointURL, - }, - } - ); - - new Alias(this, `${this.STACK_NAMING_PREFIX}LambdaFunctionAlias`, { - aliasName: 'live', - version: lambdaFunction.currentVersion, - }); - - lambdaFunction.addToRolePolicy( - new PolicyStatement({ - effect: Effect.ALLOW, - resources: ['*'], - actions: ['osis:ingest'], - }), - ); - - // Create a Lambda Subscription Filter on the specific log group created above - const subscriptionFilter = new SubscriptionFilter(this, `${this.STACK_NAMING_PREFIX}LogSubscription`, { - logGroup: logGroup, - destination: new LambdaDestination(lambdaFunction), - filterPattern: FilterPattern.allEvents(), - }); - subscriptionFilter.node.addDependency(lambdaFunction); - } -} \ No newline at end of file From edc0b944e328cdc14682398f083f180cb563a63e Mon Sep 17 00:00:00 2001 From: Michael Kaiser Date: Sat, 2 Nov 2024 10:04:11 -0500 Subject: [PATCH 3/8] Update package.json Update cdk and specify version for alpha module. * does not work and should match cdk version. --- typescript/opensearch/cwlogs_ingestion/package.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/typescript/opensearch/cwlogs_ingestion/package.json b/typescript/opensearch/cwlogs_ingestion/package.json index fe2ddaaa8d..c1d7b145f8 100644 --- a/typescript/opensearch/cwlogs_ingestion/package.json +++ b/typescript/opensearch/cwlogs_ingestion/package.json @@ -13,8 +13,8 @@ "devDependencies": { "@types/jest": "^29.5.4", "@types/node": "20.5.9", - "aws-cdk": "^2.14.0", - "aws-cdk-lib": "^2.14.0", + "aws-cdk": "^2.164.1", + "aws-cdk-lib": "^2.164.1", "constructs": "^10.2.43", "globals": "^15.6.0", "jest": "^29.6.4", @@ -23,9 +23,9 @@ "typescript": "~5.2.2" }, "dependencies": { - "@aws-cdk/aws-lambda-python-alpha": "*", - "aws-cdk": "^2.102.0", - "aws-cdk-lib": "^2.102.0", + "@aws-cdk/aws-lambda-python-alpha": "2.164.1", + "aws-cdk": "^2.164.1", + "aws-cdk-lib": "^2.164.1", "constructs": "^10.0.0", "source-map-support": "^0.5.21" } From 3b549d8d7d2291bce23ba35e284f15acf09fcf05 Mon Sep 17 00:00:00 2001 From: Michael Kaiser Date: Sat, 2 Nov 2024 10:07:15 -0500 Subject: [PATCH 4/8] Update package.json Need to add alpha suffix. --- typescript/opensearch/cwlogs_ingestion/package.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/typescript/opensearch/cwlogs_ingestion/package.json b/typescript/opensearch/cwlogs_ingestion/package.json index c1d7b145f8..cba042315d 100644 --- a/typescript/opensearch/cwlogs_ingestion/package.json +++ b/typescript/opensearch/cwlogs_ingestion/package.json @@ -13,8 +13,8 @@ "devDependencies": { "@types/jest": "^29.5.4", "@types/node": "20.5.9", - "aws-cdk": "^2.164.1", - "aws-cdk-lib": "^2.164.1", + "aws-cdk": "^2.165.0", + "aws-cdk-lib": "^2.165.0", "constructs": "^10.2.43", "globals": "^15.6.0", "jest": "^29.6.4", @@ -23,9 +23,9 @@ "typescript": "~5.2.2" }, "dependencies": { - "@aws-cdk/aws-lambda-python-alpha": "2.164.1", - "aws-cdk": "^2.164.1", - "aws-cdk-lib": "^2.164.1", + "@aws-cdk/aws-lambda-python-alpha": "2.165.0-alpha.0", + "aws-cdk": "^2.165.0", + "aws-cdk-lib": "^2.165.0", "constructs": "^10.0.0", "source-map-support": "^0.5.21" } From 31d723879dfe4284c83b8ebe315b5ec4c94b7ab6 Mon Sep 17 00:00:00 2001 From: Michael Kaiser Date: Sat, 2 Nov 2024 10:13:14 -0500 Subject: [PATCH 5/8] Update cwlogs_subscription_stack.ts Update Python version --- .../cwlogs_ingestion/lib/cwlogs_subscription_stack.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/typescript/opensearch/cwlogs_ingestion/lib/cwlogs_subscription_stack.ts b/typescript/opensearch/cwlogs_ingestion/lib/cwlogs_subscription_stack.ts index 067a2db8cf..8a5dc93343 100644 --- a/typescript/opensearch/cwlogs_ingestion/lib/cwlogs_subscription_stack.ts +++ b/typescript/opensearch/cwlogs_ingestion/lib/cwlogs_subscription_stack.ts @@ -34,7 +34,7 @@ export class CWLogsSubscriptionStack extends Stack { code: Code.fromAsset(path.join(__dirname, '../resources/lambda/log_emitter')), handler: 'handler.log_emitter', timeout: Duration.seconds(300), - runtime: Runtime.PYTHON_3_9, + runtime: Runtime.PYTHON_3_12, logGroup: logGroup }); @@ -56,15 +56,15 @@ export class CWLogsSubscriptionStack extends Stack { const lambdaLayer = new PythonLayerVersion(this, `${this.STACK_NAMING_PREFIX}LambdaLayer`, { entry: path.join(__dirname, "../resources/lambda/cw_subscription_filter/layers"), compatibleRuntimes: [ - Runtime.PYTHON_3_9, - Runtime.PYTHON_3_8, + Runtime.PYTHON_3_12, + Runtime.PYTHON_3_12, ], description: "A layer that contains the required modules", license: "MIT License", }); const lambdaFunction = new Function(this, `${this.STACK_NAMING_PREFIX}LambdaFunction`, { - runtime: Runtime.PYTHON_3_9, + runtime: Runtime.PYTHON_3_12, code: Code.fromAsset(path.join(__dirname, '../resources/lambda/cw_subscription_filter')), handler: 'handler.cw_subscription_handler', layers: [lambdaLayer], @@ -96,4 +96,4 @@ export class CWLogsSubscriptionStack extends Stack { subscriptionFilter.node.addDependency(lambdaFunction); } -} \ No newline at end of file +} From 9f42336e57e72ba2d429a0e34708b262e8cc0d94 Mon Sep 17 00:00:00 2001 From: Michael Kaiser Date: Sat, 2 Nov 2024 10:14:19 -0500 Subject: [PATCH 6/8] Update cwlogs_subscription_stack.ts --- .../cwlogs_ingestion/lib/cwlogs_subscription_stack.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/typescript/opensearch/cwlogs_ingestion/lib/cwlogs_subscription_stack.ts b/typescript/opensearch/cwlogs_ingestion/lib/cwlogs_subscription_stack.ts index 8a5dc93343..77e89eddf2 100644 --- a/typescript/opensearch/cwlogs_ingestion/lib/cwlogs_subscription_stack.ts +++ b/typescript/opensearch/cwlogs_ingestion/lib/cwlogs_subscription_stack.ts @@ -57,7 +57,7 @@ export class CWLogsSubscriptionStack extends Stack { entry: path.join(__dirname, "../resources/lambda/cw_subscription_filter/layers"), compatibleRuntimes: [ Runtime.PYTHON_3_12, - Runtime.PYTHON_3_12, + Runtime.PYTHON_3_11, ], description: "A layer that contains the required modules", license: "MIT License", From 6b061280847a35ef8dc42942077e2c858a37cbdd Mon Sep 17 00:00:00 2001 From: Michael Kaiser Date: Sat, 2 Nov 2024 10:23:22 -0500 Subject: [PATCH 7/8] Update os_setup_stack.ts Update to account SP --- .../opensearch/cwlogs_ingestion/lib/os_setup_stack.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/typescript/opensearch/cwlogs_ingestion/lib/os_setup_stack.ts b/typescript/opensearch/cwlogs_ingestion/lib/os_setup_stack.ts index 266c2200d4..7385841ea6 100644 --- a/typescript/opensearch/cwlogs_ingestion/lib/os_setup_stack.ts +++ b/typescript/opensearch/cwlogs_ingestion/lib/os_setup_stack.ts @@ -1,6 +1,6 @@ import { Fn, Stack, StackProps } from 'aws-cdk-lib'; import { Peer, Port, SecurityGroup, Vpc } from 'aws-cdk-lib/aws-ec2'; -import { Effect, Policy, PolicyDocument, PolicyStatement, Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam'; +import { Effect, Policy, PolicyDocument, PolicyStatement, Role, AccountPrincipal } from 'aws-cdk-lib/aws-iam'; import { CfnAccessPolicy, CfnCollection, CfnSecurityPolicy, CfnVpcEndpoint } from 'aws-cdk-lib/aws-opensearchserverless'; import { CfnPipeline } from 'aws-cdk-lib/aws-osis'; import { Construct } from 'constructs'; @@ -129,7 +129,7 @@ export class OpenSearchSetupStack extends Stack { // Create a dashboard access role const dashboardAccessRole = new Role(this, `${this.STACK_RESOURCE_NAMING_PREFIX}DashboardAccessRole`, { - assumedBy: new ServicePrincipal('ec2.amazonaws.com'), + assumedBy: new AccountPrincipal(this.account) , }); dashboardAccessRole.attachInlinePolicy( @@ -241,4 +241,4 @@ export class OpenSearchSetupStack extends Stack { return formattedPipelineConfiguration; } -} \ No newline at end of file +} From f6d38751b89cac4379defa1968dcf5c463fed610 Mon Sep 17 00:00:00 2001 From: Michael Kaiser Date: Sat, 2 Nov 2024 10:25:22 -0500 Subject: [PATCH 8/8] Update os_setup_stack.ts --- typescript/opensearch/cwlogs_ingestion/lib/os_setup_stack.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/typescript/opensearch/cwlogs_ingestion/lib/os_setup_stack.ts b/typescript/opensearch/cwlogs_ingestion/lib/os_setup_stack.ts index 7385841ea6..bae2882c4a 100644 --- a/typescript/opensearch/cwlogs_ingestion/lib/os_setup_stack.ts +++ b/typescript/opensearch/cwlogs_ingestion/lib/os_setup_stack.ts @@ -1,6 +1,6 @@ import { Fn, Stack, StackProps } from 'aws-cdk-lib'; import { Peer, Port, SecurityGroup, Vpc } from 'aws-cdk-lib/aws-ec2'; -import { Effect, Policy, PolicyDocument, PolicyStatement, Role, AccountPrincipal } from 'aws-cdk-lib/aws-iam'; +import { Effect, Policy, PolicyDocument, PolicyStatement, Role, AccountPrincipal, ServicePrincipal } from 'aws-cdk-lib/aws-iam'; import { CfnAccessPolicy, CfnCollection, CfnSecurityPolicy, CfnVpcEndpoint } from 'aws-cdk-lib/aws-opensearchserverless'; import { CfnPipeline } from 'aws-cdk-lib/aws-osis'; import { Construct } from 'constructs';