Skip to content

Commit

Permalink
updating README and fixing build
Browse files Browse the repository at this point in the history
  • Loading branch information
corymhall committed Jul 26, 2023
1 parent 77d79d3 commit 0dae1b5
Show file tree
Hide file tree
Showing 8 changed files with 381 additions and 21 deletions.
369 changes: 369 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 [email protected]
```

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
},
}));
```
5 changes: 1 addition & 4 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading

0 comments on commit 0dae1b5

Please sign in to comment.