packages/apps/core/devops/lib/devops.ts (573 lines of code) (raw):
/*!
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0
*/
import { MdaaAppConfigParser, MdaaAppConfigParserProps, MdaaBaseConfigContents, MdaaCdkApp } from '@aws-mdaa/app';
import { MdaaRole } from '@aws-mdaa/iam-constructs';
import { MdaaKmsKey } from '@aws-mdaa/kms-constructs';
import { MdaaL3Construct, MdaaL3ConstructProps } from '@aws-mdaa/l3-construct';
import { IMdaaResourceNaming } from '@aws-mdaa/naming';
import { MdaaBucket } from '@aws-mdaa/s3-constructs';
import { Schema } from 'ajv';
import { AppProps, Aspects, IAspect, Stack } from 'aws-cdk-lib';
import {
BuildSpec,
ComputeType,
LinuxBuildImage,
PipelineProject,
PipelineProjectProps,
} from 'aws-cdk-lib/aws-codebuild';
import { IRepository, Repository } from 'aws-cdk-lib/aws-codecommit';
import { Artifact, Pipeline, PipelineProps, PipelineType } from 'aws-cdk-lib/aws-codepipeline';
import {
CodeBuildAction,
CodeBuildActionProps,
CodeCommitSourceAction,
CodeCommitTrigger,
ManualApprovalAction,
} from 'aws-cdk-lib/aws-codepipeline-actions';
import {
AccountPrincipal,
CompositePrincipal,
Effect,
IRole,
ManagedPolicy,
Policy,
PolicyDocument,
PolicyStatement,
Role,
ServicePrincipal,
} from 'aws-cdk-lib/aws-iam';
import { IKey } from 'aws-cdk-lib/aws-kms';
import { Bucket } from 'aws-cdk-lib/aws-s3';
import { Construct, IConstruct } from 'constructs';
import * as configSchema from './config-schema.json';
import { MdaaNagSuppressions } from '@aws-mdaa/construct'; //NOSONAR
export interface ValidateStageCommands {
readonly install?: string[];
readonly commands?: string[];
}
export interface StageCommands {
readonly install?: string[];
readonly pre?: string[];
readonly post?: string[];
}
export interface Commands extends StageCommands {
readonly preDeploy?: StageCommands;
readonly preDeployValidate?: ValidateStageCommands;
readonly deploy?: StageCommands;
readonly postDeployValidate?: ValidateStageCommands;
}
export interface DevOpsConfigContents extends MdaaBaseConfigContents, Commands {
readonly mdaaCodeCommitRepo: string;
readonly mdaaBranch?: string;
readonly configsCodeCommitRepo: string;
readonly configsBranch?: string;
readonly pipelines?: { [pipelineName: string]: PipelineConfig };
readonly cdkBootstrapContext?: string;
}
export interface PipelineConfig extends Commands {
readonly domainFilter?: string[];
readonly envFilter?: string[];
readonly moduleFilter?: string[];
}
export class DevOpsConfigParser extends MdaaAppConfigParser<DevOpsConfigContents> {
public readonly devopsConfig: DevOpsConfigContents;
constructor(stack: Stack, props: MdaaAppConfigParserProps) {
super(stack, props, configSchema as Schema);
this.devopsConfig = this.configContents;
}
}
export class MdaaDevopsCDKApp extends MdaaCdkApp {
constructor(props?: AppProps) {
super({ ...props, ...{ useBootstrap: false } }, MdaaCdkApp.parsePackageJson(`${__dirname}/../package.json`));
}
protected subGenerateResources(
stack: Stack,
l3ConstructProps: MdaaL3ConstructProps,
parserProps: MdaaAppConfigParserProps,
) {
const appConfig = new DevOpsConfigParser(stack, parserProps);
new MdaaDevopsL3Construct(stack, 'devops', {
...l3ConstructProps,
...appConfig.devopsConfig,
});
Aspects.of(stack).add(new FixCdkBuildProject());
}
}
export interface MdaaDevopsL3ConstructProps extends MdaaL3ConstructProps, DevOpsConfigContents {}
export class MdaaDevopsL3Construct extends MdaaL3Construct {
private static readonly DEFAULT_CDK_BOOTSTRAP_CONTEXT = 'hnb659fds';
private readonly props: MdaaDevopsL3ConstructProps;
constructor(scope: Construct, id: string, props: MdaaDevopsL3ConstructProps) {
super(scope, id, props);
this.props = props;
const pipelineRole = new MdaaRole(this, 'pipeline-role', {
roleName: 'pipeline',
naming: this.props.naming,
assumedBy: new ServicePrincipal('codepipeline.amazonaws.com'),
});
const mdaaRepo = Repository.fromRepositoryName(this, 'mdaa-import-repo', this.props.mdaaCodeCommitRepo);
const configsRepo = Repository.fromRepositoryName(this, 'configs-import-repo', this.props.configsCodeCommitRepo);
const kmsKey = new MdaaKmsKey(this, 'kms-key', {
naming: this.props.naming,
keyUserRoleIds: [pipelineRole.roleId],
});
const devOpsBucket = new MdaaBucket(this, 'pipeline-bucket', {
naming: this.props.naming,
encryptionKey: kmsKey,
});
MdaaNagSuppressions.addCodeResourceSuppressions(
devOpsBucket,
[
{
id: 'NIST.800.53.R5-S3BucketReplicationEnabled',
reason: 'Bucket does not contain data assets. Replication not required.',
},
{
id: 'HIPAA.Security-S3BucketReplicationEnabled',
reason: 'Bucket does not contain data assets. Replication not required.',
},
{
id: 'PCI.DSS.321-S3BucketReplicationEnabled',
reason: 'Bucket does not contain data assets. Replication not required.',
},
],
true,
);
const codeCommitEventRole = new MdaaRole(this, 'codecommit-event-role', {
roleName: 'codecommit-event',
naming: this.props.naming,
assumedBy: new ServicePrincipal('events.amazonaws.com'),
});
const codeCommitReadPolicy = new PolicyDocument({
statements: [
new PolicyStatement({
effect: Effect.ALLOW,
actions: ['codecommit:GetBranch', 'codecommit:GetCommit', 'codecommit:GetRepository', 'codecommit:GitPull'],
resources: [mdaaRepo.repositoryArn, configsRepo.repositoryArn],
}),
],
});
const codeCommitActionRole = new MdaaRole(this, 'codecommit-action-role', {
roleName: 'codecommit-action',
naming: this.props.naming,
assumedBy: new AccountPrincipal(this.account),
inlinePolicies: { codecommit_read: codeCommitReadPolicy },
});
const codeBuildActionRole = new MdaaRole(this, 'codebuild-action-role', {
roleName: 'codebuild-action',
naming: this.props.naming,
assumedBy: new CompositePrincipal(
new ServicePrincipal('codebuild.amazonaws.com'),
new AccountPrincipal(this.account),
),
managedPolicies: [ManagedPolicy.fromAwsManagedPolicyName('AWSCloudFormationReadOnlyAccess')],
});
const codeBuildActionPolicy = new Policy(this, 'codebuild-policy');
const cdkLookupRole = this.importCdkRole(this, 'lookup', this.props.cdkBootstrapContext);
const cdkDeployRole = this.importCdkRole(this, 'deploy', this.props.cdkBootstrapContext);
const cdkExecRole = this.importCdkRole(this, 'exec', this.props.cdkBootstrapContext);
const cdkFilePublishingRole = this.importCdkRole(this, 'file-publishing', this.props.cdkBootstrapContext);
const cdkImagePublishingRole = this.importCdkRole(this, 'image-publishing', this.props.cdkBootstrapContext);
const cdkBucket = Bucket.fromBucketName(
this,
`cdk-bucket-import`,
`cdk-${this.props.cdkBootstrapContext ?? MdaaDevopsL3Construct.DEFAULT_CDK_BOOTSTRAP_CONTEXT}-assets-${
this.account
}-${this.region}`,
);
codeBuildActionPolicy.addStatements(
new PolicyStatement({
sid: 'ASSUMECDKROLES',
actions: ['sts:AssumeRole'],
resources: [
cdkLookupRole.roleArn,
cdkDeployRole.roleArn,
cdkFilePublishingRole.roleArn,
cdkImagePublishingRole.roleArn,
cdkExecRole.roleArn,
],
effect: Effect.ALLOW,
}),
);
codeBuildActionPolicy.addStatements(
new PolicyStatement({
sid: 'S3List',
actions: ['s3:ListAllMyBuckets'],
resources: ['*'],
effect: Effect.ALLOW,
}),
new PolicyStatement({
sid: 'CloudFormationChangeSets',
actions: [
'cloudformation:CreateChangeSet',
'cloudformation:DescribeChangeSet',
'cloudformation:DeleteChangeSet',
],
resources: ['*'],
effect: Effect.ALLOW,
}),
);
codeBuildActionPolicy.addStatements(
new PolicyStatement({
sid: 'CDKS3',
actions: ['s3:Get*', 's3:Put*', 's3:List*'],
resources: [cdkBucket.bucketArn, cdkBucket.arnForObjects('*')],
effect: Effect.ALLOW,
}),
);
MdaaNagSuppressions.addCodeResourceSuppressions(
codeBuildActionPolicy,
[
{ id: 'AwsSolutions-IAM5', reason: 'Permissions are scoped least privilege for deployment time.' },
{ id: 'NIST.800.53.R5-IAMNoInlinePolicy', reason: 'Inline policy appropriate.' },
{ id: 'HIPAA.Security-IAMNoInlinePolicy', reason: 'Inline policy appropriate.' },
{ id: 'PCI.DSS.321-IAMNoInlinePolicy', reason: 'Inline policy appropriate.' },
],
true,
);
codeBuildActionPolicy.attachToRole(codeBuildActionRole);
const manualActionRole = new MdaaRole(this, 'manual-action-role', {
roleName: 'manual-action',
naming: this.props.naming,
assumedBy: new AccountPrincipal(this.account),
});
const assumeActionRoleGrant = codeBuildActionRole.grantAssumeRole(pipelineRole);
Object.entries(this.props.pipelines ?? {}).forEach(entry => {
const pipelineProps: MdaaPipelineProps = {
pipelineType: PipelineType.V2,
naming: this.props.naming.withModuleName(`devops-${entry[0]}`),
pipelineName: this.props.naming.resourceName(entry[0]),
...entry[1],
role: pipelineRole,
artifactBucket: devOpsBucket,
codeCommitActionRole: codeCommitActionRole,
codeCommitEventRole: codeCommitEventRole,
codeBuildActionRole: codeBuildActionRole,
mdaaRepo: mdaaRepo,
configsRepo: configsRepo,
kmsKey: kmsKey,
manualActionRole: manualActionRole,
install: [...(this.props.install ?? []), ...(entry[1].install ?? [])],
pre: [...(this.props.pre ?? []), ...(entry[1].pre ?? [])],
post: [...(this.props.post ?? []), ...(entry[1].post ?? [])],
preDeploy: { ...this.props.preDeploy, ...entry[1].preDeploy },
preDeployValidate:
this.props.preDeployValidate || entry[1].preDeployValidate
? { ...this.props.preDeployValidate, ...entry[1].preDeployValidate }
: undefined,
deploy: { ...this.props.deploy, ...entry[1].deploy },
postDeployValidate:
this.props.postDeployValidate || entry[1].postDeployValidate
? { ...this.props.postDeployValidate, ...entry[1].postDeployValidate }
: undefined,
};
const pipeline = new MdaaPipeline(this, `mdaa-pipeline-${entry[0]}`, pipelineProps);
assumeActionRoleGrant.applyBefore(pipeline);
});
MdaaNagSuppressions.addCodeResourceSuppressions(
pipelineRole,
[
{ id: 'AwsSolutions-IAM5', reason: 'Permissions are scoped least privilege for deployment time.' },
{ id: 'NIST.800.53.R5-IAMNoInlinePolicy', reason: 'Inline policy appropriate.' },
{ id: 'HIPAA.Security-IAMNoInlinePolicy', reason: 'Inline policy appropriate.' },
{ id: 'PCI.DSS.321-IAMNoInlinePolicy', reason: 'Inline policy appropriate.' },
],
true,
);
MdaaNagSuppressions.addCodeResourceSuppressions(
codeBuildActionRole,
[
{ id: 'AwsSolutions-IAM4', reason: 'AWSCloudFormationReadOnlyAccess is Read Only Access' },
{ id: 'AwsSolutions-IAM5', reason: 'Permissions are scoped least privilege for deployment time.' },
{ id: 'NIST.800.53.R5-IAMNoInlinePolicy', reason: 'Inline policy appropriate.' },
{ id: 'HIPAA.Security-IAMNoInlinePolicy', reason: 'Inline policy appropriate.' },
{ id: 'PCI.DSS.321-IAMNoInlinePolicy', reason: 'Inline policy appropriate.' },
],
true,
);
MdaaNagSuppressions.addCodeResourceSuppressions(
codeCommitActionRole,
[
{ id: 'AwsSolutions-IAM5', reason: 'Permissions are scoped least privilege for deployment time.' },
{ id: 'NIST.800.53.R5-IAMNoInlinePolicy', reason: 'Inline policy appropriate.' },
{ id: 'HIPAA.Security-IAMNoInlinePolicy', reason: 'Inline policy appropriate.' },
{ id: 'PCI.DSS.321-IAMNoInlinePolicy', reason: 'Inline policy appropriate.' },
],
true,
);
MdaaNagSuppressions.addCodeResourceSuppressions(
codeCommitEventRole,
[
{ id: 'NIST.800.53.R5-IAMNoInlinePolicy', reason: 'Inline policy appropriate.' },
{ id: 'HIPAA.Security-IAMNoInlinePolicy', reason: 'Inline policy appropriate.' },
{ id: 'PCI.DSS.321-IAMNoInlinePolicy', reason: 'Inline policy appropriate.' },
],
true,
);
}
private importCdkRole(scope: Construct, roleName: string, cdkBootstrapContext?: string): IRole {
return Role.fromRoleName(
scope,
`cdk-${roleName}-role-import`,
`cdk-${cdkBootstrapContext ?? MdaaDevopsL3Construct.DEFAULT_CDK_BOOTSTRAP_CONTEXT}-${roleName}-role-${
Stack.of(scope).account
}-${Stack.of(scope).region}`,
);
}
}
export interface MdaaPipelineProps extends PipelineProps, StageCommands, PipelineConfig {
readonly naming: IMdaaResourceNaming;
readonly pipelineName: string;
readonly codeCommitActionRole: IRole;
readonly codeCommitEventRole: IRole;
readonly codeBuildActionRole: IRole;
readonly mdaaRepo: IRepository;
readonly mdaaBranch?: string;
readonly configsRepo: IRepository;
readonly configsBranch?: string;
readonly kmsKey: IKey;
readonly manualActionRole: IRole;
}
export class MdaaPipeline extends Pipeline {
private readonly props: MdaaPipelineProps;
constructor(scope: Construct, id: string, props: MdaaPipelineProps) {
super(scope, id, props);
this.props = props;
const sourceStage = this.addStage({ stageName: 'Source' });
const mdaaSourceOutput = new Artifact('MDAA');
const mdaaSourceAction = this.createCodeCommitSourceAction(
'mdaa',
mdaaSourceOutput,
this.props.codeCommitActionRole,
this.props.codeCommitEventRole,
this.props.mdaaRepo,
this.props.mdaaBranch ?? 'main',
);
const configSourceOutput = new Artifact('CONFIGS');
const configSourceAction = this.createCodeCommitSourceAction(
'configs',
configSourceOutput,
this.props.codeCommitActionRole,
this.props.codeCommitEventRole,
this.props.configsRepo,
this.props.configsBranch ?? 'main',
);
sourceStage.addAction(mdaaSourceAction);
sourceStage.addAction(configSourceAction);
const pipelineProjects: PipelineProject[] = [];
const preDeployOutput = new Artifact('PREDEPLOY_OUTPUT');
this.addPreDeployStage(configSourceOutput, mdaaSourceOutput, preDeployOutput, pipelineProjects);
this.addPreDeployValidateStage(preDeployOutput, pipelineProjects);
this.addDeployStage(preDeployOutput, pipelineProjects);
this.addPostDeployValidateStage(preDeployOutput, pipelineProjects);
const codeBuildActionPolicy = new Policy(this, 'codebuild-action-policy', {
statements: [
new PolicyStatement({
actions: ['codebuild:StartBuild'],
resources: pipelineProjects.map(x => x.projectArn),
effect: Effect.ALLOW,
}),
],
});
MdaaNagSuppressions.addCodeResourceSuppressions(
codeBuildActionPolicy,
[
{ id: 'NIST.800.53.R5-IAMNoInlinePolicy', reason: 'Inline policy appropriate.' },
{ id: 'HIPAA.Security-IAMNoInlinePolicy', reason: 'Inline policy appropriate.' },
{ id: 'PCI.DSS.321-IAMNoInlinePolicy', reason: 'Inline policy appropriate.' },
],
true,
);
this.props.codeBuildActionRole.attachInlinePolicy(codeBuildActionPolicy);
}
private addPostDeployValidateStage(preDeployOutput: Artifact, pipelineProjects: PipelineProject[]) {
if (this.props.postDeployValidate) {
const [validateAction, validateProject] = this.createCodeBuildAction(
'PostDeployValidate',
preDeployOutput,
undefined,
undefined,
{
installCommands: [...(this.props.install ?? []), ...(this.props.postDeployValidate?.install ?? [])],
preCommands: undefined,
commands: [...(this.props.postDeployValidate?.commands ?? [])],
},
);
pipelineProjects.push(validateProject);
const postDeployValidateStage = this.addStage({
stageName: 'Post-Deploy-Validate',
});
postDeployValidateStage.addAction(validateAction);
}
}
private addDeployStage(preDeployOutput: Artifact, pipelineProjects: PipelineProject[]) {
const deployStage = this.addStage({ stageName: 'Deploy' });
const [deployAction, deployProject] = this.createCodeBuildAction(
'Deploy',
preDeployOutput,
undefined,
undefined,
{
installCommands: ['n 18', ...(this.props.install ?? []), ...(this.props.deploy?.install ?? [])],
preCommands: [...(this.props.pre ?? []), ...(this.props.deploy?.pre ?? [])],
commands: [this.createMdaaCommand('deploy')],
postCommands: [...(this.props.post ?? []), ...(this.props.deploy?.post ?? [])],
},
);
deployStage.addAction(deployAction);
pipelineProjects.push(deployProject);
}
private addPreDeployValidateStage(preDeployOutput: Artifact, pipelineProjects: PipelineProject[]) {
const preDeployValidateStage = this.addStage({
stageName: 'Pre-Deploy-Validate',
});
if (this.props.preDeployValidate) {
const [validateAction, validateProject] = this.createCodeBuildAction(
'PreDeployValidate',
preDeployOutput,
undefined,
undefined,
{
installCommands: [...(this.props.install ?? []), ...(this.props.preDeployValidate?.install ?? [])],
preCommands: undefined,
commands: [...(this.props.preDeployValidate?.commands ?? [])],
},
);
preDeployValidateStage.addAction(validateAction);
pipelineProjects.push(validateProject);
}
preDeployValidateStage.addAction(
new ManualApprovalAction({
actionName: 'Approve',
role: this.props.manualActionRole,
}),
);
}
private addPreDeployStage(
configSourceOutput: Artifact,
mdaaSourceOutput: Artifact,
preDeployOutput: Artifact,
pipelineProjects: PipelineProject[],
): PipelineProject {
const preDeployStage = this.addStage({ stageName: 'Pre-Deploy' });
const [diffAction, diffProject] = this.createCodeBuildAction(
'Synth-And-Diff',
configSourceOutput,
[mdaaSourceOutput],
[preDeployOutput],
{
installCommands: [
'n 18',
'ln -s $CODEBUILD_SRC_DIR_MDAA ./mdaa',
...(this.props.install ?? []),
...(this.props.preDeploy?.install ?? []),
],
preCommands: [...(this.props.pre ?? []), ...(this.props.preDeploy?.pre ?? [])],
commands: [this.createMdaaCommand('diff')],
postCommands: [...(this.props.post ?? []), ...(this.props.preDeploy?.post ?? [])],
},
);
preDeployStage.addAction(diffAction);
pipelineProjects.push(diffProject);
return diffProject;
}
private createCodeCommitSourceAction(
actionName: string,
output: Artifact,
role: IRole,
eventRole: IRole,
repository: IRepository,
branch: string,
): CodeCommitSourceAction {
return new CodeCommitSourceAction({
output,
actionName: actionName,
role: role,
runOrder: undefined,
branch: branch,
trigger: CodeCommitTrigger.EVENTS,
repository: repository,
eventRole: eventRole,
codeBuildCloneOutput: true,
});
}
private createMdaaCommand(mdaaAction: string): string {
const mdaaCmd = [`./mdaa/bin/mdaa ${mdaaAction}`];
if (this.props.domainFilter) {
mdaaCmd.push(`-d ${this.props.domainFilter.join(',')}`);
}
if (this.props.envFilter) {
mdaaCmd.push(`-e ${this.props.envFilter.join(',')}`);
}
if (this.props.moduleFilter) {
mdaaCmd.push(`-m ${this.props.moduleFilter.join(',')}`);
}
return mdaaCmd.join(' ');
}
private createCodeBuildAction(
actionName: string,
input: Artifact,
extraInputs?: Artifact[],
outputs?: Artifact[],
commands?: {
installCommands?: string[];
preCommands?: string[];
commands?: string[];
postCommands?: string[];
},
): [CodeBuildAction, PipelineProject] {
const projectProps: PipelineProjectProps = {
projectName: this.props.naming.resourceName(actionName),
encryptionKey: this.props.kmsKey,
role: this.props.codeBuildActionRole,
environment: {
computeType: ComputeType.X2_LARGE,
buildImage: LinuxBuildImage.AMAZON_LINUX_2_5,
},
buildSpec: BuildSpec.fromObject({
version: '0.2',
phases: {
install: {
commands: commands?.installCommands,
},
pre_build: {
commands: commands?.preCommands,
},
build: {
commands: commands?.commands,
},
post_build: {
commands: commands?.postCommands,
},
},
artifacts: {
files: ['**/*'],
'enable-symlinks': 'yes',
},
}),
};
const codeBuildProject = new PipelineProject(this, `codebuild-project-${actionName}`, projectProps);
const codeBuildActionProps: CodeBuildActionProps = {
input: input,
extraInputs: extraInputs,
actionName: actionName,
project: codeBuildProject,
role: this.props.codeBuildActionRole,
outputs: outputs,
};
const action = new CodeBuildAction(codeBuildActionProps);
return [action, codeBuildProject];
}
}
class FixCdkBuildProject implements IAspect {
public visit(construct: IConstruct): void {
if (construct.node.id.startsWith('codebuild-project')) {
MdaaNagSuppressions.addCodeResourceSuppressions(
construct,
[
{ id: 'HIPAA.Security-CodeBuildProjectSourceRepoUrl', reason: 'Pipeline source is CodeCommit.' },
{ id: 'PCI.DSS.321-CodeBuildProjectSourceRepoUrl', reason: 'Pipeline source is CodeCommit.' },
],
true,
);
}
}
}