From c71af2955fed81e181ab3f15eeb6838b4bb50ca7 Mon Sep 17 00:00:00 2001 From: flaviostutz Date: Sat, 27 Jan 2024 01:16:25 +0100 Subject: [PATCH] feat: initial implementation of wso2-application (not tested) --- README.md | 107 +-------- docs/base-nodejs-lambda.md | 76 +++++++ docs/openapigateway-lambda.md | 18 ++ docs/wso2-api.md | 44 ++++ examples/pnpm-lock.yaml | 2 +- examples/src/wso2/petstore-openapi.ts | 2 +- lib/src/index.ts | 3 + lib/src/lambda/lambda-base.ts | 4 +- lib/src/wso2/types.ts | 95 ++++++++ lib/src/wso2/utils.ts | 134 +++++++++++ lib/src/wso2/wso2-api/handler/index.test.ts | 12 +- lib/src/wso2/wso2-api/handler/index.ts | 72 +----- lib/src/wso2/wso2-api/handler/wso2-v1.ts | 108 +-------- lib/src/wso2/wso2-api/types.ts | 95 +------- .../wso2/wso2-api/{handler => }/utils.test.ts | 0 lib/src/wso2/wso2-api/{handler => }/utils.ts | 19 -- lib/src/wso2/wso2-api/wso2-api.ts | 61 +---- .../wso2-application/handler/index.test.ts | 212 ++++++++++++++++++ .../wso2/wso2-application/handler/index.ts | 137 +++++++++++ .../wso2/wso2-application/handler/wso2-v1.ts | 94 ++++++++ lib/src/wso2/wso2-application/types.ts | 12 + lib/src/wso2/wso2-application/v1/types.ts | 63 ++++++ .../wso2-application/wso2-application.test.ts | 43 ++++ .../wso2/wso2-application/wso2-application.ts | 73 ++++++ lib/src/wso2/wso2-utils.ts | 101 ++++++++- 25 files changed, 1152 insertions(+), 435 deletions(-) create mode 100644 docs/base-nodejs-lambda.md create mode 100644 docs/openapigateway-lambda.md create mode 100644 docs/wso2-api.md create mode 100644 lib/src/wso2/types.ts create mode 100644 lib/src/wso2/utils.ts rename lib/src/wso2/wso2-api/{handler => }/utils.test.ts (100%) rename lib/src/wso2/wso2-api/{handler => }/utils.ts (62%) create mode 100644 lib/src/wso2/wso2-application/handler/index.test.ts create mode 100644 lib/src/wso2/wso2-application/handler/index.ts create mode 100644 lib/src/wso2/wso2-application/handler/wso2-v1.ts create mode 100644 lib/src/wso2/wso2-application/types.ts create mode 100644 lib/src/wso2/wso2-application/v1/types.ts create mode 100644 lib/src/wso2/wso2-application/wso2-application.test.ts create mode 100644 lib/src/wso2/wso2-application/wso2-application.ts diff --git a/README.md b/README.md index f8592ab..105a42f 100644 --- a/README.md +++ b/README.md @@ -2,111 +2,26 @@ A collection of CDK constructs and utilities for making the development of AWS based applications easier and safer in a practical way. -See examples for BaseNodeJsLambda and OpenApiGatewayLambda below. ## Construct BaseNodeJsLambda -Based on [AWS Construct NodeJsFunction](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_lambda_nodejs.NodejsFunction.html) and adds the following capabilities: - - creates a default security group. See property 'defaultSecurityGroup' of this construct - - creates an alias called "live" pointing to the latest lambda version and replaced versions are deleted automatically. See property 'liveAlias' of this construct - - typed configuration props for common usage scenarios - - autoscaling of provisioned concurrent invocations (so you lower cold starts). You can also use automatic scheduling to tweak min/max depending on a cron expression. See props.provisionedConcurrentExecutions - - explicit private VPC configuration (see props.network) - - source code path standardization to "[basePath]/[lambdaEventType]/[lambdaName]/index.ts" (can be overwritten by explicit props.entry) - - custom CA support for HTTP calls (NodeJS NODE_EXTRA_CA_CERTS). See props.extraCaPubCert - - option to subscribe an Lambda Arn to the log group related to the Lambda function. See props.logGroupSubscriberLambdaArn - - adds environment STAGE to Lambda. See props.stage +Creates a Lambda with common utilities for default live alias, auto scaling of provisioned concurrency, log subscription, default security group, custom CA certificate config etc -### Usage + - Check [Construct BaseNodeJsLambda](base-nodejs-lambda.md) for more details -#### Simple Typescript Lambda Function -```ts - // instantiate Lambda construct - // this will bundle your ts code using esbuild - const func = new BaseNodeJsFunction(stack, 'test-lambda', { - stage: 'dev', - eventType: EventType.Http - }); -``` +## Construct OpenapiGatewayLambda + +Creates an AWS APIGateway from definitions in an Openapi document based on Zod schemas and connects routes defined in Openapi to Lambda functions. -#### Complex Typescript Lambda Function + - Check [Construct OpenapiGatewayLambda](openapigateway-lambda.md) for more details -```ts - // this can be reused in various lambda definitions - const globalLambdaConfig = { - eventType: EventType.Http, - runtime: Runtime.NODEJS_18_X, - extraCaPubCert: 'ABCXxxxyz123123123' // add private CA pub certificate to NodeJS - } - const lambdaConfig: BaseNodeJsProps = { - // merge config with global defaults - ...globalLambdaConfig, - sourceMap: true, // add code source map to esbuild and configure Node. This might impose severe performance penauties - provisionedConcurrentExecutions: { - minCapacity: 1, // min instances in auto-scaling of provisioned lambdas - maxCapacity: 5, // max instances in auto-scaling. if empty, the number of provisioned instances will be fixed to "minCapacity" - schedules: [ // for automatically changing min/max on certain hours - { - minCapacity: 0, - maxCapacity: 2, - schedule: Schedule.cron({ hour: '22' }), - name: 'Lower provisioned instances during the night' - }, - { - minCapacity: 1, - maxCapacity: 5, - schedule: Schedule.cron({ hour: '7' }), - name: 'Keep at minimum one provisioned instance during the day' - } - ] - } - } +## WSO2 - // register an external Lambda to receive all Cloudwatch log events - // created by this Lambda (used to forward logs to Datadog, Splunk etc) - lambdaConfig.logGroupSubscriberLambdaArn = - 'arn:aws:lambda:eu-west-1:012345678:function:datadogForwarder'; +### Construct WSO2Api + +Creates an WSO2 API from definitions in an Openapi document. - // instantiate Lambda construct - const func = new BaseNodeJsFunction(stack, 'test-lambda', lambdaConfig); - - // add custom network access rules to default security group created for this lambda - func.defaultSecurityGroup.addEgressRule( - Peer.ipv4('10.20.30.40/32'), - Port.tcp(8888), - 'Allow lambda to access api X', - ); - -``` - -## Construct OpenApiGatewayLambda - -Rest API built from Openapi specs and aws extensions for running on Lambda functions with the following characteristics: - - Uses API Gateway extensions for OpenAPI for connecting api routes to Lambdas (https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-swagger-extensions.html) - - Creates an AWS API Gateway along with multiple Lambdas for each route defined in OpenAPI spec - - Uses api operations that supports Zod schemas in definitions (prop.openapiOperations) - - An opiniated set of defaults that can be overwritten in props - -Partialy inspired on https://blog.serverlessadvocate.com/serverless-openapi-amazon-api-gateway-with-the-aws-cdk-part-1-8a90477ebc24 - -AWS labs has a similar construct, but it relies on openapi specs written in yml and deployed to S3 buckets and CustomResources. We want to avoid S3 buckets and CustomResources to keep things faster/simpler and want to write our openapi specs in pure TS for better typing. - -Check https://github.com/awslabs/aws-solutions-constructs/blob/main/source/patterns/%40aws-solutions-constructs/aws-openapigateway-lambda/lib/index.ts - -### Usage - -TODO - -## Construct WSO2 API - - - -### Usage - -Supported output attributes of this Custom Resource (you can use GetAtt on these): - - ApiEndpointUrl: returns the endpoint that can be used to invoke this API in WSO2 - -For this construct, lots of experiences were extracted from [serverless-wso2-apim](https://github.com/ramgrandhi/serverless-wso2-apim). Thanks for the good work, Ram! + - Check [Construct Wso2Api](wso2-api.md) for more details diff --git a/docs/base-nodejs-lambda.md b/docs/base-nodejs-lambda.md new file mode 100644 index 0000000..d34b3da --- /dev/null +++ b/docs/base-nodejs-lambda.md @@ -0,0 +1,76 @@ +## Construct BaseNodeJsLambda + +Based on [AWS Construct NodeJsFunction](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_lambda_nodejs.NodejsFunction.html) and adds the following capabilities: + - creates a default security group. See property 'defaultSecurityGroup' of this construct + - creates an alias called "live" pointing to the latest lambda version and replaced versions are deleted automatically. See property 'liveAlias' of this construct + - typed configuration props for common usage scenarios + - autoscaling of provisioned concurrent invocations (so you lower cold starts). You can also use automatic scheduling to tweak min/max depending on a cron expression. See props.provisionedConcurrentExecutions + - explicit private VPC configuration (see props.network) + - source code path standardization to "[basePath]/[lambdaEventType]/[lambdaName]/index.ts" (can be overwritten by explicit props.entry) + - custom CA support for HTTP calls (NodeJS NODE_EXTRA_CA_CERTS). See props.extraCaPubCert + - option to subscribe an Lambda Arn to the log group related to the Lambda function. See props.logGroupSubscriberLambdaArn + - adds environment STAGE to Lambda. See props.stage + +### Usage + +#### Simple Typescript Lambda Function + +```ts + // instantiate Lambda construct + // this will bundle your ts code using esbuild + const func = new BaseNodeJsFunction(stack, 'test-lambda', { + stage: 'dev', + eventType: EventType.Http + }); +``` + +#### Complex Typescript Lambda Function + +```ts + // this can be reused in various lambda definitions + const globalLambdaConfig = { + eventType: EventType.Http, + runtime: Runtime.NODEJS_18_X, + extraCaPubCert: 'ABCXxxxyz123123123' // add private CA pub certificate to NodeJS + } + + const lambdaConfig: BaseNodeJsProps = { + // merge config with global defaults + ...globalLambdaConfig, + sourceMap: true, // add code source map to esbuild and configure Node. This might impose severe performance penauties + provisionedConcurrentExecutions: { + minCapacity: 1, // min instances in auto-scaling of provisioned lambdas + maxCapacity: 5, // max instances in auto-scaling. if empty, the number of provisioned instances will be fixed to "minCapacity" + schedules: [ // for automatically changing min/max on certain hours + { + minCapacity: 0, + maxCapacity: 2, + schedule: Schedule.cron({ hour: '22' }), + name: 'Lower provisioned instances during the night' + }, + { + minCapacity: 1, + maxCapacity: 5, + schedule: Schedule.cron({ hour: '7' }), + name: 'Keep at minimum one provisioned instance during the day' + } + ] + } + } + + // register an external Lambda to receive all Cloudwatch log events + // created by this Lambda (used to forward logs to Datadog, Splunk etc) + lambdaConfig.logGroupSubscriberLambdaArn = + 'arn:aws:lambda:eu-west-1:012345678:function:datadogForwarder'; + + // instantiate Lambda construct + const func = new BaseNodeJsFunction(stack, 'test-lambda', lambdaConfig); + + // add custom network access rules to default security group created for this lambda + func.defaultSecurityGroup.addEgressRule( + Peer.ipv4('10.20.30.40/32'), + Port.tcp(8888), + 'Allow lambda to access api X', + ); + +``` \ No newline at end of file diff --git a/docs/openapigateway-lambda.md b/docs/openapigateway-lambda.md new file mode 100644 index 0000000..681e255 --- /dev/null +++ b/docs/openapigateway-lambda.md @@ -0,0 +1,18 @@ +## Construct OpenApiGatewayLambda + +Rest API built from Openapi specs and aws extensions for running on Lambda functions with the following characteristics: + - Uses API Gateway extensions for OpenAPI for connecting api routes to Lambdas (https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-swagger-extensions.html) + - Creates an AWS API Gateway along with multiple Lambdas for each route defined in OpenAPI spec + - Uses api operations that supports Zod schemas in definitions (prop.openapiOperations) + - An opiniated set of defaults that can be overwritten in props + +Partialy inspired on https://blog.serverlessadvocate.com/serverless-openapi-amazon-api-gateway-with-the-aws-cdk-part-1-8a90477ebc24 + +AWS labs has a similar construct, but it relies on openapi specs written in yml and deployed to S3 buckets and CustomResources. We want to avoid S3 buckets and CustomResources to keep things faster/simpler and want to write our openapi specs in pure TS for better typing. + +Check https://github.com/awslabs/aws-solutions-constructs/blob/main/source/patterns/%40aws-solutions-constructs/aws-openapigateway-lambda/lib/index.ts + +### Usage + +TODO + diff --git a/docs/wso2-api.md b/docs/wso2-api.md new file mode 100644 index 0000000..95c3876 --- /dev/null +++ b/docs/wso2-api.md @@ -0,0 +1,44 @@ +## Construct WSO2 API + +Creates a new WSO2 API in a WSO2 server based on api definitions and an Openapi document. + +Check type *Wso2ApiProps* for a complete definition of the props for this construct. + +Supported output attributes for this CustomResource in CFN via GetAtt are: + - EndpointUrl: Endpoint URL of this API in WSO2 + - Wso2ApiId: Id of the API in WSO2 + +### Usage + +```ts + const wso2Props: Wso2ApiProps = { + wso2Config: { + baseApiUrl: 'https://mywso2.com', + credentialsSecretId: 'myWso2Creds', + }, + apiDefinition: { + version: 'v1', + type: 'HTTP', + endpointConfig: { + production_endpoints: { + url: 'http://serverabc.com', + }, + endpoint_type: 'http', + }, + context: '/petstore', + name: 'petstore-sample', + gatewayEnvironments: ['public'], + corsConfiguration: { + accessControlAllowOrigins: ['testwebsite.com'], + }, + }, + openapiDocument: mypetstoreOpenapiDoc, + }; + + // instantiate cdk construct + new Wso2Api(scope, `wso2-petstore`, wso2Props); +``` + +See a complete example at [/examples/src/wso2](/examples/src/wso2) + +For this construct, lots of experiences were extracted from [serverless-wso2-apim](https://github.com/ramgrandhi/serverless-wso2-apim). Thanks for the good work, Ram! diff --git a/examples/pnpm-lock.yaml b/examples/pnpm-lock.yaml index 93b6384..4437f2f 100644 --- a/examples/pnpm-lock.yaml +++ b/examples/pnpm-lock.yaml @@ -7029,7 +7029,7 @@ packages: dev: false file:../lib/dist/cdk-practical-constructs-0.0.1.tgz: - resolution: {integrity: sha512-paMC4twWLfs7T1vK5rA9YYEG0WPA8ppitAcecMkqXr+5IsqfOHVjXvfrAwV4nmkYtdOso/+NRa2t+sDu2U7/9w==, tarball: file:../lib/dist/cdk-practical-constructs-0.0.1.tgz} + resolution: {integrity: sha512-JUAC8oRbcK3XJ0yHqzDDvWT8yoggD14y9GxwWolv8i8yfHro+klEYrecyxJjhak/SZI+6E3JcB5pf63xi3yN+A==, tarball: file:../lib/dist/cdk-practical-constructs-0.0.1.tgz} name: cdk-practical-constructs version: 0.0.1 dependencies: diff --git a/examples/src/wso2/petstore-openapi.ts b/examples/src/wso2/petstore-openapi.ts index 03f4893..0a0d76e 100644 --- a/examples/src/wso2/petstore-openapi.ts +++ b/examples/src/wso2/petstore-openapi.ts @@ -17,7 +17,7 @@ export const petstoreOpenapi: OpenAPIObject = { paths: { '/pets': { get: { - summary: 'List all pets', + summary: 'List all pets 1', operationId: 'listPets', tags: ['pets'], parameters: [ diff --git a/lib/src/index.ts b/lib/src/index.ts index bf92571..a21981f 100644 --- a/lib/src/index.ts +++ b/lib/src/index.ts @@ -10,3 +10,6 @@ export * from './lambda/types'; export { Wso2Api } from './wso2/wso2-api/wso2-api'; export * from './wso2/wso2-api/types'; + +export { Wso2Application } from './wso2/wso2-application/wso2-application'; +export * from './wso2/wso2-application/types'; diff --git a/lib/src/lambda/lambda-base.ts b/lib/src/lambda/lambda-base.ts index 7f83e14..1e5c701 100644 --- a/lib/src/lambda/lambda-base.ts +++ b/lib/src/lambda/lambda-base.ts @@ -328,7 +328,9 @@ const addSecurityGroups = ( const defaultSG = new SecurityGroup(scope, `sg-default-${scope.node.id}`, { vpc: props.vpc, description: `Default security group for Lambda ${scope.node.id}`, - allowAllOutbound: typeof props.allowAllOutbound !== 'undefined' ?? props.allowAllOutbound, + allowAllOutbound: + (typeof props.allowAllOutbound !== 'undefined' && props.allowAllOutbound) ?? + props.allowAllOutbound, }); if (props.allowOutboundTo) { props.allowOutboundTo.forEach((ato) => { diff --git a/lib/src/wso2/types.ts b/lib/src/wso2/types.ts new file mode 100644 index 0000000..715857f --- /dev/null +++ b/lib/src/wso2/types.ts @@ -0,0 +1,95 @@ +import { RemovalPolicy } from 'aws-cdk-lib/core'; +import { BackoffOptions } from 'exponential-backoff'; + +import { LambdaConfig } from '../lambda/types'; + +/** + * Configurations used on the Lambda that receives events + * from Cloudformation to invoke WSO2 server. So if your + * WSO2 is accessible only via an specific network, or needs special rules + * or internal CA certificates, configure it using this property + */ +export type Wso2LambdaConfig = Pick< + LambdaConfig, + | 'allowOutboundTo' + | 'securityGroups' + | 'extraCaPubCert' + | 'network' + | 'logGroupSubscriberLambdaArn' + | 'logGroupRetention' + | 'logGroupRemovalPolicy' +>; + +export type Wso2BaseProperties = { + /** + * Configurations related to WSO2 APIM host, credentials tenant etc + */ + wso2Config: Wso2Config; + /** + * If true, during the creation of this CFN Resource, if an API in WSO2 already exists with the same tenant/name/version, it will fail. + * If false, an existing API in WSO2 can be used. This means that an API that wasn't created by this construct can + * be updated or even deleted by this Custom Resource (if 'Retain' is 'DESTROY' for this resource). + * @default true + */ + failIfExists?: boolean; + /** + * Automatic retry for checks and mutations + * This is a best effort to make the deployment successful even when WSO2 cluster is unstable, + * but if you use long retries your CFN stack might take too long to fail when the WSO2 server + * is unavailable, as it will continue retrying for minutes. + */ + retryOptions?: RetryOptions; + /** + * Removes or retains API in WSO2 APIM server when this application is removed from CFN + * https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.RemovalPolicy.html + * Defaults to RETAIN, which means that the API will be kept in WSO2 when this resource is deleted in CFN + */ + removalPolicy?: RemovalPolicy; + /** + * Lambda config for the CustomResource that will be used for running the WSO2 API calls + * By default this Lambda will have access to all networks so it's able to invoke the WSO2 APIs. + * You can add custom CA pub certs (extraCaPubCert) or configure a specific VPC to run it (network) for example. + */ + customResourceConfig?: Wso2LambdaConfig; +}; + +/** + * Retry options for API requests to WSO2 server + */ +export type RetryOptions = { + /** + * Retry options for check operations such as getting API to compare with desired contents. + * + * @default "{ startingDelay: 500, timeMultiple: 1.5, numOfAttempts: 10, maxDelay: 10000 }" which means retries on [500ms, 750ms, 1125ms, 1687ms (elapsed: 4s), 2531, 3796, 5696, 8542 (elapsed: 24s), 10000, 10000, 10000, 10000, 10000 (elapsed: 74s max)] + */ + checkRetries?: BackoffOptions; + /** + * Retry options for operations such as create/update API on WSO2, change api lifecycle, publish Openapi docs etc + * @default "{ startingDelay: 2000, timeMultiple: 1.5, numOfAttempts: 3, maxDelay: 5000 }" + */ + mutationRetries?: BackoffOptions; +}; + +export type Wso2Config = { + /** + * WSO2 server API base URL. This is the base URL from which the API calls to WSO2 will be sent. + * @example https://mywso2.com/ + */ + baseApiUrl: string; + /** + * Tenant identification (when using multi tenant setups) + * @example mypublic.com + */ + tenant?: string; + /** + * Secret id in Secret Manager with credentials for accessing WSO2 API. It will be used for + * listing APIs, creating client credentials, publishing APIs etc + * @example 'wso2/customers/credentials' - with json contents "{ user: 'myuser', pwd: 'mypass' }" + */ + credentialsSecretId: string; + /** + * Version of the WSO2 server API + * @default v1 + */ + apiVersion?: 'v1'; +}; diff --git a/lib/src/wso2/utils.ts b/lib/src/wso2/utils.ts new file mode 100644 index 0000000..1e4795b --- /dev/null +++ b/lib/src/wso2/utils.ts @@ -0,0 +1,134 @@ +/* eslint-disable no-console */ +import { existsSync } from 'fs'; + +import { Duration, RemovalPolicy, ScopedAws } from 'aws-cdk-lib'; +import { PolicyStatement } from 'aws-cdk-lib/aws-iam'; +import { Runtime } from 'aws-cdk-lib/aws-lambda'; +import { RetentionDays } from 'aws-cdk-lib/aws-logs'; +import { Provider } from 'aws-cdk-lib/custom-resources'; +import { Construct } from 'constructs'; +import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager'; + +import { BaseNodeJsFunction } from '../lambda/lambda-base'; +import { EventType } from '../lambda/types'; + +import { RetryOptions, Wso2BaseProperties } from './types'; + +export const addLambdaAndProviderForWso2Operations = (args: { + scope: Construct; + id: string; + props: Wso2BaseProperties; + baseDir: string; +}): { customResourceProvider: Provider; customResourceFunction: BaseNodeJsFunction } => { + const logGroupRetention = + args.props.customResourceConfig?.logGroupRetention ?? RetentionDays.ONE_MONTH; + + const { accountId, region } = new ScopedAws(args.scope); + + // resolve the entry file from workspace (.ts file), or + // from the dist dir (.js file) when being used as a lib + let wso2LambdaEntry = `${args.baseDir}/handler/index.ts`; + if (!existsSync(wso2LambdaEntry)) { + wso2LambdaEntry = `${args.baseDir}/handler/index.js`; + } + + // lambda function used for invoking WSO2 APIs during CFN operations + const customResourceFunction = new BaseNodeJsFunction(args.scope, `${args.id}-custom-lambda`, { + ...args.props.customResourceConfig, + stage: 'dev', + timeout: Duration.seconds(120), + runtime: Runtime.NODEJS_18_X, + eventType: EventType.CustomResource, + createLiveAlias: false, + createDefaultLogGroup: true, + logGroupRemovalPolicy: RemovalPolicy.DESTROY, + entry: wso2LambdaEntry, + initialPolicy: [ + PolicyStatement.fromJson({ + Effect: 'Allow', + Action: 'secretsmanager:GetSecretValue', + Resource: `arn:aws:secretsmanager:${region}:${accountId}:secret:${args.props.wso2Config.credentialsSecretId}*`, + }), + ], + logGroupRetention, + // allow all outbound by default + allowAllOutbound: typeof args.props.customResourceConfig?.network !== 'undefined', + }); + + const customResourceProvider = new Provider(args.scope, `${args.id}-custom-provider`, { + onEventHandler: customResourceFunction.nodeJsFunction, + }); + + return { customResourceProvider, customResourceFunction }; +}; + +export const getSecretValue = async (secretId: string): Promise => { + const client = new SecretsManagerClient(); + const response = await client.send( + new GetSecretValueCommand({ + SecretId: secretId, + }), + ); + if (response.SecretString) { + return response.SecretString; + } + if (!response.SecretBinary) { + throw new Error('Invalid type of secret found'); + } + const buff = Buffer.from(response.SecretBinary); + return buff.toString('ascii'); +}; + +const defaultRetryOpts = { + checkRetries: { + startingDelay: 500, + delayFirstAttempt: true, + maxDelay: 10000, + numOfAttempts: 10, + timeMultiple: 1.5, + // 500, 750, 1125, 1687 (4s), 2531, 3796, 5696 (16s), 8542 (24s), 10000, 10000, 10000, 10000, 10000 (74s) + }, + mutationRetries: { + startingDelay: 2000, + delayFirstAttempt: false, + maxDelay: 5000, + numOfAttempts: 3, + timeMultiple: 1.5, + // 2000, 3000 + }, +}; +export const applyRetryDefaults = (retryOptions?: RetryOptions): RetryOptions => { + const ropts: RetryOptions = { + // default config for backoff + ...defaultRetryOpts, + }; + + if (retryOptions?.checkRetries) { + ropts.checkRetries = retryOptions?.checkRetries; + } + if (retryOptions?.mutationRetries) { + ropts.mutationRetries = retryOptions?.mutationRetries; + } + + if (ropts.checkRetries) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ropts.checkRetries.retry = (err: any, attemptNumber: number): boolean => { + console.log(`Error detected. err=${err}`); + console.log(`Retrying check (#${attemptNumber})...`); + return true; + }; + } + if (ropts.mutationRetries) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ropts.mutationRetries.retry = (err: any, attemptNumber: number): boolean => { + console.log(`Error detected. err=${err}`); + console.log(`Retrying mutation (#${attemptNumber})...`); + return true; + }; + } + return ropts; +}; + +export const truncateStr = (str: string, size: number): string => { + return str.substring(0, Math.min(str.length, size)); +}; diff --git a/lib/src/wso2/wso2-api/handler/index.test.ts b/lib/src/wso2/wso2-api/handler/index.test.ts index 00e8d0a..41bf979 100644 --- a/lib/src/wso2/wso2-api/handler/index.test.ts +++ b/lib/src/wso2/wso2-api/handler/index.test.ts @@ -7,8 +7,8 @@ import { mockClient } from 'aws-sdk-client-mock'; import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager'; import { petstoreOpenapi } from '../__tests__/petstore'; -import { Wso2ApiBaseProperties } from '../types'; import { ApiFromListV1, PublisherPortalAPIv1, Wso2ApiDefinitionV1 } from '../v1/types'; +import { Wso2ApiCustomResourceProperties } from '../types'; import { Wso2ApiCustomResourceEvent, handler } from './index'; @@ -37,7 +37,7 @@ describe('wso2 custom resource lambda', () => { beforeEach(() => { nock.cleanAll(); // silence verbose console logs. comment this for debugging - // console.log = (): void => {}; + console.log = (): void => {}; }); afterEach(() => { console.log = originalConsoleLog; @@ -364,7 +364,7 @@ describe('wso2 custom resource lambda', () => { }; const testCFNEventCreate = ( - baseProperties: Wso2ApiBaseProperties, + baseProperties: Wso2ApiCustomResourceProperties, ): Wso2ApiCustomResourceEvent => { return { ...commonEvt, @@ -373,7 +373,7 @@ describe('wso2 custom resource lambda', () => { }; }; const testCFNEventDelete = ( - baseProperties: Wso2ApiBaseProperties, + baseProperties: Wso2ApiCustomResourceProperties, PhysicalResourceId: string, ): Wso2ApiCustomResourceEvent => { return { @@ -384,7 +384,7 @@ describe('wso2 custom resource lambda', () => { }; }; const testCFNEventUpdate = ( - baseProperties: Wso2ApiBaseProperties, + baseProperties: Wso2ApiCustomResourceProperties, PhysicalResourceId: string, oldResourceProperties: Record, ): Wso2ApiCustomResourceEvent => { @@ -397,7 +397,7 @@ describe('wso2 custom resource lambda', () => { }; }; - const testEvent: Wso2ApiBaseProperties = { + const testEvent: Wso2ApiCustomResourceProperties = { wso2Config: { baseApiUrl: baseWso2Url, credentialsSecretId: 'arn:aws:secretsmanager:us-east-1:123123123:secret:MySecret', diff --git a/lib/src/wso2/wso2-api/handler/index.ts b/lib/src/wso2/wso2-api/handler/index.ts index 3a35e91..2b070ef 100644 --- a/lib/src/wso2/wso2-api/handler/index.ts +++ b/lib/src/wso2/wso2-api/handler/index.ts @@ -2,18 +2,15 @@ import { CdkCustomResourceEvent, CdkCustomResourceResponse } from 'aws-lambda'; import { AxiosInstance } from 'axios'; -import { RetryOptions, Wso2ApiBaseProperties } from '../types'; import { PublisherPortalAPIv1 } from '../v1/types'; +import { Wso2ApiCustomResourceProperties } from '../types'; +import { prepareAxiosForWso2Calls } from '../../wso2-utils'; +import { applyRetryDefaults, truncateStr } from '../../utils'; -import { - createUpdateAndPublishApiInWso2, - findWso2Api, - prepareAxiosForWso2Api, - removeApiInWso2, -} from './wso2-v1'; +import { createUpdateAndPublishApiInWso2, findWso2Api, removeApiInWso2 } from './wso2-v1'; export type Wso2ApiCustomResourceEvent = CdkCustomResourceEvent & { - ResourceProperties: Wso2ApiBaseProperties; + ResourceProperties: Wso2ApiCustomResourceProperties; }; export type Wso2ApiCustomResourceResponse = CdkCustomResourceResponse & { @@ -49,7 +46,7 @@ export const handler = async ( try { console.log('>>> Prepare WSO2 API client...'); // const wso2Client = await prepareWso2ApiClient(event.ResourceProperties.wso2Config); - const wso2Axios = await prepareAxiosForWso2Api(event.ResourceProperties.wso2Config); + const wso2Axios = await prepareAxiosForWso2Calls(event.ResourceProperties.wso2Config); if (event.RequestType === 'Create' || event.RequestType === 'Update') { if (event.RequestType === 'Update') { @@ -59,7 +56,8 @@ export const handler = async ( const { wso2ApiId, endpointUrl } = await createOrUpdateWso2Api(event, wso2Axios); response.PhysicalResourceId = wso2ApiId; response.Data = { - ApiEndpointUrl: endpointUrl, + EndpointUrl: endpointUrl, + Wso2ApiId: wso2ApiId, }; response.Status = 'SUCCESS'; return response; @@ -136,57 +134,3 @@ const createOrUpdateWso2Api = async ( throw new Error(`Invalid requestType found. requestType=${event.ResourceType}`); }; - -const defaultRetryOpts = { - checkRetries: { - startingDelay: 500, - delayFirstAttempt: true, - maxDelay: 10000, - numOfAttempts: 10, - timeMultiple: 1.5, - // 500, 750, 1125, 1687 (4s), 2531, 3796, 5696 (16s), 8542 (24s), 10000, 10000, 10000, 10000, 10000 (74s) - }, - mutationRetries: { - startingDelay: 2000, - delayFirstAttempt: false, - maxDelay: 5000, - numOfAttempts: 2, - timeMultiple: 1.5, - // 2000, 3000 - }, -}; -const applyRetryDefaults = (retryOptions?: RetryOptions): RetryOptions => { - const ropts: RetryOptions = { - // default config for backoff - ...defaultRetryOpts, - }; - - if (retryOptions?.checkRetries) { - ropts.checkRetries = retryOptions?.checkRetries; - } - if (retryOptions?.mutationRetries) { - ropts.mutationRetries = retryOptions?.mutationRetries; - } - - if (ropts.checkRetries) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ropts.checkRetries.retry = (err: any, attemptNumber: number): boolean => { - console.log(`Error detected. err=${err}`); - console.log(`Retrying check (#${attemptNumber})...`); - return true; - }; - } - if (ropts.mutationRetries) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ropts.mutationRetries.retry = (err: any, attemptNumber: number): boolean => { - console.log(`Error detected. err=${err}`); - console.log(`Retrying mutation (#${attemptNumber})...`); - return true; - }; - } - return ropts; -}; - -const truncateStr = (str: string, size: number): string => { - return str.substring(0, Math.min(str.length, size)); -}; diff --git a/lib/src/wso2/wso2-api/handler/wso2-v1.ts b/lib/src/wso2/wso2-api/handler/wso2-v1.ts index af68f9e..f3f57ca 100644 --- a/lib/src/wso2/wso2-api/handler/wso2-v1.ts +++ b/lib/src/wso2/wso2-api/handler/wso2-v1.ts @@ -1,17 +1,10 @@ /* eslint-disable no-console */ import { OpenAPIObject } from 'openapi3-ts/oas30'; import { oas30 } from 'openapi3-ts'; -import axios, { AxiosError, AxiosInstance } from 'axios'; import FormData from 'form-data'; import { backOff } from 'exponential-backoff'; +import { AxiosInstance } from 'axios'; -import { - Wso2ApimConfig, - checkWso2ServerVersion, - getBearerToken, - registerClient, -} from '../../wso2-utils'; -import { RetryOptions, Wso2Config } from '../types'; import { ApiFromListV1, DevPortalAPIv1, @@ -19,103 +12,8 @@ import { Wso2ApiDefinitionV1, Wso2ApiListV1, } from '../v1/types'; - -import { areAttributeNamesEqual, getSecretValue } from './utils'; - -export const prepareAxiosForWso2Api = async (wso2Config: Wso2Config): Promise => { - // get wso2 user/pass - const creds = await getSecretValue(wso2Config.credentialsSecretId); - let wso2Creds; - try { - wso2Creds = JSON.parse(creds); - } catch (err) { - throw new Error( - `Couldn't parse credentials from secret manager at ${wso2Config.credentialsSecretId}. Check if it's a json with attributes {'user':'someuser', 'pwd':'mypass'}. err=${err}`, - ); - } - if (!wso2Creds.user || !wso2Creds.pwd) { - throw new Error( - `'user' and 'pwd' attributes from credentials ${wso2Config.credentialsSecretId} are required`, - ); - } - - // prepare wso2 api client - let username = wso2Creds.user; - if (wso2Config.tenant) { - username = `${wso2Creds.user}@${wso2Config.tenant}`; - } - - const wso2ApimConfig: Wso2ApimConfig = { - baseUrl: wso2Config.baseApiUrl, - username, - password: wso2Creds.pwd, - clientName: 'cdk-practical-constructs-wso2', - }; - - const clientCredentials = await registerClient(wso2ApimConfig); - const accessToken = await getBearerToken(wso2ApimConfig, clientCredentials); - - await checkWso2ServerVersion(wso2ApimConfig, 'v1'); - - const client = axios.create({ - baseURL: wso2Config.baseApiUrl, - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - client.interceptors.request.use((config: any) => { - // eslint-disable-next-line no-param-reassign - config.metadata = { startTime: new Date().getTime() }; - console.log(`> REQUEST: ${config.method?.toUpperCase()} ${wso2Config.baseApiUrl}${config.url}`); - console.log( - JSON.stringify({ - baseURL: config.baseURL, - url: config.url, - params: config.params, - method: config.method, - headers: config.headers, - status: config.status, - data: config.data, - }), - ); - return config; - }); - - client.interceptors.response.use( - (response) => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - const elapsedTime = new Date().getTime() - response.config.metadata.startTime; - console.log(`> RESPONSE: ${response.status} (${elapsedTime}ms)`); - console.log( - JSON.stringify({ - status: response.status, - headers: response.headers, - data: response.data, - }), - ); - return response; - }, - // eslint-disable-next-line promise/prefer-await-to-callbacks - (error: Error | AxiosError) => { - if (axios.isAxiosError(error)) { - console.log(`RESPONSE ERROR: status=${error.response?.status}`); - console.log( - JSON.stringify({ - status: error.response?.status, - headers: error.response?.headers, - data: error.response?.data, - }), - ); - } - throw error; - }, - ); - - return client; -}; +import { areAttributeNamesEqual } from '../utils'; +import { RetryOptions } from '../../types'; export const findWso2Api = async (args: { wso2Axios: AxiosInstance; diff --git a/lib/src/wso2/wso2-api/types.ts b/lib/src/wso2/wso2-api/types.ts index 5e6fd92..3b77595 100644 --- a/lib/src/wso2/wso2-api/types.ts +++ b/lib/src/wso2/wso2-api/types.ts @@ -1,73 +1,12 @@ import type { oas30 } from 'openapi3-ts'; -import { RemovalPolicy } from 'aws-cdk-lib/core'; -import { BackoffOptions } from 'exponential-backoff'; -import { LambdaConfig } from '../../lambda/types'; +import { Wso2BaseProperties } from '../types'; import { Wso2ApiDefinitionV1 } from './v1/types'; -export type Wso2ApiProps = Wso2ApiBaseProperties & { - /** - * Removes or retains API in WSO2 APIM server when this application is removed from CFN - * https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.RemovalPolicy.html - * Defaults to RETAIN, which means that the API will be kept in WSO2 when this resource is deleted in CFN - */ - removalPolicy?: RemovalPolicy; - /** - * Lambda config for the CustomResource that will be used for running the WSO2 API calls - * By default this Lambda will have access to all networks so it's able to invoke the WSO2 APIs. - * You can add custom CA pub certs (extraCaPubCert) or configure a specific VPC to run it (network) for example. - */ - customResourceConfig?: Wso2LambdaConfig; -}; - -export type Wso2Config = { - /** - * WSO2 server API base URL. This is the base URL from which the API calls to WSO2 will be sent. - * @example https://mywso2.com/ - */ - baseApiUrl: string; - /** - * Tenant identification (when using multi tenant setups) - * @example mypublic.com - */ - tenant?: string; - /** - * Secret id in Secret Manager with credentials for accessing WSO2 API. It will be used for - * listing APIs, creating client credentials, publishing APIs etc - * @example 'wso2/customers/credentials' - with json contents "{ user: 'myuser', pwd: 'mypass' }" - */ - credentialsSecretId: string; - /** - * Version of the WSO2 server API - * @default v1 - */ - apiVersion?: 'v1'; -}; +export type Wso2ApiCustomResourceProperties = Wso2ApiProps; -/** - * Wso2ApiDefinition for WSO2 API. - * Fields may vary depending on the WSO2 server version (wso2Version) - */ - -// export type Wso2ApiDefinition = Wso2ApiDefinitionV1; - -export type Wso2LambdaConfig = Pick< - LambdaConfig, - | 'allowOutboundTo' - | 'securityGroups' - | 'extraCaPubCert' - | 'network' - | 'logGroupSubscriberLambdaArn' - | 'logGroupRetention' - | 'logGroupRemovalPolicy' ->; - -export type Wso2ApiBaseProperties = { - /** - * Configurations related to WSO2 APIM host, credentials tenant etc - */ - wso2Config: Wso2Config; +export type Wso2ApiProps = Wso2BaseProperties & { /** * WSO2 specific document with API definitions * Some default values might be applied on top of the input when using in the construct @@ -78,32 +17,4 @@ export type Wso2ApiBaseProperties = { * The paths/operations in this document will be used to configure routes in WSO2 */ openapiDocument: oas30.OpenAPIObject; - /** - * If true, during the creation of this CFN Resource, if an API in WSO2 already exists with the same tenant/name/version, it will fail. - * If false, an existing API in WSO2 can be used. This means that an API that wasn't created by this construct can - * be updated or even deleted by this Custom Resource (if 'Retain' is 'DESTROY' for this resource). - * @default true - */ - failIfExists?: boolean; - /** - * Automatic retry for checks and mutations - * This is a best effort to make the deployment successful even when WSO2 cluster is unstable, - * but if you use long retries your CFN stack might take too long to fail when the WSO2 server - * is unavailable, as it will continue retrying for minutes. - */ - retryOptions?: RetryOptions; -}; - -export type RetryOptions = { - /** - * Retry options for check operations such as getting API to compare with desired contents. - * - * @default "{ startingDelay: 500, timeMultiple: 1.5, numOfAttempts: 10, maxDelay: 10000 }" which means retries on [500ms, 750ms, 1125ms, 1687ms (elapsed: 4s), 2531, 3796, 5696, 8542 (elapsed: 24s), 10000, 10000, 10000, 10000, 10000 (elapsed: 74s max)] - */ - checkRetries?: BackoffOptions; - /** - * Retry options for operations such as create/update API on WSO2, change api lifecycle, publish Openapi docs etc - * @default "{ startingDelay: 2000, timeMultiple: 1.5, numOfAttempts: 2, maxDelay: 5000 }" which means retries on [500ms, 750ms, 1125ms, 1687ms (elapsed: 4s), 2531, 3796, 5696, 8542 (elapsed: 24s), 10000, 10000, 10000, 10000, 10000 (elapsed: 74s max)] - */ - mutationRetries?: BackoffOptions; }; diff --git a/lib/src/wso2/wso2-api/handler/utils.test.ts b/lib/src/wso2/wso2-api/utils.test.ts similarity index 100% rename from lib/src/wso2/wso2-api/handler/utils.test.ts rename to lib/src/wso2/wso2-api/utils.test.ts diff --git a/lib/src/wso2/wso2-api/handler/utils.ts b/lib/src/wso2/wso2-api/utils.ts similarity index 62% rename from lib/src/wso2/wso2-api/handler/utils.ts rename to lib/src/wso2/wso2-api/utils.ts index fd2426f..84d7e6e 100644 --- a/lib/src/wso2/wso2-api/handler/utils.ts +++ b/lib/src/wso2/wso2-api/utils.ts @@ -1,22 +1,3 @@ -import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager'; - -export const getSecretValue = async (secretId: string): Promise => { - const client = new SecretsManagerClient({ region: 'eu-west-1' }); - const response = await client.send( - new GetSecretValueCommand({ - SecretId: secretId, - }), - ); - if (response.SecretString) { - return response.SecretString; - } - if (!response.SecretBinary) { - throw new Error('Invalid type of secret found'); - } - const buff = Buffer.from(response.SecretBinary); - return buff.toString('ascii'); -}; - export const areAttributeNamesEqual = ( // eslint-disable-next-line @typescript-eslint/no-explicit-any obj1: Record | undefined, diff --git a/lib/src/wso2/wso2-api/wso2-api.ts b/lib/src/wso2/wso2-api/wso2-api.ts index 988700c..986f5dc 100644 --- a/lib/src/wso2/wso2-api/wso2-api.ts +++ b/lib/src/wso2/wso2-api/wso2-api.ts @@ -1,18 +1,12 @@ -import { existsSync } from 'fs'; - import { Construct } from 'constructs'; -import { CustomResource, Duration, RemovalPolicy, ScopedAws } from 'aws-cdk-lib/core'; -import { IFunction, Runtime } from 'aws-cdk-lib/aws-lambda'; -import { PolicyStatement } from 'aws-cdk-lib/aws-iam'; +import { CustomResource, RemovalPolicy } from 'aws-cdk-lib/core'; +import { IFunction } from 'aws-cdk-lib/aws-lambda'; import { OpenAPIObject } from 'openapi3-ts/oas30'; -import { RetentionDays } from 'aws-cdk-lib/aws-logs'; -import { Provider } from 'aws-cdk-lib/custom-resources'; -import { EventType } from '../../lambda/types'; import { lintOpenapiDocument } from '../../utils/openapi-lint'; -import { BaseNodeJsFunction } from '../../lambda/lambda-base'; +import { addLambdaAndProviderForWso2Operations } from '../utils'; -import { Wso2ApiProps } from './types'; +import { Wso2ApiCustomResourceProperties, Wso2ApiProps } from './types'; import { applyDefaultsWso2ApiDefinition, validateWso2ApiDefs } from './api-defs'; import { Wso2ApiDefinitionV1 } from './v1/types'; @@ -46,44 +40,13 @@ export class Wso2Api extends Construct { const wso2ApiDefs = applyDefaultsWso2ApiDefinition(props.apiDefinition, props.openapiDocument); - const { accountId, region } = new ScopedAws(scope); - - const logGroupRetention = - props.customResourceConfig?.logGroupRetention ?? RetentionDays.ONE_MONTH; - - // resolve the entry file from workspace (.ts file), or - // from the dist dir (.js file) when being used as a lib - let wso2LambdaEntry = `${__dirname}/handler/index.ts`; - if (!existsSync(wso2LambdaEntry)) { - wso2LambdaEntry = `${__dirname}/handler/index.js`; - } - - // lambda function used for invoking WSO2 APIs during CFN operations - const customResourceFunction = new BaseNodeJsFunction(this, `${id}-wso2api-custom-lambda`, { - ...props.customResourceConfig, - stage: 'dev', - timeout: Duration.seconds(120), - runtime: Runtime.NODEJS_18_X, - eventType: EventType.CustomResource, - createLiveAlias: false, - createDefaultLogGroup: true, // TODO change to false? - logGroupRemovalPolicy: RemovalPolicy.RETAIN, // TODO change to DESTROY after - entry: wso2LambdaEntry, - initialPolicy: [ - PolicyStatement.fromJson({ - Effect: 'Allow', - Action: 'secretsmanager:GetSecretValue', - Resource: `arn:aws:secretsmanager:${region}:${accountId}:secret:${props.wso2Config.credentialsSecretId}*`, - }), - ], - logGroupRetention, - // allow all outbound by default - allowAllOutbound: typeof props.customResourceConfig?.network !== 'undefined', - }); - - const customResourceProvider = new Provider(this, `${id}-wso2api-custom-provider`, { - onEventHandler: customResourceFunction.nodeJsFunction, - }); + const { customResourceProvider, customResourceFunction } = + addLambdaAndProviderForWso2Operations({ + scope: this, + id: `${id}-wso2api`, + props, + baseDir: __dirname, + }); // TODO check if large open api documents can be passed by Custom Resource properties @@ -95,7 +58,7 @@ export class Wso2Api extends Construct { apiDefinition: wso2ApiDefs, openapiDocument: props.openapiDocument, retryOptions: props.retryOptions, - }, + } as Wso2ApiCustomResourceProperties, resourceType: 'Custom::Wso2Api', removalPolicy: props.removalPolicy ?? RemovalPolicy.RETAIN, }); diff --git a/lib/src/wso2/wso2-application/handler/index.test.ts b/lib/src/wso2/wso2-application/handler/index.test.ts new file mode 100644 index 0000000..4096e1d --- /dev/null +++ b/lib/src/wso2/wso2-application/handler/index.test.ts @@ -0,0 +1,212 @@ +/* eslint-disable no-console */ +/* eslint-disable camelcase */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import nock from 'nock'; +import { mockClient } from 'aws-sdk-client-mock'; +import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager'; + +import { Wso2ApplicationDefinition } from '../v1/types'; +import { Wso2ApplicationCustomResourceProperties } from '../types'; + +import { Wso2ApplicationCustomResourceEvent, handler } from './index'; + +const baseWso2Url = 'https://mywso2.com'; + +const testRetryOptions = { + checkRetries: { + startingDelay: 100, + delayFirstAttempt: true, + maxDelay: 100, + numOfAttempts: 0, + timeMultiple: 1.1, + }, + mutationRetries: { + startingDelay: 100, + delayFirstAttempt: true, + maxDelay: 100, + numOfAttempts: 0, + timeMultiple: 1.1, + }, +}; + +const originalConsoleLog = console.log; + +describe('wso2 application custom resource lambda', () => { + beforeEach(() => { + nock.cleanAll(); + // silence verbose console logs. comment this for debugging + // console.log = (): void => {}; + }); + afterEach(() => { + console.log = originalConsoleLog; + }); + + it('wso2 application delete', async () => { + nockBasicWso2SDK(); + + // application get mock + nock(baseWso2Url) + .delete(/.*\/store\/v1\/applications\/[^\\/]+$/) + .times(1) // check if was created + .reply(200); + + const eres = await handler(testCFNEventDelete(testEvent, '123-456')); + expect(eres.Status).toBe('SUCCESS'); + }); + + it('basic wso2 application update', async () => { + nockBasicWso2SDK(); + + const testDefs: Wso2ApplicationDefinition = testApplicationDefs(); + + // api list mock + nock(baseWso2Url) + .get(/.*\/store\/v1\/applications.*/) + .query(true) + .times(1) // check create or update + .reply(200, { list: [{ ...testDefs, applicationId: '123-456' }] }); + + // application update mock + nock(baseWso2Url) + .put(/.*\/store\/v1\/applications\/[^\\/]+$/) + .times(1) + .reply(200); + + // application get mock + nock(baseWso2Url) + .get(/.*\/store\/v1\/applications\/[^\\/]+$/) + .times(1) // check if was created + .reply(200, { ...testDefs }); + + const eres = await handler( + testCFNEventCreate({ + ...testEvent, + }), + ); + expect(eres.PhysicalResourceId).toBe('123-456'); + expect(eres.Status).toBe('SUCCESS'); + }); + + it('basic wso2 application create', async () => { + nockBasicWso2SDK(); + + // api list mock + nock(baseWso2Url) + .get(/.*\/store\/v1\/applications.*/) + .query(true) + .times(1) // check create or update + .reply(200, { list: [] }); + + const testDefs: Wso2ApplicationDefinition = testApplicationDefs(); + + // application create mock + nock(baseWso2Url) + .post(/.*\/store\/v1\/applications$/) + .reply(201, { ...testDefs, applicationId: '123-456' }); + + // application get mock + nock(baseWso2Url) + .get(/.*\/store\/v1\/applications\/[^\\/]+$/) + .times(1) // check if was created + .reply(200, { ...testDefs }); + + const eres = await handler( + testCFNEventCreate({ + ...testEvent, + }), + ); + expect(eres.PhysicalResourceId).toBe('123-456'); + expect(eres.Status).toBe('SUCCESS'); + }); + + const testApplicationDefs = (): Wso2ApplicationDefinition => { + return { + name: 'someapplication', + throttlingPolicy: 'Unlimited', + attributes: { + mycustom1: 'value1', + mycustom2: 'value2', + }, + }; + }; + + const commonEvt = { + StackId: 'test-stack', + RequestId: '123-123123', + LogicalResourceId: 'abc abc', + ServiceToken: 'arn:somelambdatest', + ResponseURL: 's3bucketxxx', + ResourceType: 'wso2application', + }; + + const testCFNEventCreate = ( + baseProperties: Wso2ApplicationCustomResourceProperties, + ): Wso2ApplicationCustomResourceEvent => { + return { + ...commonEvt, + RequestType: 'Create', + ResourceProperties: { ...baseProperties, ServiceToken: 'arn:somelambdatest' }, + }; + }; + const testCFNEventDelete = ( + baseProperties: Wso2ApplicationCustomResourceProperties, + PhysicalResourceId: string, + ): Wso2ApplicationCustomResourceEvent => { + return { + ...commonEvt, + RequestType: 'Delete', + ResourceProperties: { ...baseProperties, ServiceToken: 'arn:somelambdatest' }, + PhysicalResourceId, + }; + }; + // const testCFNEventUpdate = ( + // baseProperties: Wso2ApplicationCustomResourceProperties, + // PhysicalResourceId: string, + // oldResourceProperties: Record, + // ): Wso2ApplicationCustomResourceEvent => { + // return { + // ...commonEvt, + // RequestType: 'Update', + // ResourceProperties: { ...baseProperties, ServiceToken: 'arn:somelambdatest' }, + // PhysicalResourceId, + // OldResourceProperties: oldResourceProperties, + // }; + // }; + + const testEvent: Wso2ApplicationCustomResourceProperties = { + wso2Config: { + baseApiUrl: baseWso2Url, + credentialsSecretId: 'arn:aws:secretsmanager:us-east-1:123123123:secret:MySecret', + apiVersion: 'v1', + }, + applicationDefinition: testApplicationDefs(), + retryOptions: testRetryOptions, + }; + + const nockBasicWso2SDK = (): void => { + const secretMock = mockClient(SecretsManagerClient); + secretMock.on(GetSecretValueCommand).resolves({ + SecretBinary: Buffer.from(JSON.stringify({ user: 'user1', pwd: 'pwd1' })), + }); + + // register client mock + nock(baseWso2Url).post('/client-registration/v0.17/register').reply(200, { + clientId: 'clientId1', + clientSecret: 'clientSecret1', + }); + + // get token mock + nock(baseWso2Url).post('/oauth2/token').reply(200, { + access_token: '1111-1111-1111', + }); + + // mock server check + nock(baseWso2Url) + .get('/services/Version') + .reply( + 200, + 'WSO2 API Manager-3.2.0', + ); + }; +}); diff --git a/lib/src/wso2/wso2-application/handler/index.ts b/lib/src/wso2/wso2-application/handler/index.ts new file mode 100644 index 0000000..23a952b --- /dev/null +++ b/lib/src/wso2/wso2-application/handler/index.ts @@ -0,0 +1,137 @@ +/* eslint-disable no-console */ + +import { CdkCustomResourceEvent, CdkCustomResourceResponse } from 'aws-lambda'; +import { AxiosInstance } from 'axios'; + +import { Wso2ApplicationCustomResourceProperties } from '../types'; +import { prepareAxiosForWso2Calls } from '../../wso2-utils'; +import { Wso2ApplicationInfo } from '../v1/types'; +import { applyRetryDefaults, truncateStr } from '../../utils'; + +import { createUpdateApplicationInWso2, removeApplicationInWso2 } from './wso2-v1'; + +export type Wso2ApplicationCustomResourceEvent = CdkCustomResourceEvent & { + ResourceProperties: Wso2ApplicationCustomResourceProperties; +}; + +export type Wso2ApplicationCustomResourceResponse = CdkCustomResourceResponse & { + Data?: { + ApiEndpointUrl?: string; + Error?: unknown; + }; + Status?: 'SUCCESS' | 'FAILED'; + Reason?: string; +}; + +export const handler = async ( + event: Wso2ApplicationCustomResourceEvent, +): Promise => { + // console.log(`WSO2 API Custom Resource invoked with: ${JSON.stringify(event)}`); + + if (!event.ResourceProperties.applicationDefinition) { + throw new Error('event.apiDefinition should be defined'); + } + if (!event.ResourceProperties.wso2Config) { + throw new Error('event.wso2Config should be defined'); + } + + const response: Wso2ApplicationCustomResourceResponse = { + StackId: event.StackId, + RequestId: event.RequestId, + LogicalResourceId: event.LogicalResourceId, + }; + + try { + console.log('>>> Prepare WSO2 API client...'); + const wso2Axios = await prepareAxiosForWso2Calls(event.ResourceProperties.wso2Config); + + if (event.RequestType === 'Create' || event.RequestType === 'Update') { + if (event.RequestType === 'Update') { + response.PhysicalResourceId = event.PhysicalResourceId; + } + console.log('>>> Creating or Updating WSO2 Application...'); + const wso2ApplicationId = await createOrUpdateWso2Application(event, wso2Axios); + response.PhysicalResourceId = wso2ApplicationId; + response.Data = { + Wso2ApplicationId: wso2ApplicationId, + }; + response.Status = 'SUCCESS'; + return response; + } + if (event.RequestType === 'Delete') { + console.log('>>> Deleting WSO2 Application...'); + response.PhysicalResourceId = event.PhysicalResourceId; + await removeApplicationInWso2({ + wso2Axios, + wso2ApplicationId: event.PhysicalResourceId, + }); + response.Status = 'SUCCESS'; + return response; + } + throw new Error('Unrecognized RequestType'); + } catch (error) { + console.log(`An error has occurred. err=${error}`); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const err = error as any; + if (err.stack) { + console.log(err.stack); + } + throw new Error(truncateStr(`${error}`, 1000)); + } +}; + +const createOrUpdateWso2Application = async ( + event: Wso2ApplicationCustomResourceEvent, + wso2Axios: AxiosInstance, +): Promise => { + if (!event.ResourceProperties.applicationDefinition?.name) { + throw new Error('applicationDefinition.name should be defined'); + } + + // find existing WSO2 application + console.log('Searching if Application already exists in WSO2...'); + let existingApplication: Wso2ApplicationInfo | undefined; + const apil = await wso2Axios.get(`/api/am/store/v1/applications`, { + params: { query: event.ResourceProperties.applicationDefinition.name }, + }); + const apiRes = apil.data.list as Wso2ApplicationInfo[]; + if (apiRes.length > 1) { + throw new Error( + `More than one Application with name '${event.ResourceProperties.applicationDefinition.name}' was found in WSO2 so we cannot determine it's id automatically`, + ); + } + if (apiRes.length === 1) { + existingApplication = apiRes[0]; + console.log( + `Found existing WSO2 Application. applicationId=${existingApplication.applicationId}; name=${existingApplication.name}`, + ); + } + + if ( + event.RequestType === 'Create' && + existingApplication && + event.ResourceProperties.failIfExists + ) { + throw new Error( + `WSO2 Application ${existingApplication.applicationId}' already exists but cannot be managed by this resource. Change 'failIfExists' to change this behavior`, + ); + } + + if (event.RequestType === 'Update' && !existingApplication) { + console.log( + `WARNING: This is an Update operation but the Application couldn't be found in WSO2. It will be created again`, + ); + } + + if (event.RequestType === 'Create' || event.RequestType === 'Update') { + return createUpdateApplicationInWso2({ + wso2Axios, + wso2Tenant: event.ResourceProperties.wso2Config.tenant ?? '', + applicationDefinition: event.ResourceProperties.applicationDefinition, + existingApplication, + retryOptions: applyRetryDefaults(event.ResourceProperties.retryOptions), + }); + } + + throw new Error(`Invalid requestType found. requestType=${event.ResourceType}`); +}; diff --git a/lib/src/wso2/wso2-application/handler/wso2-v1.ts b/lib/src/wso2/wso2-application/handler/wso2-v1.ts new file mode 100644 index 0000000..8e0977e --- /dev/null +++ b/lib/src/wso2/wso2-application/handler/wso2-v1.ts @@ -0,0 +1,94 @@ +/* eslint-disable no-console */ +import { backOff } from 'exponential-backoff'; +import { AxiosInstance } from 'axios'; + +import { Wso2ApplicationDefinition, Wso2ApplicationInfo } from '../v1/types'; +import { RetryOptions } from '../../types'; + +export type UpsertWso2Args = { + wso2Axios: AxiosInstance; + wso2Tenant: string; + existingApplication?: Wso2ApplicationInfo; + applicationDefinition: Wso2ApplicationDefinition; + retryOptions: RetryOptions; +}; + +/** + * Delete Application in WSO2 server + */ +export const removeApplicationInWso2 = async (args: { + wso2Axios: AxiosInstance; + wso2ApplicationId: string; +}): Promise => { + if (!args.wso2ApplicationId) { + throw new Error('wso2ApplicationId is required for deleting Application'); + } + await args.wso2Axios.delete(`/api/am/store/v1/applications/${args.wso2ApplicationId}`); +}; + +/** + * Perform calls in WSO2 API to create or update an Application + * @returns {string} Id of the Application in WSO2 + */ +export const createUpdateApplicationInWso2 = async (args: UpsertWso2Args): Promise => { + console.log(''); + console.log(`>>> Create or update application in WSO2...`); + // will retry create/update api operation if fails + const wso2ApplicationId = await backOff( + async () => createUpdateApplicationInWso2AndCheck(args), + args.retryOptions.mutationRetries, + ); + + console.log('Application created/updated on WSO2 server successfuly'); + + return wso2ApplicationId; +}; + +export const createUpdateApplicationInWso2AndCheck = async ( + args: UpsertWso2Args, +): Promise => { + // create new API in WSO2 + if (!args.existingApplication) { + console.log(`Creating new Application in WSO2`); + const apir = await args.wso2Axios.post( + `/api/am/store/v1/applications`, + args.applicationDefinition, + ); + + const dataRes = apir.data as Wso2ApplicationInfo; + if (!dataRes.applicationId) { + throw new Error( + `'applicationId' id wasn't returned as part of the Application creation response`, + ); + } + console.log(`Application created in WSO2`); + + // wait for Application to be created by retrying checks + await backOff(async () => { + await args.wso2Axios.get(`/api/am/store/v1/applications/${dataRes.applicationId}`); + }, args.retryOptions.checkRetries); + + return dataRes.applicationId; + } + + // update existing API in WSO2 + console.log(`Updating Application definitions in WSO2`); + + if (!args.existingApplication.applicationId) { + throw new Error('Existing applicationId should be defined'); + } + + await args.wso2Axios.put( + `/api/am/store/v1/applications/${args.existingApplication.applicationId}`, + args.applicationDefinition, + ); + + // wait for Application to be created by retrying checks + await backOff(async () => { + await args.wso2Axios.get( + `/api/am/store/v1/applications/${args.existingApplication?.applicationId}`, + ); + }, args.retryOptions.checkRetries); + + return args.existingApplication.applicationId; +}; diff --git a/lib/src/wso2/wso2-application/types.ts b/lib/src/wso2/wso2-application/types.ts new file mode 100644 index 0000000..3388aa3 --- /dev/null +++ b/lib/src/wso2/wso2-application/types.ts @@ -0,0 +1,12 @@ +import { Wso2BaseProperties } from '../types'; + +import { Wso2ApplicationDefinition } from './v1/types'; + +export type Wso2ApplicationCustomResourceProperties = Wso2ApplicationProps; + +/** + * WSO2 Application construct parameters + */ +export type Wso2ApplicationProps = Wso2BaseProperties & { + applicationDefinition: Wso2ApplicationDefinition; +}; diff --git a/lib/src/wso2/wso2-application/v1/types.ts b/lib/src/wso2/wso2-application/v1/types.ts new file mode 100644 index 0000000..9a3cdab --- /dev/null +++ b/lib/src/wso2/wso2-application/v1/types.ts @@ -0,0 +1,63 @@ +export type Wso2ApplicationInfo = Wso2ApplicationDefinition & { + /** @example 01234567-0123-0123-0123-012345678901 */ + applicationId?: string; + /** + * @default + * @example APPROVED + */ + status?: string; + /** @example [] */ + groups?: string[]; + subscriptionCount?: number; + /** + * Scopes allowed by this application + * @example [] + * */ + subscriptionScopes?: ScopeInfo[]; + /** + * @description Application created user + * + * @example admin + */ + owner?: string; + /** @example false */ + hashEnabled?: boolean; +}; + +export type Wso2ApplicationDefinition = { + /** @example CalculatorApp */ + name: string; + /** @example Unlimited */ + throttlingPolicy: 'Unlimited' | 'Bronze' | 'Silver' | 'Gold' | string; + /** @example Sample calculator application */ + description?: string; + /** + * @description Type of the access token generated for this application. + * + * **JWT:** A self-contained, signed JWT based access token which is issued by default. + * + * @default JWT + * @example JWT + * @enum {string} + */ + tokenType?: 'JWT'; + /** @example {} */ + attributes?: { [key: string]: string }; +}; + +export type ScopeInfo = { + /** @example admin_scope */ + key?: string; + /** @example admin scope */ + name?: string; + /** + * @description Allowed roles for the scope + * @example [ + * "manager", + * "developer" + * ] + */ + roles?: string[]; + /** @description Description of the scope */ + description?: string; +}; diff --git a/lib/src/wso2/wso2-application/wso2-application.test.ts b/lib/src/wso2/wso2-application/wso2-application.test.ts new file mode 100644 index 0000000..7058354 --- /dev/null +++ b/lib/src/wso2/wso2-application/wso2-application.test.ts @@ -0,0 +1,43 @@ +/* eslint-disable camelcase */ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +/* eslint-disable fp/no-mutating-methods */ + +import { App, Stack } from 'aws-cdk-lib/core'; +import { Template } from 'aws-cdk-lib/assertions'; + +import { Wso2ApplicationProps } from './types'; +import { Wso2Application } from './wso2-application'; + +describe('wso2-application-construct', () => { + it('minimal wso2 api', async () => { + const app = new App(); + const stack = new Stack(app); + + const testProps1 = testProps(); + const wso2Application = new Wso2Application(stack, 'wso2', testProps1); + + expect(wso2Application.customResourceFunction).toBeDefined(); + + const template = Template.fromStack(stack); + // eslint-disable-next-line no-console + console.log(JSON.stringify(template.toJSON(), null, 2)); + + template.hasResourceProperties('Custom::Wso2Application', { + wso2Config: testProps1.wso2Config, + applicationDefinition: testProps1.applicationDefinition, + }); + }); +}); + +const testProps = (): Wso2ApplicationProps => { + return { + wso2Config: { + baseApiUrl: 'http://localhost:8080/wso2', + credentialsSecretId: 'arn::creds', + }, + applicationDefinition: { + name: 'test-application', + throttlingPolicy: 'Unlimited', + }, + }; +}; diff --git a/lib/src/wso2/wso2-application/wso2-application.ts b/lib/src/wso2/wso2-application/wso2-application.ts new file mode 100644 index 0000000..f5adbe2 --- /dev/null +++ b/lib/src/wso2/wso2-application/wso2-application.ts @@ -0,0 +1,73 @@ +import { Construct } from 'constructs'; +import { CustomResource, RemovalPolicy } from 'aws-cdk-lib/core'; +import { IFunction } from 'aws-cdk-lib/aws-lambda'; + +import { addLambdaAndProviderForWso2Operations } from '../utils'; + +import { Wso2ApplicationCustomResourceProperties, Wso2ApplicationProps } from './types'; + +/** + * WSO2 API CDK construct for creating WSO2 Application + * This construct is related to one "physical" application in WSO2. + * + * The internal implementation tries to protect itself from various scenarios where larger or more complex + * WSO2 clusters might lead to out-of-order or delays in operations that happen assynchronously after the API + * accepts the requests, so for every mutation, there is a check to verify sanity. + */ +export class Wso2Application extends Construct { + readonly customResourceFunction: IFunction; + + constructor(scope: Construct, id: string, props: Wso2ApplicationProps) { + super(scope, id); + + // Do as much of the logic in the construct as possible and leave only + // the minimal complexity to the Lambda Custom Resource as it's harder + // to debug and eventual errors will rollback the entire stack and will + // make the feedback cycle much longer. + + // Keep this construct stateless (don't access WSO2 apis) and + // leave the stateful part to the Lambda Custom Resource (accessing WSO2 apis etc) + + validateProps(props); + + const { customResourceProvider, customResourceFunction } = + addLambdaAndProviderForWso2Operations({ + scope: this, + id: `${id}-wso2app`, + props, + baseDir: __dirname, + }); + + // eslint-disable-next-line no-new + new CustomResource(this, `${id}-wso2app-custom-resource`, { + serviceToken: customResourceProvider.serviceToken, + properties: { + wso2Config: props.wso2Config, + applicationDefinition: props.applicationDefinition, + retryOptions: props.retryOptions, + } as Wso2ApplicationCustomResourceProperties, + resourceType: 'Custom::Wso2Application', + removalPolicy: props.removalPolicy ?? RemovalPolicy.RETAIN, + }); + + this.customResourceFunction = customResourceFunction.nodeJsFunction; + } +} + +export const validateProps = (props: Wso2ApplicationProps): void => { + if (!props.wso2Config) throw new Error('wso2Config is required'); + if (!props.wso2Config.baseApiUrl) throw new Error('wso2Config.baseApiUrl is required'); + if (!props.wso2Config.credentialsSecretId) { + throw new Error('wso2Config.credentialsSecretManagerPath is required'); + } + + if (!props.applicationDefinition) { + throw new Error('applicationDefinition is required'); + } + if (!props.applicationDefinition.name) { + throw new Error('applicationDefinition.name is required'); + } + if (!props.applicationDefinition.throttlingPolicy) { + throw new Error('applicationDefinition.throttlingPolicy is required'); + } +}; diff --git a/lib/src/wso2/wso2-utils.ts b/lib/src/wso2/wso2-utils.ts index 623a9d7..58faa3d 100644 --- a/lib/src/wso2/wso2-utils.ts +++ b/lib/src/wso2/wso2-utils.ts @@ -1,8 +1,12 @@ +/* eslint-disable no-console */ import https from 'https'; -import axios from 'axios'; +import axios, { AxiosError, AxiosInstance } from 'axios'; import qs from 'qs'; +import { Wso2Config } from './types'; +import { getSecretValue } from './utils'; + export type Wso2ApimConfig = { /** * WSO2 API base Url. E.g.: https://mywso2.com @@ -38,6 +42,101 @@ export type ClientCredentials = { clientSecret: string; }; +export const prepareAxiosForWso2Calls = async (wso2Config: Wso2Config): Promise => { + // get wso2 user/pass + const creds = await getSecretValue(wso2Config.credentialsSecretId); + let wso2Creds; + try { + wso2Creds = JSON.parse(creds); + } catch (err) { + throw new Error( + `Couldn't parse credentials from secret manager at ${wso2Config.credentialsSecretId}. Check if it's a json with attributes {'user':'someuser', 'pwd':'mypass'}. err=${err}`, + ); + } + if (!wso2Creds.user || !wso2Creds.pwd) { + throw new Error( + `'user' and 'pwd' attributes from credentials ${wso2Config.credentialsSecretId} are required`, + ); + } + + // prepare wso2 api client + let username = wso2Creds.user; + if (wso2Config.tenant) { + username = `${wso2Creds.user}@${wso2Config.tenant}`; + } + + const wso2ApimConfig: Wso2ApimConfig = { + baseUrl: wso2Config.baseApiUrl, + username, + password: wso2Creds.pwd, + clientName: 'cdk-practical-constructs-wso2', + }; + + const clientCredentials = await registerClient(wso2ApimConfig); + const accessToken = await getBearerToken(wso2ApimConfig, clientCredentials); + + await checkWso2ServerVersion(wso2ApimConfig, 'v1'); + + const client = axios.create({ + baseURL: wso2Config.baseApiUrl, + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + client.interceptors.request.use((config: any) => { + // eslint-disable-next-line no-param-reassign + config.metadata = { startTime: new Date().getTime() }; + console.log(`> REQUEST: ${config.method?.toUpperCase()} ${wso2Config.baseApiUrl}${config.url}`); + console.log( + JSON.stringify({ + baseURL: config.baseURL, + url: config.url, + params: config.params, + method: config.method, + headers: config.headers, + status: config.status, + data: config.data, + }), + ); + return config; + }); + + client.interceptors.response.use( + (response) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const elapsedTime = new Date().getTime() - response.config.metadata.startTime; + console.log(`> RESPONSE: ${response.status} (${elapsedTime}ms)`); + console.log( + JSON.stringify({ + status: response.status, + headers: response.headers, + data: response.data, + }), + ); + return response; + }, + // eslint-disable-next-line promise/prefer-await-to-callbacks + (error: Error | AxiosError) => { + if (axios.isAxiosError(error)) { + console.log(`RESPONSE ERROR: status=${error.response?.status}`); + console.log( + JSON.stringify({ + status: error.response?.status, + headers: error.response?.headers, + data: error.response?.data, + }), + ); + } + throw error; + }, + ); + + return client; +}; + export const registerClient = async (config: Wso2ApimConfig): Promise => { const data = { clientName: config.clientName ?? 'wso2apim-sdk-client',