diff --git a/README.md b/README.md index f2237cb..41a1b6a 100644 --- a/README.md +++ b/README.md @@ -61,3 +61,372 @@ will automatically: container. ## Application lifecycle + +### 1. Initialize the app + +Ideally all of the projen stuff would be part of a separate library (not done to +keep everything in this project) so that I could do something like: + +```console +npx projen new --from my-projen-lib@1.0.0 +``` + +This would initialize the projen project with all of the customizations. My +organization could have projen templates for common project types (i.e. `--from +@my-org/projen-website`, `--from @my-org/projen-serverless`, `--from +@my-org/projen-ecs`, etc) + +### 2. Create the application Stage + +A CDK Stage is an abstraction that describes a single logical, cohesive +deployable unit of your application. Within a Stage we will define all of +our Stacks which should be deployed together. Once we define our stage, we can +then instantiate our stage multiple times to model multiple copies of our +application which could be deployed to different environments. + +I start by creating the Stage because the first stage that I will instantiate +will be my development stage. This allows me to iterate in my development +environment and then when I am ready to deploy to production I can just +instantiate a new instance of the stage. + +_src/app.ts_ +```ts +import { Stage, StageProps } from 'aws-cdk-lib/core'; +import { Construct } from 'constructs'; + +export class AppStage extends Stage { + constructor(scope: Construct, id: string, props?: StageProps) { + super(scope, id, props); + } +} +``` + +_src/main.ts_ +```ts +import { App } from 'aws-cdk-lib/core'; +import { AppStage } from './app'; + +const app = new App(); + +new AppStage(app, 'DevStage', { + env: { + region: 'us-east-2', + account: process.env.CDK_DEFAULT_ACCOUNT, // my personal dev account + }, +}); +``` + +Now my app has a single Stage that will deploy to my personal development +account. + +### 3. Start building the first component + +#### Create Lambda handler + +I'll start developing my first component (green). This component will be a +`construct` so that I could build and test it independently. + +I'll create a new folder and file for the lambda handler [src/posts/create.lambda.ts](./src/posts/create.lambda.ts). +After re-running projen I should see a new file that was generated and contains +the CDK Lambda function [src/posts/create-function.ts](./src/posts/create-function.ts). + +I can also create unit tests for the handler [test/posts/create.lambda.test.ts](./test/posts/create.lambda.test.ts) + +At this point I will probably run `yarn test:watch` and iterate on the handler +code and unit tests. + +Once I am ready to start testing it as a Lambda function, I can create +the `construct` for the component. + +_src/components/create-post.ts_ +```ts +export interface CreatePostProps { + +} + +export class CreatePost extends Construct { + constructor(scope: Construct, id: string, props: CreatePostProps) { + super(scope, id); + new CreateFunction(this, 'CreatePost'); + } +} +``` + +I can also create the unit test for this component [test/components/create-post.test.ts](./test/components/create-post.test.ts). + +#### Create integration test + +Once I have the component created I can create the integration test which will +allow me to iterate in the cloud. + +[test/components/integ.create-post.ts](./test/components/integ.create-post.ts) +```ts +export class TestCase extends Stack { + constructor(scope: Construct, id: string, props?: StackProps) { + super(scope, id, props); + + new CreatePost(this, 'CreatePost', { }); + } +} + +const testCase = new TestCase(app, 'integ-create-post', { }); + +const integ = new IntegTest(app, 'integ-test', { + testCases: [testCase], + diffAssets: true, +}); +``` + +I can then deploy this in `watch` mode. This allows me to watch for changes and +automatically deploy updates to just this component. + +```console +yarn integ-runner --watch test/components/integ.create-post.ts +``` + +Now that it is deployed successfully I can go back and add the missing features +to the component. + +#### Iterate and get component working + +I have the Lambda function deployed, but I need to create an API Gateway with a +route to the Lambda function. I also need to create the DynamoDB table which +will contain the data for the app. + +I'll update the integration test and add the API Gateway HTTP API. +_integ.create-post.ts_ +```ts +export class TestCase extends Stack { + constructor(scope: Construct, id: string, props?: StackProps) { + super(scope, id, props); + + const api = new HttpApi(this, 'IntegApi'); + const createPost = new CreatePost(this, 'CreatePost', { + api, + }); + } +} +``` + +Now I need to update the `CreatePost` construct to take the `HttpApi` and add +the route. + +_src/components/create-post.ts_ +```ts +export interface CreatePostProps { + /** + * The HTTP Api + */ + readonly api: HttpApi; +} + +export class CreatePost extends Construct { + constructor(scope: Construct, id: string, props: CreatePostProps) { + super(scope, id); + const app = new CreateFunction(this, 'CreatePost'); + props.api.addRoutes({ + path: '/posts', + methods: [HttpMethod.POST], + integration: new HttpLambdaIntegration('createPost', app), + }); + } +} +``` + +Next I'll setup the connection to the DynamoDB table. + +_src/components/create-post.ts_ +```ts +export interface CreatePostProps { + ..., + readonly table: ITable; +} +const app = new CreateFunction(this, 'CreatePost', { + environment: { + TABLE_NAME: props.table.tableName, + }, +}); +``` + +_integ.create-post.ts_ +```ts +export class TestCase extends Stack { + constructor(scope: Construct, id: string, props?: StackProps) { + super(scope, id, props); + + const api = new HttpApi(this, 'IntegApi'); + const table = new Table(this, 'IntegTable', { + partitionKey: { + name: 'pk', + type: AttributeType.STRING, + }, + }); + const createPost = new CreatePost(this, 'CreatePost', { + api, + table, + }); + } +} +``` + +At this point I can manually invoke the Api endpoint, but instead I'll setup +some automated assertion tests. + +Lets create a new file to contain our test cases: + +[test/components/test-cases.ts](./test/components/test-cases.ts) +```ts +const testCases: Post[] = [ + { + author: 'corymhall', + content: 'This is a test post', + createdAt: new Date().toISOString(), + pk: '1', + status: Status.PUBLISHED, + summary: 'Summary', + }, +]; +``` + +I can reuse these test cases between my unit tests and my integration tests. + +_test/components/integ-create-post.ts_ +```ts +const integ = new IntegTest(...); + +for (const test of testCases) { + integ.assertions.httpApiCall(`${testCase.api.url!}posts`, { + method: 'POST', + body: JSON.stringify(test), + }).next( + integ.assertions.awsApiCall('DynamoDB', 'getItem', { + Key: { + pk: { S: test.pk }, + }, + TableName: testCase.table.tableName, + }).expect( + ExpectedResult.objectLike({ + Item: marshall({ + author: test.author, + content: test.content, + pk: test.pk, + status: test.status, + summary: test.summary, + }), + }), + ), + ); +} +``` + +For every test case that I add a new assertion test will be created which will +invoke the HTTP Api and then query the DynamoDB table for the record that should +have just been created and assert that the record matches. + +Now every time I save a file my unit tests will run and my integration test will +run and I will be able to see if the assertion tests have succeeded or not. I +can also setup these integration tests to be automatically run as part of CI/CD. + +### 4. Start building the second component + +Building the second component will largely follow the same process as what was +used for the first component so I won't go over it again here. The component 2 +files can be viewed here: + +- [components/get-post.ts](./src/components/get-post.ts) +- [posts/get-post.ts](./src/posts/get-post.ts) +- [posts/get-post.ecs-task.ts](./src/posts/get-post.ecs-task.ts) +- [constructs/fargate-service.ts](./src/constructs/fargate-service.ts) +- [constructs/extensions.ts](./src/constructs/extensions.ts) +- [test/posts/get-post.ecs-task.test.ts](./test/posts/get-post.ecs-task.test.ts) +- [test/posts/get-post.test.ts](./test/components/get-post.test.ts) +- [test/components/integ.get-post.ts](./test/components/integ.get-post.ts) + + +### 5. Putting it together + +Now that we've got both components built and can test them independently, we can +put them together in our `AppStage`. + +_see file_ +[src/app.ts](./src/app.ts) + +I'll then create a new integ test for the app stage. + +_test/integ.app.ts_ +```ts +const app = new App(); + +const appStage = new AppStage(app, 'BlogAppIntegStage', { + env: { + region: 'us-west-2', + account: process.env.CDK_DEFAULT_ACCOUNT, + }, +}); +``` + +And I'll add assertions that use both components, using the same test cases as +before. + +```ts +const integ = new IntegTest(...); + +testCases.forEach(test => { + integ.assertions.httpApiCall(`${appStage.api.url!}posts`, { + method: 'POST', + body: JSON.stringify(test), + }).next( + integ.assertions.httpApiCall(`${appStage.api.url!}posts/${test.pk}`, { + }).expect(ExpectedResult.objectLike({ + body: { + author: test.author, + content: test.content, + pk: test.pk, + status: test.status, + summary: test.summary, + }, + })), + ); +}); +``` + +### 6. Create deployment pipeline + +Now that I'm ready to deploy my application to my pre-prod and prod environments +I'll create a deployment pipeline. + +_src/main.ts_ +```ts +const pipeline = new CodePipeline(pipelineStack, 'DeliveryPipeline', { + synth: new ShellStep('synth', { + ..., + commands: [ + 'yarn install --frozen-lockfile', + 'npx cdk synth', + ], + }), + crossAccountKeys: true, + useChangeSets: false, +}); +``` + +And add my deployment stages for each environment. + +```ts +pipeline.addStage(new AppStage(app, 'PreProdStage', { + env: { + region: 'us-east-2', + account: 'PRE_PROD_ACCOUNT', // pre-prod account + }, +})); + +/** + * Add a stage to the deployment pipeline for my pre-prod environment + */ +pipeline.addStage(new AppStage(app, 'ProdStage', { + env: { + region: 'us-east-2', + account: 'PROD_ACCOUNT', // prod account + }, +})); +``` diff --git a/src/main.ts b/src/main.ts index 227d6cd..1b947eb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,10 +6,7 @@ import { AppStage } from './app'; const app = new App({ policyValidationBeta1: [new CfnGuardValidator({ - controlTowerRulesEnabled: true, - // rules: [ - // '/home/hallcor/work/cdklabs/cdk-validator-cfnguard/main/rules/control-tower/cfn-guard/ecs/ct-ecs-pr-1.guard', - // ], + controlTowerRulesEnabled: false, })], postCliContext: { '@aws-cdk/core:validationReportJson': true, diff --git a/test/components/create-post.test.ts b/test/components/create-post.test.ts index 76ac8c7..220143c 100644 --- a/test/components/create-post.test.ts +++ b/test/components/create-post.test.ts @@ -1,9 +1,9 @@ import { Stack } from 'aws-cdk-lib'; import { Match, Template } from 'aws-cdk-lib/assertions'; import { AttributeType, Table } from 'aws-cdk-lib/aws-dynamodb'; -import { MonitoringFacade } from 'cdk-monitoring-constructs'; import { CreatePost } from '../../src/components/create-post'; import { Api } from '../../src/constructs/api'; +import { Monitoring } from '../../src/constructs/monitoring'; /** * In this test we are testing the "CreatePost" construct @@ -11,7 +11,7 @@ import { Api } from '../../src/constructs/api'; */ test('resources are created with expected properties', () => { const stack = new Stack(); - const monitor = new MonitoringFacade(stack, 'Monitor'); + const monitor = new Monitoring(stack, 'Monitor'); new CreatePost(stack, 'CreatePost', { api: new Api(stack, 'Api', { monitor, diff --git a/test/components/get-post.test.ts b/test/components/get-post.test.ts index 39c2fdf..2f40a49 100644 --- a/test/components/get-post.test.ts +++ b/test/components/get-post.test.ts @@ -1,10 +1,10 @@ import { Stack } from 'aws-cdk-lib'; import { Match, Template } from 'aws-cdk-lib/assertions'; import { AttributeType, Table } from 'aws-cdk-lib/aws-dynamodb'; -import { MonitoringFacade } from 'cdk-monitoring-constructs'; import { GetPost } from '../../src/components/get-post'; import { Network } from '../../src/components/network'; import { Api } from '../../src/constructs/api'; +import { Monitoring } from '../../src/constructs/monitoring'; /** * In this test we are testing the "CreatePost" construct @@ -13,7 +13,7 @@ import { Api } from '../../src/constructs/api'; test('resources are created with expected properties', () => { // GIVEN const stack = new Stack(); - const monitor = new MonitoringFacade(stack, 'Monitor'); + const monitor = new Monitoring(stack, 'Monitor'); const network = new Network(stack, 'Network'); // WHEN diff --git a/test/constructs/api.test.ts b/test/constructs/api.test.ts index d5274ab..04a88be 100644 --- a/test/constructs/api.test.ts +++ b/test/constructs/api.test.ts @@ -2,14 +2,11 @@ import { HttpMethod } from '@aws-cdk/aws-apigatewayv2-alpha'; import { Stack } from 'aws-cdk-lib'; import { Match, Template } from 'aws-cdk-lib/assertions'; import { Vpc } from 'aws-cdk-lib/aws-ec2'; -import { AwsLogDriver, ContainerImage, ITaskDefinitionExtension, Protocol } from 'aws-cdk-lib/aws-ecs'; import { Code, Function, Runtime } from 'aws-cdk-lib/aws-lambda'; -import { ILogGroup, LogGroup } from 'aws-cdk-lib/aws-logs'; -import { MonitoringFacade } from 'cdk-monitoring-constructs'; import { Network } from '../../src/components/network'; import { Api } from '../../src/constructs/api'; import { ApiGatewayService } from '../../src/constructs/fargate-service'; -import { IContainer } from '../../src/types'; +import { Monitoring } from '../../src/constructs/monitoring'; import { TestAppContainer } from '../utils'; /** @@ -23,13 +20,12 @@ test('resources should not be replaced', () => { // WHEN const stack = new Stack(); new Api(stack, 'Api', { - monitor: new MonitoringFacade(stack, 'Monitoring'), + monitor: new Monitoring(stack, 'Monitoring'), }); // THEN Template.fromStack(stack).templateMatches({ Resources: Match.objectLike({ - MonitoringMonitoringDashboardsDashboard5649A1D9: Match.objectLike({ Type: 'AWS::CloudWatch::Dashboard' }), ApiF70053CD: Match.objectLike({ Type: 'AWS::ApiGatewayV2::Api' }), ApiDefaultStage189A7074: Match.objectLike({ Type: 'AWS::ApiGatewayV2::Stage' }), }), @@ -47,7 +43,7 @@ test('will create a vpc link', () => { const vpc = new Vpc(stack, 'Vpc'); new Api(stack, 'Api', { vpc, - monitor: new MonitoringFacade(stack, 'Monitoring'), + monitor: new Monitoring(stack, 'Monitoring'), }); // THEN @@ -65,7 +61,7 @@ test('can add a service route', () => { // GIVEN const stack = new Stack(); const network = new Network(stack, 'Network'); - const monitor = new MonitoringFacade(stack, 'Monitoring'); + const monitor = new Monitoring(stack, 'Monitoring'); const api = new Api(stack, 'Api', { monitor, vpc: network.cluster.vpc, @@ -77,7 +73,6 @@ test('can add a service route', () => { methods: [HttpMethod.GET], app: new ApiGatewayService(stack, 'Service', { appContainer: new TestAppContainer(), - monitor, cluster: network.cluster, }), }); @@ -116,7 +111,7 @@ test('can add a service route', () => { test('can add a lambda route', () => { // GIVEN const stack = new Stack(); - const monitor = new MonitoringFacade(stack, 'Monitoring'); + const monitor = new Monitoring(stack, 'Monitoring'); const api = new Api(stack, 'Api', { monitor, }); diff --git a/test/constructs/fargate-service.test.ts b/test/constructs/fargate-service.test.ts index 0223413..359e77b 100644 --- a/test/constructs/fargate-service.test.ts +++ b/test/constructs/fargate-service.test.ts @@ -1,6 +1,5 @@ import { Stack } from 'aws-cdk-lib'; import { Match, Template } from 'aws-cdk-lib/assertions'; -import { MonitoringFacade } from 'cdk-monitoring-constructs'; import { Network } from '../../src/components/network'; import { ApiGatewayService } from '../../src/constructs/fargate-service'; import { TestAppContainer } from '../utils'; @@ -18,7 +17,6 @@ test('resources should not be replaced', () => { new ApiGatewayService(stack, 'Service', { appContainer: new TestAppContainer(), cluster: network.cluster, - monitor: new MonitoringFacade(stack, 'Monitor'), }); // THEN diff --git a/test/integ.app.ts b/test/integ.app.ts index 1cf6789..1dac91a 100644 --- a/test/integ.app.ts +++ b/test/integ.app.ts @@ -16,7 +16,7 @@ const appStage = new AppStage(app, 'BlogAppIntegStage', { }, }); -appStage.stack.exportValue(appStage.api.apiEndpoint); +appStage.appStack.exportValue(appStage.api.apiEndpoint); const testStack = new Stack(appStage, 'BlogAppIntegAssertionsStack', { env: { region: 'us-west-2', diff --git a/test/utils.ts b/test/utils.ts index 314b2d0..81ea2ac 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -4,6 +4,7 @@ import { ILogGroup } from 'aws-cdk-lib/aws-logs'; import { IContainer } from '../src/types'; export class TestAppContainer implements IContainer { + id = 'TestContainer'; bind(logGroup: ILogGroup): ITaskDefinitionExtension { return { extend(taskDefinition) {