Amazon CodeCatalyst Pipeline¶
This presents a reference implementation of the Application Pipeline reference architecture. The pipeline is created with Amazon CodeCatalyst for building the software and performing testing tasks. All the infrastructure for this reference implementation is defined with AWS Cloud Development Kit. The source code for this reference implementation is available in GitHub for review.
This reference implementation has been contributed to Amazon CodeCatalyst as blueprint named DevOps deployment pipeline
. You can try out this reference implementation in your own CodeCatalyst space by creating a new project from the blueprint.
Disclaimer
This reference implementation is intended to serve as an example of how to accomplish the guidance in the reference architecture using Amazon CodeCatalyst. The reference implementation has intentionally not followed the following AWS Well-Architected best practices to make it accessible by a wider range of customers. Be sure to address these before using parts of this code for any workloads in your own environment:
- cdk bootstrap with AdministratorAccess - the default policy used for
cdk bootstrap
isAdministratorAccess
but should be replaced with a more appropriate policy with least privilege in your account. - TLS on HTTP endpoint - the listener for the sample application uses HTTP instead of HTTPS to avoid having to create new ACM certificates and Route53 hosted zones. This should be replaced in your account with an
HTTPS
listener.
Local Development¶
Developers need fast-feedback for potential issues with their code. Automation should run in their developer workspace to give them feedback before the deployment pipeline runs.
Pre-Commit Hooks
Pre-Commit hooks are scripts that are executed on the developer's workstation when they try to create a new commit. These hooks have an opportunity to inspect the state of the code before the commit occurs and abort the commit if tests fail. An example of pre-commit hooks are Git hooks. Examples of tools to configure and store pre-commit hooks as code include but are not limited to husky and pre-commit.
The following .pre-commit-config.yaml
is added to the repository that will build the code with Maven, run unit tests with JUnit, check for code quality with Checkstyle, run static application security testing with PMD and check for secrets in the code with gitleaks.
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v2.3.0
hooks:
- id: check-yaml
- id: check-json
- id: trailing-whitespace
- repo: https://github.com/pre-commit/mirrors-eslint
rev: v8.23.0
hooks:
- id: eslint
- repo: https://github.com/ejba/pre-commit-maven
rev: v0.3.3
hooks:
- id: maven-test
- repo: https://github.com/zricethezav/gitleaks
rev: v8.12.0
hooks:
- id: gitleaks
Source¶
Application Source Code
The application source code can be found in the src/main/java directory. It is intended to serve only as a reference and should be replaced by your own application source code.
This reference implementation includes a Spring Boot application that exposes a REST API and uses a database for persistence. The API is implemented in FruitController.java
:
public class FruitController {
/**
* JPA repository for fruits.
*/
private final FruitRepository repository;
/**
* Logic to map between entities and DTOs
*/
private final FruitMapper mapper;
FruitController(final FruitRepository r, final FruitMapper m) {
this.repository = r;
this.mapper = m;
}
@GetMapping("/api/fruits")
List<FruitDTO> all() {
return repository.findAll()
.stream()
.map(mapper::toDto)
.collect(Collectors.toList());
}
@PostMapping("/api/fruits")
FruitDTO newFruit(@RequestBody final FruitDTO fruit) {
return mapper.toDto(repository.save(mapper.toEntity(fruit)));
}
@GetMapping("/api/fruits/{id}")
FruitDTO one(@PathVariable final Long id) {
return repository.findById(id)
.map(mapper::toDto)
.orElseThrow(() -> new FruitNotFoundException(id));
}
@PutMapping("/api/fruits/{id}")
FruitDTO replaceFruit(
@RequestBody final FruitDTO newFruit,
@PathVariable final Long id) {
newFruit.setId(id);
return mapper.toDto(repository.save(mapper.toEntity(newFruit)));
}
@DeleteMapping("/api/fruits/{id}")
void deleteFruit(@PathVariable final Long id) {
repository.deleteById(id);
}
}
The application source code is stored in Amazon CodeCatalyst repository that is created and initialized from the blueprint.
Test Source Code
The test source code can be found in the src/test/java directory. It is intended to serve only as a reference and should be replaced by your own test source code.
The reference implementation includes source code for unit, integration and end-to-end testing. Unit and integration tests can be found in src/test/java
. For example, FruitControllerWithoutClassificationTest.java
performs unit tests of each API path with the JUnit testing library:
public void shouldReturnList() throws Exception {
when(repository.findAll()).thenReturn(Arrays.asList(new Fruit("Mango", FruitClassification.pome), new Fruit("Dragonfruit", FruitClassification.berry)));
this.mockMvc.perform(get("/api/fruits")).andDo(print()).andExpect(status().isOk())
.andExpect(content().json("[{\"name\": \"Mango\"}, {\"name\": \"Dragonfruit\"}]"));
}
Acceptance tests are preformed with SoapUI and are defined in fruit-api-soapui-project.xml
. They are executed by Maven using plugins in pom.xml
.
Infrastructure Source Code
The infrastructure source code can be found in the infrastructure directory. It is intended to serve as a reference but much of the code can also be reused in your own CDK applications.
Infrastructure source code defines the deployment of the application are stored in infrastructure/
folder and uses AWS Cloud Development Kit.
super(scope, id, props);
const image = new AssetImage('.', { target: 'build' });
const appName = Stack.of(this).stackName.toLowerCase().replace(`-${Stack.of(this).region}-`, '-');
const vpc = new ec2.Vpc(this, 'Vpc', {
maxAzs: 3,
natGateways: props?.natGateways,
});
new FlowLog(this, 'VpcFlowLog', { resourceType: FlowLogResourceType.fromVpc(vpc) });
const dbName = 'fruits';
const dbSecret = new DatabaseSecret(this, 'AuroraSecret', {
username: 'fruitapi',
secretName: `${appName}-DB`,
});
const db = new ServerlessCluster(this, 'AuroraCluster', {
engine: DatabaseClusterEngine.AURORA_MYSQL,
vpc,
credentials: Credentials.fromSecret(dbSecret),
defaultDatabaseName: dbName,
deletionProtection: false,
clusterIdentifier: appName,
});
const cluster = new ecs.Cluster(this, 'Cluster', {
vpc,
containerInsights: true,
clusterName: appName,
});
const appLogGroup = new LogGroup(this, 'AppLogGroup', {
retention: RetentionDays.ONE_WEEK,
logGroupName: `/aws/ecs/service/${appName}`,
removalPolicy: RemovalPolicy.DESTROY,
});
let deploymentConfig: IEcsDeploymentConfig | undefined = undefined;
if (props?.deploymentConfigName) {
deploymentConfig = EcsDeploymentConfig.fromEcsDeploymentConfigName(this, 'DeploymentConfig', props.deploymentConfigName);
}
const appConfigEnabled = props?.appConfigRoleArn !== undefined && props.appConfigRoleArn.length > 0;
const service = new ApplicationLoadBalancedCodeDeployedFargateService(this, 'Api', {
cluster,
capacityProviderStrategies: [
{
capacityProvider: 'FARGATE_SPOT',
weight: 1,
},
],
minHealthyPercent: 50,
maxHealthyPercent: 200,
desiredCount: 3,
cpu: 512,
memoryLimitMiB: 1024,
taskImageOptions: {
image,
containerName: 'api',
containerPort: 8080,
family: appName,
logDriver: AwsLogDriver.awsLogs({
logGroup: appLogGroup,
streamPrefix: 'service',
}),
secrets: {
SPRING_DATASOURCE_USERNAME: Secret.fromSecretsManager( dbSecret, 'username' ),
SPRING_DATASOURCE_PASSWORD: Secret.fromSecretsManager( dbSecret, 'password' ),
},
environment: {
SPRING_DATASOURCE_URL: `jdbc:mysql://${db.clusterEndpoint.hostname}:${db.clusterEndpoint.port}/${dbName}`,
APPCONFIG_AGENT_APPLICATION: this.node.tryGetContext('workloadName'),
APPCONFIG_AGENT_ENVIRONMENT: this.node.tryGetContext('environmentName'),
APPCONFIG_AGENT_ENABLED: appConfigEnabled.toString(),
},
},
deregistrationDelay: Duration.seconds(5),
responseTimeAlarmThreshold: Duration.seconds(3),
targetHealthCheck: {
healthyThresholdCount: 2,
unhealthyThresholdCount: 2,
interval: Duration.seconds(60),
path: '/actuator/health',
},
deploymentConfig,
terminationWaitTime: Duration.minutes(5),
apiCanaryTimeout: Duration.seconds(5),
apiTestSteps: [{
name: 'getAll',
path: '/api/fruits',
jmesPath: 'length(@)',
expectedValue: 5,
}],
});
if (appConfigEnabled) {
service.taskDefinition.addContainer('appconfig-agent', {
image: ecs.ContainerImage.fromRegistry('public.ecr.aws/aws-appconfig/aws-appconfig-agent:2.x'),
essential: false,
logging: AwsLogDriver.awsLogs({
logGroup: appLogGroup,
streamPrefix: 'service',
}),
environment: {
SERVICE_REGION: this.region,
ROLE_ARN: props!.appConfigRoleArn!,
ROLE_SESSION_NAME: appName,
LOG_LEVEL: 'info',
},
portMappings: [{ containerPort: 2772 }],
});
service.taskDefinition.addToTaskRolePolicy(new PolicyStatement({
actions: ['sts:AssumeRole'],
resources: [props!.appConfigRoleArn!],
}));
}
service.service.connections.allowTo(db, Port.tcp(db.clusterEndpoint.port));
this.apiUrl = new CfnOutput(this, 'endpointUrl', {
value: `http://${service.listener.loadBalancer.loadBalancerDnsName}`,
});
Notice that the infrastructure code is written in Typescript which is different from the Application Source Code (Java). This was done intentionally to demonstrate that CDK allows defining infrastructure code in whatever language is most appropriate for the team that owns the use of CDK in the organization.
Static Assets
There are no static assets used by the sample application.
Dependency Manifests
All third-party dependencies used by the sample application are define in the pom.xml
:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.liquibase</groupId>
<artifactId>liquibase-core</artifactId>
</dependency>
</dependencies>
Static Configuration
Static configuration for the application is defined in src/main/resources/application.yml
:
spring:
application:
name: fruit-api
main:
banner-mode: "off"
jackson:
default-property-inclusion: non_null
springdoc:
swagger-ui:
path: /swagger-ui
appconfig-agent:
environment: alpha
log-level-from:
configuration: operations
Database Source Code
The database source code can be found in the src/main/resources/db directory. It is intended to serve only as a reference and should be replaced by your own database source code.
The code that manages the schema and initial data for the application is defined using Liquibase in src/main/resources/db/changelog/db.changelog-master.yml
:
databaseChangeLog:
- changeSet:
id: "1"
author: AWS
changes:
- createTable:
tableName: fruit
columns:
- column:
name: id
type: bigint
autoIncrement: true
constraints:
primaryKey: true
nullable: false
- column:
name: name
type: varchar(250)
- insert:
tableName: fruit
columns:
- column:
name: name
value: Apple
- insert:
tableName: fruit
columns:
- column:
name: name
value: Orange
- insert:
tableName: fruit
columns:
- column:
name: name
value: Banana
- insert:
tableName: fruit
columns:
- column:
name: name
value: Cherry
- insert:
tableName: fruit
columns:
- column:
name: name
value: Grape
- changeSet:
id: "2"
author: AWS
changes:
- addColumn:
tableName: fruit
columns:
- column:
name: classification
type: varchar(250)
constraints:
nullable: true
- update:
tableName: fruit
columns:
- column:
name: classification
value: pome
where: name='Apple'
- update:
tableName: fruit
columns:
- column:
name: classification
value: berry
where: name='Orange'
- update:
tableName: fruit
columns:
- column:
name: classification
value: berry
where: name='Banana'
- update:
tableName: fruit
columns:
- column:
name: classification
value: drupe
where: name='Cherry'
- update:
tableName: fruit
columns:
- column:
name: classification
value: berry
where: name='Grape'
Build¶
Actions in this stage all run in less than 10 minutes so that developers can take action on fast feedback before moving on to their next task. Each of the actions below are defined as code with AWS Cloud Development Kit.
Build Code
The Java source code is compiled, unit tested and packaged by Maven. An action is added to the workflow to build and package the source code:
Package:
Identifier: aws/build@v1
Inputs:
Sources:
- WorkflowSource
Outputs:
AutoDiscoverReports:
Enabled: true
ReportNamePrefix: build
SuccessCriteria:
PassRate: 100
Artifacts:
- Name: package
Files:
- "**/*"
Configuration:
Steps:
- Run: mvn verify --batch-mode --no-transfer-progress
Unit Tests
The unit tests are run by Maven at the same time the Build Code
action occurs. The results of the unit tests are uploaded to AWS Code Build Test Reports to track over time.
Code Quality
Code quality is enforced through the PMD and Checkstyle Maven plugins:
<plugin>
<artifactId>maven-pmd-plugin</artifactId>
<configuration>
<printFailingErrors></printFailingErrors>
</configuration>
<executions>
<execution>
<phase>test</phase>
<goals>
<goal>check</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<artifactId>maven-checkstyle-plugin</artifactId>
<configuration>
<printFailingErrors></printFailingErrors>
</configuration>
<executions>
<execution>
<phase>test</phase>
<goals>
<goal>check</goal>
</goals>
</execution>
</executions>
</plugin>
Additionally, cdk-nag is run against the deployment stack to identify any security issues with the resources being created. The pipeline will fail if any are detected. The following code demonstrates how cdk-nag is called as a part of the build stage. The code also demonstrates how to suppress findings.
import { App, Aspects } from 'aws-cdk-lib';
import { Annotations, Match, Template } from 'aws-cdk-lib/assertions';
import { SynthesisMessage } from 'aws-cdk-lib/cx-api';
import { AwsSolutionsChecks, NagSuppressions } from 'cdk-nag';
import { DeploymentStack } from '../src/deployment';
function synthesisMessageToString(sm: SynthesisMessage): string {
return `${sm.entry.data} [${sm.id}]`;
}
expect.addSnapshotSerializer({
test: (val) => typeof val === 'string' && val.match(/^dummy.dkr.ecr.us-east.1/) !== null,
serialize: () => '"dummy-ecr-image"',
});
expect.addSnapshotSerializer({
test: (val) => typeof val === 'string' && val.match(/^[a-f0-9]+\.zip$/) !== null,
serialize: () => '"code.zip"',
});
describe('cdk-nag', () => {
let stack: DeploymentStack;
let app: App;
beforeAll(() => {
const appName = 'fruit-api';
const workloadName = 'food';
const environmentName = 'unit-test';
app = new App({ context: { appName, environmentName, workloadName } });
stack = new DeploymentStack(app, 'TestStack', {
env: {
account: 'dummy',
region: 'us-east-1',
},
});
Aspects.of(stack).add(new AwsSolutionsChecks());
// Suppress CDK-NAG for TaskDefinition role and ecr:GetAuthorizationToken permission
NagSuppressions.addResourceSuppressionsByPath(
stack,
`/${stack.stackName}/Api/TaskDef/ExecutionRole/DefaultPolicy/Resource`,
[{ id: 'AwsSolutions-IAM5', reason: 'Allow ecr:GetAuthorizationToken', appliesTo: ['Resource::*'] }],
);
// Suppress CDK-NAG for secret rotation
NagSuppressions.addResourceSuppressionsByPath(
stack,
`/${stack.stackName}/AuroraSecret/Resource`,
[{ id: 'AwsSolutions-SMG4', reason: 'Dont require secret rotation' }],
);
// Suppress CDK-NAG for RDS Serverless
NagSuppressions.addResourceSuppressionsByPath(
stack,
`/${stack.stackName}/AuroraCluster/Resource`,
[
{ id: 'AwsSolutions-RDS6', reason: 'IAM authentication not supported on Serverless v1' },
{ id: 'AwsSolutions-RDS10', reason: 'Disable delete protection to simplify cleanup of Reference Implementation' },
{ id: 'AwsSolutions-RDS11', reason: 'Custom port not supported on Serverless v1' },
{ id: 'AwsSolutions-RDS14', reason: 'Backtrack not supported on Serverless v1' },
{ id: 'AwsSolutions-RDS16', reason: 'CloudWatch Log Export not supported on Serverless v1' },
],
);
NagSuppressions.addResourceSuppressionsByPath(stack, [
`/${stack.stackName}/Api/DeploymentGroup/Deployment/DeploymentProvider/framework-onEvent`,
`/${stack.stackName}/Api/DeploymentGroup/Deployment/DeploymentProvider/framework-isComplete`,
`/${stack.stackName}/Api/DeploymentGroup/Deployment/DeploymentProvider/framework-onTimeout`,
`/${stack.stackName}/Api/DeploymentGroup/Deployment/DeploymentProvider/waiter-state-machine`,
], [
{ id: 'AwsSolutions-IAM5', reason: 'Unrelated to construct under test' },
{ id: 'AwsSolutions-L1', reason: 'Unrelated to construct under test' },
{ id: 'AwsSolutions-SF1', reason: 'Unrelated to construct under test' },
{ id: 'AwsSolutions-SF2', reason: 'Unrelated to construct under test' },
], true);
// Ignore findings from access log bucket
NagSuppressions.addResourceSuppressionsByPath(stack, [
`/${stack.stackName}/Api/AccessLogBucket`,
], [
{ id: 'AwsSolutions-S1', reason: 'Dont need access logs for access log bucket' },
{ id: 'AwsSolutions-IAM5', reason: 'Allow resource:*', appliesTo: ['Resource::*'] },
]);
NagSuppressions.addResourceSuppressionsByPath(stack, [
`/${stack.stackName}/Api/Canary/ServiceRole`,
], [{ id: 'AwsSolutions-IAM5', reason: 'Allow resource:*' }]);
NagSuppressions.addResourceSuppressionsByPath(stack, [
`/${stack.stackName}/Api/CanaryArtifactsBucket`,
], [{ id: 'AwsSolutions-S1', reason: 'Dont need access logs for canary bucket' }]);
NagSuppressions.addResourceSuppressionsByPath(stack, [
`/${stack.stackName}/Api/DeploymentGroup/ServiceRole`,
], [
{ id: 'AwsSolutions-IAM4', reason: 'Allow AWSCodeDeployRoleForECS policy', appliesTo: ['Policy::arn:<AWS::Partition>:iam::aws:policy/AWSCodeDeployRoleForECS'] },
]);
NagSuppressions.addResourceSuppressionsByPath(stack, [
`/${stack.stackName}/Api/DeploymentGroup/Deployment`,
], [
{
id: 'AwsSolutions-IAM4',
reason: 'Allow AWSLambdaBasicExecutionRole policy',
appliesTo: ['Policy::arn:<AWS::Partition>:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole'],
},
], true);
NagSuppressions.addResourceSuppressionsByPath(stack, [
`/${stack.stackName}/Api/TaskDef`,
], [
{
id: 'AwsSolutions-ECS2',
reason: 'Allow environment variables for configuration of values that are not confidential',
},
]);
NagSuppressions.addResourceSuppressionsByPath(stack, [
`/${stack.stackName}/Api/LB/SecurityGroup`,
], [
{
id: 'AwsSolutions-EC23',
reason: 'Allow public inbound access on ELB',
},
]);
});
test('Snapshot', () => {
const template = Template.fromStack(stack);
expect(template.toJSON()).toMatchSnapshot();
});
test('cdk-nag AwsSolutions Pack errors', () => {
const errors = Annotations.fromStack(stack).findError(
'*',
Match.stringLikeRegexp('AwsSolutions-.*'),
).map(synthesisMessageToString);
expect(errors).toHaveLength(0);
});
test('cdk-nag AwsSolutions Pack warnings', () => {
const warnings = Annotations.fromStack(stack).findWarning(
'*',
Match.stringLikeRegexp('AwsSolutions-.*'),
).map(synthesisMessageToString);
expect(warnings).toHaveLength(0);
});
});
describe('Deployment without AppConfig', () => {
let stack: DeploymentStack;
let app: App;
beforeAll(() => {
const appName = 'fruit-api';
const environmentName = 'unit-test';
app = new App({ context: { appName, environmentName } });
stack = new DeploymentStack(app, 'TestStack', {
env: {
account: 'dummy',
region: 'us-east-1',
},
});
});
test('Snapshot', () => {
const template = Template.fromStack(stack);
expect(template.toJSON()).toMatchSnapshot();
});
test('taskdef', () => {
const template = Template.fromStack(stack);
template.hasResourceProperties('AWS::ECS::TaskDefinition', {
ContainerDefinitions: [
{
Environment: [{
Name: 'SPRING_DATASOURCE_URL',
}, {
Name: 'APPCONFIG_AGENT_APPLICATION',
}, {
Name: 'APPCONFIG_AGENT_ENVIRONMENT',
Value: 'unit-test',
}, {
Name: 'APPCONFIG_AGENT_ENABLED',
Value: 'false',
}],
},
],
});
});
});
describe('Deployment with AppConfig', () => {
let stack: DeploymentStack;
let app: App;
beforeAll(() => {
const appName = 'fruit-api';
const workloadName = 'food';
const environmentName = 'unit-test';
app = new App({ context: { appName, environmentName, workloadName } });
stack = new DeploymentStack(app, 'TestStack', {
appConfigRoleArn: 'dummy-role-arn',
env: {
account: 'dummy',
region: 'us-east-1',
},
});
});
test('Snapshot', () => {
const template = Template.fromStack(stack);
expect(template.toJSON()).toMatchSnapshot();
});
test('taskdef', () => {
const template = Template.fromStack(stack);
template.hasResourceProperties('AWS::ECS::TaskDefinition', {
ContainerDefinitions: [
{
Environment: [{
Name: 'SPRING_DATASOURCE_URL',
}, {
Name: 'APPCONFIG_AGENT_APPLICATION',
Value: 'food',
}, {
Name: 'APPCONFIG_AGENT_ENVIRONMENT',
Value: 'unit-test',
}, {
Name: 'APPCONFIG_AGENT_ENABLED',
Value: 'true',
}],
},
{
Environment: [{
Name: 'SERVICE_REGION',
Value: 'us-east-1',
}, {
Name: 'ROLE_ARN',
Value: 'dummy-role-arn',
}, {
Name: 'ROLE_SESSION_NAME',
}, {
Name: 'LOG_LEVEL',
Value: 'info',
}],
},
],
});
});
});
Secrets Detection
Trivy is used to scan the source for secrets. The Trivy GitHub Action is used in the Amazon CodeCatalyst workflow to perform the scan:
SCA:
Identifier: aws/github-actions-runner@v1
Inputs:
Sources:
- WorkflowSource
Configuration:
Steps:
- name: Trivy Vulnerability Scanner
uses: aquasecurity/trivy-action@master
with:
scan-type: fs
ignore-unfixed: true
format: cyclonedx
output: sbom.json
severity: CRITICAL,HIGH
security-checks: vuln,config,secret
Static Application Security Testing (SAST)
The SpotBugs Maven plugin is used along with the Find Security Bugs plugin to identify OWASP Top 10 and CWE vulnerabilities in the application source code.
Package and Store Artifact(s)
AWS Cloud Development Kit handles the packaging and storing of assets during the Synth
action and Assets
stage. The Synth
action generates the CloudFormation templates to be deployed into the subsequent environments along with staging up the files necessary to create a docker image. The Assets
stage then performs the docker build step to create a new image and push the image to Amazon ECR repositories in each environment account.
Software Composition Analysis (SCA)
Trivy is used to scan the source for vulnerabilities in its dependencies. The pom.xml
and Dockerfile
files are scanned for configuration issues or vulnerabilities in any dependencies. The scanning is accomplished by a CDK construct that creates a CodeBuild job to run trivy
:
SCA:
Identifier: aws/github-actions-runner@v1
Inputs:
Sources:
- WorkflowSource
Configuration:
Steps:
- name: Trivy Vulnerability Scanner
uses: aquasecurity/trivy-action@master
with:
scan-type: fs
ignore-unfixed: true
format: cyclonedx
output: sbom.json
severity: CRITICAL,HIGH
security-checks: vuln,config,secret
Trivy is also used within the Dockerfile
to scan the image after it is built. The docker build
will fail if Trivy finds any vulnerabilities in the final image:
FROM public.ecr.aws/amazoncorretto/amazoncorretto:17-al2022-jdk as build
USER nobody
WORKDIR /app
COPY target/fruit-api.jar /app
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 CMD /bin/curl --fail --silent localhost:8080/actuator/health | grep UP || exit 1
ENTRYPOINT ["java","-jar","/app/fruit-api.jar"]
# Use multi-stage builds to scan newly created image with Trivy. This second stage 'vulnscan'
# isn't published to Amazon ECR and is never run. It is only used to run the Trivy scan
# against the newly created image in the 'build' stage.
#
# This stage must run as root so Trivy can scan all files in the image, not just
# those accessible by the nobody user. The user is switched back to 'nobody' at
# the end to ensure that even if this image is used for something it is done
# without the 'root' user.
FROM build AS vulnscan
USER root
COPY --from=aquasec/trivy:latest /usr/local/bin/trivy /usr/local/bin/trivy
RUN trivy filesystem --exit-code 1 --no-progress --ignore-unfixed -s CRITICAL /
USER nobody
Software Bill of Materials (SBOM)
Trivy generates an SBOM in the form of a CycloneDX JSON report. The SBOM is saved as a CodeCatalyst asset. Trivy supports additional SBOM formats such as SPDX, and SARIF.
Test (Beta)¶
Launch Environment
The infrastructure for each environment is defined in AWS Cloud Development Kit:
super(scope, id, props);
const image = new AssetImage('.', { target: 'build' });
const appName = Stack.of(this).stackName.toLowerCase().replace(`-${Stack.of(this).region}-`, '-');
const vpc = new ec2.Vpc(this, 'Vpc', {
maxAzs: 3,
natGateways: props?.natGateways,
});
new FlowLog(this, 'VpcFlowLog', { resourceType: FlowLogResourceType.fromVpc(vpc) });
const dbName = 'fruits';
const dbSecret = new DatabaseSecret(this, 'AuroraSecret', {
username: 'fruitapi',
secretName: `${appName}-DB`,
});
const db = new ServerlessCluster(this, 'AuroraCluster', {
engine: DatabaseClusterEngine.AURORA_MYSQL,
vpc,
credentials: Credentials.fromSecret(dbSecret),
defaultDatabaseName: dbName,
deletionProtection: false,
clusterIdentifier: appName,
});
const cluster = new ecs.Cluster(this, 'Cluster', {
vpc,
containerInsights: true,
clusterName: appName,
});
const appLogGroup = new LogGroup(this, 'AppLogGroup', {
retention: RetentionDays.ONE_WEEK,
logGroupName: `/aws/ecs/service/${appName}`,
removalPolicy: RemovalPolicy.DESTROY,
});
let deploymentConfig: IEcsDeploymentConfig | undefined = undefined;
if (props?.deploymentConfigName) {
deploymentConfig = EcsDeploymentConfig.fromEcsDeploymentConfigName(this, 'DeploymentConfig', props.deploymentConfigName);
}
const appConfigEnabled = props?.appConfigRoleArn !== undefined && props.appConfigRoleArn.length > 0;
const service = new ApplicationLoadBalancedCodeDeployedFargateService(this, 'Api', {
cluster,
capacityProviderStrategies: [
{
capacityProvider: 'FARGATE_SPOT',
weight: 1,
},
],
minHealthyPercent: 50,
maxHealthyPercent: 200,
desiredCount: 3,
cpu: 512,
memoryLimitMiB: 1024,
taskImageOptions: {
image,
containerName: 'api',
containerPort: 8080,
family: appName,
logDriver: AwsLogDriver.awsLogs({
logGroup: appLogGroup,
streamPrefix: 'service',
}),
secrets: {
SPRING_DATASOURCE_USERNAME: Secret.fromSecretsManager( dbSecret, 'username' ),
SPRING_DATASOURCE_PASSWORD: Secret.fromSecretsManager( dbSecret, 'password' ),
},
environment: {
SPRING_DATASOURCE_URL: `jdbc:mysql://${db.clusterEndpoint.hostname}:${db.clusterEndpoint.port}/${dbName}`,
APPCONFIG_AGENT_APPLICATION: this.node.tryGetContext('workloadName'),
APPCONFIG_AGENT_ENVIRONMENT: this.node.tryGetContext('environmentName'),
APPCONFIG_AGENT_ENABLED: appConfigEnabled.toString(),
},
},
deregistrationDelay: Duration.seconds(5),
responseTimeAlarmThreshold: Duration.seconds(3),
targetHealthCheck: {
healthyThresholdCount: 2,
unhealthyThresholdCount: 2,
interval: Duration.seconds(60),
path: '/actuator/health',
},
deploymentConfig,
terminationWaitTime: Duration.minutes(5),
apiCanaryTimeout: Duration.seconds(5),
apiTestSteps: [{
name: 'getAll',
path: '/api/fruits',
jmesPath: 'length(@)',
expectedValue: 5,
}],
});
if (appConfigEnabled) {
service.taskDefinition.addContainer('appconfig-agent', {
image: ecs.ContainerImage.fromRegistry('public.ecr.aws/aws-appconfig/aws-appconfig-agent:2.x'),
essential: false,
logging: AwsLogDriver.awsLogs({
logGroup: appLogGroup,
streamPrefix: 'service',
}),
environment: {
SERVICE_REGION: this.region,
ROLE_ARN: props!.appConfigRoleArn!,
ROLE_SESSION_NAME: appName,
LOG_LEVEL: 'info',
},
portMappings: [{ containerPort: 2772 }],
});
service.taskDefinition.addToTaskRolePolicy(new PolicyStatement({
actions: ['sts:AssumeRole'],
resources: [props!.appConfigRoleArn!],
}));
}
service.service.connections.allowTo(db, Port.tcp(db.clusterEndpoint.port));
this.apiUrl = new CfnOutput(this, 'endpointUrl', {
value: `http://${service.listener.loadBalancer.loadBalancerDnsName}`,
});
The CDK deployment is then performed for each environment:
Deploy:
Identifier: aws/cdk-deploy@v1
DependsOn:
- Build
Inputs:
Artifacts:
- package
Environment:
Name: Beta
Connections:
- Name: beta
Role: codecatalyst
Configuration:
StackName: fruit-api
Region: us-west-2
Context: '{"deploymentConfigurationName":"CodeDeployDefault.ECSCanary10Percent5Minutes"}'
CfnOutputVariables: '["endpointUrl"]'
Database Deploy
Spring Boot is configured to run Liquibase on startup. This reads the configuration in src/main/resources/db/changelog/db.changelog-master.yml
to define the tables and initial data for the database:
databaseChangeLog:
- changeSet:
id: "1"
author: AWS
changes:
- createTable:
tableName: fruit
columns:
- column:
name: id
type: bigint
autoIncrement: true
constraints:
primaryKey: true
nullable: false
- column:
name: name
type: varchar(250)
- insert:
tableName: fruit
columns:
- column:
name: name
value: Apple
- insert:
tableName: fruit
columns:
- column:
name: name
value: Orange
- insert:
tableName: fruit
columns:
- column:
name: name
value: Banana
- insert:
tableName: fruit
columns:
- column:
name: name
value: Cherry
- insert:
tableName: fruit
columns:
- column:
name: name
value: Grape
- changeSet:
id: "2"
author: AWS
changes:
- addColumn:
tableName: fruit
columns:
- column:
name: classification
type: varchar(250)
constraints:
nullable: true
- update:
tableName: fruit
columns:
- column:
name: classification
value: pome
where: name='Apple'
- update:
tableName: fruit
columns:
- column:
name: classification
value: berry
where: name='Orange'
- update:
tableName: fruit
columns:
- column:
name: classification
value: berry
where: name='Banana'
- update:
tableName: fruit
columns:
- column:
name: classification
value: drupe
where: name='Cherry'
- update:
tableName: fruit
columns:
- column:
name: classification
value: berry
where: name='Grape'
Deploy Software
The Launch Environment action above creates a new Amazon ECS Task Definition for the new docker image and then updates the Amazon ECS Service to use the new Task Definition.
Integration Tests
Integration tests are preformed during the Build Source action. They are defined with SoapUI in fruit-api-soapui-project.xml
. They are executed by Maven in the integration-test
phase using plugins in pom.xml
. Spring Boot is configure to start a local instance of the application with an H2 database during the pre-integration-test
phase and then to terminate on the post-integration-test
phase. The results of the unit tests are uploaded to Amazon CodeCatalyst to track over time.
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<id>pre-integration-test</id>
<goals>
<goal>start</goal>
</goals>
</execution>
<execution>
<id>post-integration-test</id>
<goals>
<goal>stop</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>com.smartbear.soapui</groupId>
<artifactId>soapui-maven-plugin</artifactId>
<version>5.7.0</version>
<configuration>
<junitReport>true</junitReport>
<outputFolder>target/soapui-reports</outputFolder>
<endpoint>${soapui.endpoint}</endpoint>
</configuration>
<executions>
<execution>
<phase>integration-test</phase>
<goals>
<goal>test</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
Acceptance Tests
Acceptance tests are preformed after the Launch Environment and Deploy Software actions:
The tests are defined with SoapUI in fruit-api-soapui-project.xml
. They are executed by Maven with the endpoint overridden to the URL from the CloudFormation output. An action is added to the CodeCatalyst workflow to run SoapUI:
Test:
Identifier: aws/managed-test@v1
Inputs:
Artifacts:
- package
Variables:
- Name: endpointUrl
Value: ${Deploy.endpointUrl}
Configuration:
Steps:
- Run: mvn --batch-mode --no-transfer-progress soapui:test -Dsoapui.endpoint=${endpointUrl}
- Run: mvn --batch-mode --no-transfer-progress compile jmeter:jmeter jmeter:results -Djmeter.endpoint=${endpointUrl} -Djmeter.threads=300 -Djmeter.duration=300 -Djmeter.throughput=6000
Outputs:
AutoDiscoverReports:
Enabled: true
IncludePaths:
- target/soapui-reports/*
ReportNamePrefix: Beta
SuccessCriteria:
PassRate: 100
The results of the unit tests are uploaded to Amazon CodeCatalyst to track over time.
Test (Gamma)¶
Launch Environment
The infrastructure for each environment is defined in AWS Cloud Development Kit:
super(scope, id, props);
const image = new AssetImage('.', { target: 'build' });
const appName = Stack.of(this).stackName.toLowerCase().replace(`-${Stack.of(this).region}-`, '-');
const vpc = new ec2.Vpc(this, 'Vpc', {
maxAzs: 3,
natGateways: props?.natGateways,
});
new FlowLog(this, 'VpcFlowLog', { resourceType: FlowLogResourceType.fromVpc(vpc) });
const dbName = 'fruits';
const dbSecret = new DatabaseSecret(this, 'AuroraSecret', {
username: 'fruitapi',
secretName: `${appName}-DB`,
});
const db = new ServerlessCluster(this, 'AuroraCluster', {
engine: DatabaseClusterEngine.AURORA_MYSQL,
vpc,
credentials: Credentials.fromSecret(dbSecret),
defaultDatabaseName: dbName,
deletionProtection: false,
clusterIdentifier: appName,
});
const cluster = new ecs.Cluster(this, 'Cluster', {
vpc,
containerInsights: true,
clusterName: appName,
});
const appLogGroup = new LogGroup(this, 'AppLogGroup', {
retention: RetentionDays.ONE_WEEK,
logGroupName: `/aws/ecs/service/${appName}`,
removalPolicy: RemovalPolicy.DESTROY,
});
let deploymentConfig: IEcsDeploymentConfig | undefined = undefined;
if (props?.deploymentConfigName) {
deploymentConfig = EcsDeploymentConfig.fromEcsDeploymentConfigName(this, 'DeploymentConfig', props.deploymentConfigName);
}
const appConfigEnabled = props?.appConfigRoleArn !== undefined && props.appConfigRoleArn.length > 0;
const service = new ApplicationLoadBalancedCodeDeployedFargateService(this, 'Api', {
cluster,
capacityProviderStrategies: [
{
capacityProvider: 'FARGATE_SPOT',
weight: 1,
},
],
minHealthyPercent: 50,
maxHealthyPercent: 200,
desiredCount: 3,
cpu: 512,
memoryLimitMiB: 1024,
taskImageOptions: {
image,
containerName: 'api',
containerPort: 8080,
family: appName,
logDriver: AwsLogDriver.awsLogs({
logGroup: appLogGroup,
streamPrefix: 'service',
}),
secrets: {
SPRING_DATASOURCE_USERNAME: Secret.fromSecretsManager( dbSecret, 'username' ),
SPRING_DATASOURCE_PASSWORD: Secret.fromSecretsManager( dbSecret, 'password' ),
},
environment: {
SPRING_DATASOURCE_URL: `jdbc:mysql://${db.clusterEndpoint.hostname}:${db.clusterEndpoint.port}/${dbName}`,
APPCONFIG_AGENT_APPLICATION: this.node.tryGetContext('workloadName'),
APPCONFIG_AGENT_ENVIRONMENT: this.node.tryGetContext('environmentName'),
APPCONFIG_AGENT_ENABLED: appConfigEnabled.toString(),
},
},
deregistrationDelay: Duration.seconds(5),
responseTimeAlarmThreshold: Duration.seconds(3),
targetHealthCheck: {
healthyThresholdCount: 2,
unhealthyThresholdCount: 2,
interval: Duration.seconds(60),
path: '/actuator/health',
},
deploymentConfig,
terminationWaitTime: Duration.minutes(5),
apiCanaryTimeout: Duration.seconds(5),
apiTestSteps: [{
name: 'getAll',
path: '/api/fruits',
jmesPath: 'length(@)',
expectedValue: 5,
}],
});
if (appConfigEnabled) {
service.taskDefinition.addContainer('appconfig-agent', {
image: ecs.ContainerImage.fromRegistry('public.ecr.aws/aws-appconfig/aws-appconfig-agent:2.x'),
essential: false,
logging: AwsLogDriver.awsLogs({
logGroup: appLogGroup,
streamPrefix: 'service',
}),
environment: {
SERVICE_REGION: this.region,
ROLE_ARN: props!.appConfigRoleArn!,
ROLE_SESSION_NAME: appName,
LOG_LEVEL: 'info',
},
portMappings: [{ containerPort: 2772 }],
});
service.taskDefinition.addToTaskRolePolicy(new PolicyStatement({
actions: ['sts:AssumeRole'],
resources: [props!.appConfigRoleArn!],
}));
}
service.service.connections.allowTo(db, Port.tcp(db.clusterEndpoint.port));
this.apiUrl = new CfnOutput(this, 'endpointUrl', {
value: `http://${service.listener.loadBalancer.loadBalancerDnsName}`,
});
The CDK deployment is then performed for each environment:
Deploy:
Identifier: aws/cdk-deploy@v1
DependsOn:
- Build
Inputs:
Artifacts:
- package
Environment:
Name: Beta
Connections:
- Name: beta
Role: codecatalyst
Configuration:
StackName: fruit-api
Region: us-west-2
Context: '{"deploymentConfigurationName":"CodeDeployDefault.ECSCanary10Percent5Minutes"}'
CfnOutputVariables: '["endpointUrl"]'
Database Deploy
Spring Boot is configured to run Liquibase on startup. This reads the configuration in src/main/resources/db/changelog/db.changelog-master.yml
to define the tables and initial data for the database:
databaseChangeLog:
- changeSet:
id: "1"
author: AWS
changes:
- createTable:
tableName: fruit
columns:
- column:
name: id
type: bigint
autoIncrement: true
constraints:
primaryKey: true
nullable: false
- column:
name: name
type: varchar(250)
- insert:
tableName: fruit
columns:
- column:
name: name
value: Apple
- insert:
tableName: fruit
columns:
- column:
name: name
value: Orange
- insert:
tableName: fruit
columns:
- column:
name: name
value: Banana
- insert:
tableName: fruit
columns:
- column:
name: name
value: Cherry
- insert:
tableName: fruit
columns:
- column:
name: name
value: Grape
- changeSet:
id: "2"
author: AWS
changes:
- addColumn:
tableName: fruit
columns:
- column:
name: classification
type: varchar(250)
constraints:
nullable: true
- update:
tableName: fruit
columns:
- column:
name: classification
value: pome
where: name='Apple'
- update:
tableName: fruit
columns:
- column:
name: classification
value: berry
where: name='Orange'
- update:
tableName: fruit
columns:
- column:
name: classification
value: berry
where: name='Banana'
- update:
tableName: fruit
columns:
- column:
name: classification
value: drupe
where: name='Cherry'
- update:
tableName: fruit
columns:
- column:
name: classification
value: berry
where: name='Grape'
Deploy Software
The Launch Environment action above creates a new Amazon ECS Task Definition for the new docker image and then updates the Amazon ECS Service to use the new Task Definition.
Application Monitoring & Logging
Amazon ECS uses Amazon CloudWatch Metrics and Amazon CloudWatch Logs for observability by default.
Synthetic Tests
Amazon CloudWatch Synthetics is used to continuously deliver traffic to the application and assert that requests are successful and responses are received within a given threshold. The canary is defined via CDK using the @cdklabs/cdk-ecs-codedeploy construct:
const service = new ApplicationLoadBalancedCodeDeployedFargateService(this, 'Api', {
...
apiCanaryTimeout: Duration.seconds(5),
apiTestSteps: [{
name: 'getAll',
path: '/api/fruits',
jmesPath: 'length(@)',
expectedValue: 5,
}],
Performance Tests
Apache JMeter is used to run performance tests against the deployed application. The tests are stored in src/test/jmeter
and added to the CodeCatalyst workflow:
Test:
Identifier: aws/managed-test@v1
Inputs:
Artifacts:
- package
Variables:
- Name: endpointUrl
Value: ${Deploy.endpointUrl}
Configuration:
Steps:
- Run: mvn --batch-mode --no-transfer-progress soapui:test -Dsoapui.endpoint=${endpointUrl}
- Run: mvn --batch-mode --no-transfer-progress compile jmeter:jmeter jmeter:results -Djmeter.endpoint=${endpointUrl} -Djmeter.threads=300 -Djmeter.duration=300 -Djmeter.throughput=6000
Outputs:
AutoDiscoverReports:
Enabled: true
IncludePaths:
- target/soapui-reports/*
ReportNamePrefix: Beta
SuccessCriteria:
PassRate: 100
Resilience Tests
Not Implemented
Dynamic Application Security Testing (DAST)
Not Implemented
Prod¶
Manual Approval
Not Implemented
Database Deploy
Spring Boot is configured to run Liquibase on startup. This reads the configuration in src/main/resources/db/changelog/db.changelog-master.yml
to define the tables and initial data for the database:
databaseChangeLog:
- changeSet:
id: "1"
author: AWS
changes:
- createTable:
tableName: fruit
columns:
- column:
name: id
type: bigint
autoIncrement: true
constraints:
primaryKey: true
nullable: false
- column:
name: name
type: varchar(250)
- insert:
tableName: fruit
columns:
- column:
name: name
value: Apple
- insert:
tableName: fruit
columns:
- column:
name: name
value: Orange
- insert:
tableName: fruit
columns:
- column:
name: name
value: Banana
- insert:
tableName: fruit
columns:
- column:
name: name
value: Cherry
- insert:
tableName: fruit
columns:
- column:
name: name
value: Grape
- changeSet:
id: "2"
author: AWS
changes:
- addColumn:
tableName: fruit
columns:
- column:
name: classification
type: varchar(250)
constraints:
nullable: true
- update:
tableName: fruit
columns:
- column:
name: classification
value: pome
where: name='Apple'
- update:
tableName: fruit
columns:
- column:
name: classification
value: berry
where: name='Orange'
- update:
tableName: fruit
columns:
- column:
name: classification
value: berry
where: name='Banana'
- update:
tableName: fruit
columns:
- column:
name: classification
value: drupe
where: name='Cherry'
- update:
tableName: fruit
columns:
- column:
name: classification
value: berry
where: name='Grape'
Progressive Deployment
Progressive deployment is implemented with AWS CodeDeploy for ECS. CodeDeploy performs a linear blue/green by deploying the new task definition as a new task with a separate target group and then shifting 10% of traffic every minute until all traffic is shifted. A CloudWatch alarm is monitored by CodeDeploy and an automatic rollback is triggered if the alarm exceeds the threshold.
Implementation of this type deployment presents challenges due to the following limitations:
- aws/aws-cdk #19163 - CDK Pipelines aren't intended to be used with CodeDeploy actions.
- AWS CloudFormation User Guide - The use of
AWS::CodeDeploy::BlueGreen
hooks andAWS::CodeDeployBlueGreen
restricts the types of changes that can be made. Additionally, you can't use auto-rollback capabilities of CodeDeploy. - aws/aws-cdk #5170 - CDK doesn't support defining CloudFormation rollback triggers. This rules out CloudFormation based blue/green deployments.
The solution was to use the @cdklabs/cdk-ecs-codedeploy construct from the Construct Hub which addresses aws/aws-cdk #1559 - Lack of support for Blue/Green ECS Deployment in CDK.
const service = new ApplicationLoadBalancedCodeDeployedFargateService(this, 'Api', {
cluster,
capacityProviderStrategies: [
{
capacityProvider: 'FARGATE_SPOT',
weight: 1,
},
],
minHealthyPercent: 50,
maxHealthyPercent: 200,
desiredCount: 3,
cpu: 512,
memoryLimitMiB: 1024,
taskImageOptions: {
image,
containerName: 'api',
containerPort: 8080,
family: appName,
logDriver: AwsLogDriver.awsLogs({
logGroup: appLogGroup,
streamPrefix: 'service',
}),
secrets: {
SPRING_DATASOURCE_USERNAME: Secret.fromSecretsManager( dbSecret, 'username' ),
SPRING_DATASOURCE_PASSWORD: Secret.fromSecretsManager( dbSecret, 'password' ),
},
environment: {
SPRING_DATASOURCE_URL: `jdbc:mysql://${db.clusterEndpoint.hostname}:${db.clusterEndpoint.port}/${dbName}`,
},
},
deregistrationDelay: Duration.seconds(5),
responseTimeAlarmThreshold: Duration.seconds(3),
healthCheck: {
healthyThresholdCount: 2,
unhealthyThresholdCount: 2,
interval: Duration.seconds(60),
path: '/actuator/health',
},
deploymentConfig,
terminationWaitTime: Duration.minutes(5),
apiCanaryTimeout: Duration.seconds(5),
apiTestSteps: [{
name: 'getAll',
path: '/api/fruits',
jmesPath: 'length(@)',
expectedValue: 5,
}],
});
this.apiUrl = new CfnOutput(this, 'endpointUrl', {
value: `http://${service.listener.loadBalancer.loadBalancerDnsName}`,
});
```rrideLogicalId('DeploymentId');
Deployments are made incrementally across regions as waves using the action groups and the CDK deploy action. Each wave contains a list of regions to deploy to in parallel. One wave must fully complete before the next wave starts. The diagram below shows how each wave deploys to 2 regions at a time.
Synthetic Tests
Amazon CloudWatch Synthetics is used to continuously deliver traffic to the application and assert that requests are successful and responses are received within a given threshold. The canary is defined via CDK using the @cdklabs/cdk-ecs-codedeploy construct:
const service = new ApplicationLoadBalancedCodeDeployedFargateService(this, 'Api', {
...
apiCanaryTimeout: Duration.seconds(5),
apiTestSteps: [{
name: 'getAll',
path: '/api/fruits',
jmesPath: 'length(@)',
expectedValue: 5,
}],
Frequently Asked Questions¶
What operating models does this reference implementation support?
This reference implementation can accomodate any operation model with minor updates:
- Fully Separated - Restrict the role that CDK uses for CloudFormation execution to only create resources from approved product portfolios in AWS Service Catalog. Ownership of creating the products in Service Catalog is owned by the Platform Engineering team and operational support of Service Catalog is owned by the Platform Operations team. The Platform Engineering team should publish CDK constructs internally that provision AWS resources through Service Catalog. Update the CDK app in the
infrastructure/
directory to use CDK constructs provided by thePlatform Engineering
team. Use a CODEOWNERS file to require all changes to theinfrastructure/
directory be approved by the Application Operations team. Additionally, restrict permissions to the Manual Approval action to only allow members of the Application Operations to approve. - Separated AEO and IEO with Centralized Governance - Restrict the role that CDK uses for CloudFormation execution to only create resources from approved product portfolios in AWS Service Catalog. Ownership of creating the products in Service Catalog is owned by the Platform Engineering team and operational support of Service Catalog is owned by the Platform Engineering team. The Platform Engineering team should publish CDK constructs internally that provision AWS resources through Service Catalog. Update the CDK app in the
infrastructure/
directory to use CDK constructs provided by thePlatform Engineering
team. - Separated AEO and IEO with Decentralized Governance - The Platform Engineering team should publish CDK constructs internally that provision AWS resources in manner that achieve organizational compliance. Update the CDK app in the
infrastructure/
directory to use CDK constructs provided by thePlatform Engineering
team.