|
| 1 | +""" |
| 2 | +Generate the CloudFormation template for the deployment pipeline. |
| 3 | +""" |
| 4 | +import argparse |
| 5 | +import getpass |
| 6 | +import logging |
| 7 | +from typing import Iterable |
| 8 | + |
| 9 | +import boto3 |
| 10 | +import troposphere |
| 11 | +from awacs import ( |
| 12 | + aws as AWS, |
| 13 | + awslambda as LAMBDA, |
| 14 | + cloudformation as CLOUDFORMATION, |
| 15 | + cloudwatch as CLOUDWATCH, |
| 16 | + codebuild as CODEBUILD, |
| 17 | + codepipeline as CODEPIPELINE, |
| 18 | + iam as IAM, |
| 19 | + logs as LOGS, |
| 20 | + s3 as S3, |
| 21 | + sts as STS, |
| 22 | +) |
| 23 | +from botocore.exceptions import ClientError |
| 24 | +from troposphere import GetAtt, Ref, Sub, Template, codebuild, codepipeline, iam, s3 |
| 25 | + |
| 26 | +APPLICATION_NAME = "AwsEncryptionSdkDecryptOraclePython" |
| 27 | +PIPELINE_STACK_NAME = "{}DeployPipeline".format(APPLICATION_NAME) |
| 28 | +CODEBUILD_IMAGE = "aws/codebuild/python:3.6.5" |
| 29 | +BUILDSPEC = "decrypt_oracle/.chalice/buildspec.yaml" |
| 30 | +GITHUB_REPO = "aws-encryption-sdk-python" |
| 31 | +WAITER_CONFIG = dict(Delay=10) |
| 32 | +_LOGGER = logging.getLogger("Decrypt Oracle Build Pipeline Deployer") |
| 33 | + |
| 34 | + |
| 35 | +class AllowEverywhere(AWS.Statement): |
| 36 | + """Shortcut for creating IAM Statements that Allow to Resource "*".""" |
| 37 | + |
| 38 | + def __init__(self, *args, **kwargs): |
| 39 | + my_kwargs = dict(Effect=AWS.Allow, Resource=["*"]) |
| 40 | + my_kwargs.update(kwargs) |
| 41 | + super(AllowEverywhere, self).__init__(*args, **my_kwargs) |
| 42 | + |
| 43 | + |
| 44 | +def _service_assume_role(service: str) -> AWS.Policy: |
| 45 | + """Build and return the IAM AssumeRolePolicy for use in service roles.""" |
| 46 | + return AWS.Policy( |
| 47 | + Statement=[ |
| 48 | + AWS.Statement( |
| 49 | + Effect=AWS.Allow, |
| 50 | + Action=[STS.AssumeRole], |
| 51 | + Principal=AWS.Principal("Service", ["{}.amazonaws.com".format(service)]), |
| 52 | + ) |
| 53 | + ] |
| 54 | + ) |
| 55 | + |
| 56 | + |
| 57 | +def _codebuild_role() -> iam.Role: |
| 58 | + """Build and return the IAM Role resource to be used by CodeBuild to run the build project.""" |
| 59 | + policy = iam.Policy( |
| 60 | + "CodeBuildPolicy", |
| 61 | + PolicyName="CodeBuildPolicy", |
| 62 | + PolicyDocument=AWS.PolicyDocument( |
| 63 | + Statement=[ |
| 64 | + AllowEverywhere(Action=[LOGS.CreateLogGroup, LOGS.CreateLogStream, LOGS.PutLogEvents]), |
| 65 | + AllowEverywhere(Action=[S3.GetObject, S3.GetObjectVersion, S3.PutObject]), |
| 66 | + ] |
| 67 | + ), |
| 68 | + ) |
| 69 | + return iam.Role("CodeBuildRole", AssumeRolePolicyDocument=_service_assume_role(CODEBUILD.prefix), Policies=[policy]) |
| 70 | + |
| 71 | + |
| 72 | +def _codebuild_builder(role: iam.Role, application_bucket: s3.Bucket) -> codebuild.Project: |
| 73 | + """Build and return the CodeBuild Project resource to be used to build the decrypt oracle.""" |
| 74 | + artifacts = codebuild.Artifacts(Type="CODEPIPELINE") |
| 75 | + environment = codebuild.Environment( |
| 76 | + ComputeType="BUILD_GENERAL1_SMALL", |
| 77 | + Image=CODEBUILD_IMAGE, |
| 78 | + Type="LINUX_CONTAINER", |
| 79 | + EnvironmentVariables=[codebuild.EnvironmentVariable(Name="APP_S3_BUCKET", Value=Ref(application_bucket))], |
| 80 | + ) |
| 81 | + source = codebuild.Source(Type="CODEPIPELINE", BuildSpec=BUILDSPEC) |
| 82 | + return codebuild.Project( |
| 83 | + "{}Build".format(APPLICATION_NAME), |
| 84 | + Artifacts=artifacts, |
| 85 | + Environment=environment, |
| 86 | + Name=APPLICATION_NAME, |
| 87 | + ServiceRole=Ref(role), |
| 88 | + Source=source, |
| 89 | + ) |
| 90 | + |
| 91 | + |
| 92 | +def _pipeline_role(buckets: Iterable[s3.Bucket]) -> iam.Role: |
| 93 | + """Build and return the IAM Role resource to be used by CodePipeline to run the pipeline.""" |
| 94 | + bucket_statements = [ |
| 95 | + AWS.Statement( |
| 96 | + Effect=AWS.Allow, |
| 97 | + Action=[S3.GetBucketVersioning, S3.PutBucketVersioning], |
| 98 | + Resource=[GetAtt(bucket, "Arn") for bucket in buckets], |
| 99 | + ), |
| 100 | + AWS.Statement( |
| 101 | + Effect=AWS.Allow, |
| 102 | + Action=[S3.GetObject, S3.PutObject], |
| 103 | + Resource=[Sub("${{{bucket}.Arn}}/*".format(bucket=bucket.title)) for bucket in buckets], |
| 104 | + ), |
| 105 | + ] |
| 106 | + policy = iam.Policy( |
| 107 | + "PipelinePolicy", |
| 108 | + PolicyName="PipelinePolicy", |
| 109 | + PolicyDocument=AWS.PolicyDocument( |
| 110 | + Statement=bucket_statements |
| 111 | + + [ |
| 112 | + AllowEverywhere(Action=[CLOUDWATCH.Action("*"), IAM.PassRole]), |
| 113 | + AllowEverywhere(Action=[LAMBDA.InvokeFunction, LAMBDA.ListFunctions]), |
| 114 | + AllowEverywhere( |
| 115 | + Action=[ |
| 116 | + CLOUDFORMATION.CreateStack, |
| 117 | + CLOUDFORMATION.DeleteStack, |
| 118 | + CLOUDFORMATION.DescribeStacks, |
| 119 | + CLOUDFORMATION.UpdateStack, |
| 120 | + CLOUDFORMATION.CreateChangeSet, |
| 121 | + CLOUDFORMATION.DeleteChangeSet, |
| 122 | + CLOUDFORMATION.DescribeChangeSet, |
| 123 | + CLOUDFORMATION.ExecuteChangeSet, |
| 124 | + CLOUDFORMATION.SetStackPolicy, |
| 125 | + CLOUDFORMATION.ValidateTemplate, |
| 126 | + ] |
| 127 | + ), |
| 128 | + AllowEverywhere(Action=[CODEBUILD.BatchGetBuilds, CODEBUILD.StartBuild]), |
| 129 | + ] |
| 130 | + ), |
| 131 | + ) |
| 132 | + return iam.Role( |
| 133 | + "CodePipelinesRole", AssumeRolePolicyDocument=_service_assume_role(CODEPIPELINE.prefix), Policies=[policy] |
| 134 | + ) |
| 135 | + |
| 136 | + |
| 137 | +def _cloudformation_role() -> iam.Role: |
| 138 | + """Build and return the IAM Role resource to be used by the pipeline to interact with CloudFormation.""" |
| 139 | + policy = iam.Policy( |
| 140 | + "CloudFormationPolicy", |
| 141 | + PolicyName="CloudFormationPolicy", |
| 142 | + PolicyDocument=AWS.PolicyDocument(Statement=[AllowEverywhere(Action=[AWS.Action("*")])]), |
| 143 | + ) |
| 144 | + return iam.Role( |
| 145 | + "CloudFormationRole", AssumeRolePolicyDocument=_service_assume_role(CLOUDFORMATION.prefix), Policies=[policy] |
| 146 | + ) |
| 147 | + |
| 148 | + |
| 149 | +def _pipeline( |
| 150 | + pipeline_role: iam.Role, |
| 151 | + cfn_role: iam.Role, |
| 152 | + codebuild_builder: codebuild.Project, |
| 153 | + artifact_bucket: s3.Bucket, |
| 154 | + github_owner: str, |
| 155 | + github_branch: str, |
| 156 | + github_access_token: troposphere.AWSProperty, |
| 157 | +) -> codepipeline.Pipeline: |
| 158 | + """Build and return the CodePipeline pipeline resource.""" |
| 159 | + _source_output = "SourceOutput" |
| 160 | + get_source = codepipeline.Stages( |
| 161 | + Name="Source", |
| 162 | + Actions=[ |
| 163 | + codepipeline.Actions( |
| 164 | + Name="PullSource", |
| 165 | + RunOrder="1", |
| 166 | + OutputArtifacts=[codepipeline.OutputArtifacts(Name=_source_output)], |
| 167 | + ActionTypeId=codepipeline.ActionTypeId( |
| 168 | + Category="Source", Owner="ThirdParty", Version="1", Provider="GitHub" |
| 169 | + ), |
| 170 | + Configuration=dict( |
| 171 | + Owner=github_owner, |
| 172 | + Repo=GITHUB_REPO, |
| 173 | + OAuthToken=Ref(github_access_token), |
| 174 | + Branch=github_branch, |
| 175 | + PollForSourceChanges=True, |
| 176 | + ), |
| 177 | + ) |
| 178 | + ], |
| 179 | + ) |
| 180 | + _compiled_cfn_template = "CompiledCfnTemplate" |
| 181 | + _changeset_name = "{}ChangeSet".format(APPLICATION_NAME) |
| 182 | + _stack_name = "{}Stack".format(APPLICATION_NAME) |
| 183 | + do_build = codepipeline.Stages( |
| 184 | + Name="Build", |
| 185 | + Actions=[ |
| 186 | + codepipeline.Actions( |
| 187 | + Name="BuildChanges", |
| 188 | + RunOrder="1", |
| 189 | + InputArtifacts=[codepipeline.InputArtifacts(Name=_source_output)], |
| 190 | + OutputArtifacts=[codepipeline.OutputArtifacts(Name=_compiled_cfn_template)], |
| 191 | + ActionTypeId=codepipeline.ActionTypeId( |
| 192 | + Category="Build", Owner="AWS", Version="1", Provider="CodeBuild" |
| 193 | + ), |
| 194 | + Configuration=dict(ProjectName=Ref(codebuild_builder)), |
| 195 | + ) |
| 196 | + ], |
| 197 | + ) |
| 198 | + stage_changeset = codepipeline.Actions( |
| 199 | + Name="StageChanges", |
| 200 | + RunOrder="1", |
| 201 | + ActionTypeId=codepipeline.ActionTypeId(Category="Deploy", Owner="AWS", Version="1", Provider="CloudFormation"), |
| 202 | + InputArtifacts=[codepipeline.InputArtifacts(Name=_compiled_cfn_template)], |
| 203 | + Configuration=dict( |
| 204 | + ActionMode="CHANGE_SET_REPLACE", |
| 205 | + ChangeSetName=_changeset_name, |
| 206 | + RoleArn=GetAtt(cfn_role, "Arn"), |
| 207 | + Capabilities="CAPABILITY_IAM", |
| 208 | + StackName=_stack_name, |
| 209 | + TemplatePath="{}::decrypt_oracle/transformed.yaml".format(_compiled_cfn_template), |
| 210 | + ), |
| 211 | + ) |
| 212 | + deploy_changeset = codepipeline.Actions( |
| 213 | + Name="Deploy", |
| 214 | + RunOrder="2", |
| 215 | + ActionTypeId=codepipeline.ActionTypeId(Category="Deploy", Owner="AWS", Version="1", Provider="CloudFormation"), |
| 216 | + Configuration=dict( |
| 217 | + ActionMode="CHANGE_SET_EXECUTE", |
| 218 | + ChangeSetName=_changeset_name, |
| 219 | + StackName=_stack_name, |
| 220 | + OutputFileName="StackOutputs.json", |
| 221 | + ), |
| 222 | + OutputArtifacts=[codepipeline.OutputArtifacts(Name="AppDeploymentValues")], |
| 223 | + ) |
| 224 | + deploy = codepipeline.Stages(Name="Deploy", Actions=[stage_changeset, deploy_changeset]) |
| 225 | + artifact_store = codepipeline.ArtifactStore(Type="S3", Location=Ref(artifact_bucket)) |
| 226 | + return codepipeline.Pipeline( |
| 227 | + "{}Pipeline".format(APPLICATION_NAME), |
| 228 | + RoleArn=GetAtt(pipeline_role, "Arn"), |
| 229 | + ArtifactStore=artifact_store, |
| 230 | + Stages=[get_source, do_build, deploy], |
| 231 | + ) |
| 232 | + |
| 233 | + |
| 234 | +def _build_template(github_owner: str, github_branch: str) -> Template: |
| 235 | + """Build and return the pipeline template.""" |
| 236 | + template = Template(Description="CI/CD pipeline for Decrypt Oracle powered by the AWS Encryption SDK for Python") |
| 237 | + github_access_token = template.add_parameter( |
| 238 | + troposphere.Parameter( |
| 239 | + "GithubPersonalToken", Type="String", Description="Personal access token for the github repo.", NoEcho=True |
| 240 | + ) |
| 241 | + ) |
| 242 | + application_bucket = template.add_resource(s3.Bucket("ApplicationBucket")) |
| 243 | + artifact_bucket = template.add_resource(s3.Bucket("ArtifactBucketStore")) |
| 244 | + builder_role = template.add_resource(_codebuild_role()) |
| 245 | + builder = template.add_resource(_codebuild_builder(builder_role, application_bucket)) |
| 246 | + # add codepipeline role |
| 247 | + pipeline_role = template.add_resource(_pipeline_role(buckets=[application_bucket, artifact_bucket])) |
| 248 | + # add cloudformation deploy role |
| 249 | + cfn_role = template.add_resource(_cloudformation_role()) |
| 250 | + # add codepipeline |
| 251 | + template.add_resource( |
| 252 | + _pipeline( |
| 253 | + pipeline_role=pipeline_role, |
| 254 | + cfn_role=cfn_role, |
| 255 | + codebuild_builder=builder, |
| 256 | + artifact_bucket=artifact_bucket, |
| 257 | + github_owner=github_owner, |
| 258 | + github_branch=github_branch, |
| 259 | + github_access_token=github_access_token, |
| 260 | + ) |
| 261 | + ) |
| 262 | + return template |
| 263 | + |
| 264 | + |
| 265 | +def _stack_exists(cloudformation) -> bool: |
| 266 | + """Determine if the stack has already been deployed.""" |
| 267 | + try: |
| 268 | + cloudformation.describe_stacks(StackName=PIPELINE_STACK_NAME) |
| 269 | + |
| 270 | + except ClientError as error: |
| 271 | + if error.response["Error"]["Message"] == "Stack with id {name} does not exist".format(name=PIPELINE_STACK_NAME): |
| 272 | + return False |
| 273 | + raise |
| 274 | + |
| 275 | + else: |
| 276 | + return True |
| 277 | + |
| 278 | + |
| 279 | +def _update_existing_stack(cloudformation, template: Template, github_token: str) -> None: |
| 280 | + """Update a stack.""" |
| 281 | + _LOGGER.info("Updating existing stack") |
| 282 | + |
| 283 | + # 3. update stack |
| 284 | + cloudformation.update_stack( |
| 285 | + StackName=PIPELINE_STACK_NAME, |
| 286 | + TemplateBody=template.to_json(), |
| 287 | + Parameters=[dict(ParameterKey="GithubPersonalToken", ParameterValue=github_token)], |
| 288 | + Capabilities=["CAPABILITY_IAM"], |
| 289 | + ) |
| 290 | + _LOGGER.info("Waiting for stack update to complete...") |
| 291 | + waiter = cloudformation.get_waiter("stack_update_complete") |
| 292 | + waiter.wait(StackName=PIPELINE_STACK_NAME, WaiterConfig=WAITER_CONFIG) |
| 293 | + _LOGGER.info("Stack update complete!") |
| 294 | + |
| 295 | + |
| 296 | +def _deploy_new_stack(cloudformation, template: Template, github_token: str) -> None: |
| 297 | + """Deploy a new stack.""" |
| 298 | + _LOGGER.info("Bootstrapping new stack") |
| 299 | + |
| 300 | + # 2. deploy template |
| 301 | + cloudformation.create_stack( |
| 302 | + StackName=PIPELINE_STACK_NAME, |
| 303 | + TemplateBody=template.to_json(), |
| 304 | + Parameters=[dict(ParameterKey="GithubPersonalToken", ParameterValue=github_token)], |
| 305 | + Capabilities=["CAPABILITY_IAM"], |
| 306 | + ) |
| 307 | + _LOGGER.info("Waiting for stack to deploy...") |
| 308 | + waiter = cloudformation.get_waiter("stack_create_complete") |
| 309 | + waiter.wait(StackName=PIPELINE_STACK_NAME, WaiterConfig=WAITER_CONFIG) |
| 310 | + _LOGGER.info("Stack deployment complete!") |
| 311 | + |
| 312 | + |
| 313 | +def _deploy_or_update_template(template: Template, github_token: str) -> None: |
| 314 | + """Update a stack, deploying a new stack if nothing exists yet.""" |
| 315 | + cloudformation = boto3.client("cloudformation") |
| 316 | + |
| 317 | + if _stack_exists(cloudformation): |
| 318 | + return _update_existing_stack(cloudformation=cloudformation, template=template, github_token=github_token) |
| 319 | + |
| 320 | + return _deploy_new_stack(cloudformation=cloudformation, template=template, github_token=github_token) |
| 321 | + |
| 322 | + |
| 323 | +def _setup_logging() -> None: |
| 324 | + """Set up logging.""" |
| 325 | + logging.basicConfig(level=logging.INFO) |
| 326 | + |
| 327 | + |
| 328 | +def main(args=None): |
| 329 | + """Entry point for CLI.""" |
| 330 | + _setup_logging() |
| 331 | + |
| 332 | + parser = argparse.ArgumentParser(description="Pipeline deployer") |
| 333 | + parser.add_argument("--github-user", required=True, help="What Github user should be used?") |
| 334 | + parser.add_argument("--github-branch", required=False, default="master", help="What Github branch should be used?") |
| 335 | + |
| 336 | + parsed = parser.parse_args(args) |
| 337 | + |
| 338 | + access_token = getpass.getpass("Github personal token:") |
| 339 | + |
| 340 | + template = _build_template(github_owner=parsed.github_user, github_branch=parsed.github_branch) |
| 341 | + _deploy_or_update_template(template=template, github_token=access_token) |
| 342 | + |
| 343 | + |
| 344 | +if __name__ == "__main__": |
| 345 | + main() |
0 commit comments