diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 022826340..ed6683469 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -24,6 +24,8 @@ for more detailed information about upgrading to this release. (`#272 `__) * Add support for setting environment variables in your app (`#273 `__) +* Add a ``generate-pipeline`` command + (`#278 `__) 0.6.0 diff --git a/Makefile b/Makefile index 7b1b1e69c..37a57b3f9 100644 --- a/Makefile +++ b/Makefile @@ -22,7 +22,7 @@ check: # Proper docstring conventions according to pep257 # # - pydocstyle --add-ignore=D100,D101,D102,D103,D104,D105,D204 chalice/ + pydocstyle --add-ignore=D100,D101,D102,D103,D104,D105,D204,D301 chalice/ # # # diff --git a/chalice/cli/__init__.py b/chalice/cli/__init__.py index 92aeb725b..7273281ed 100644 --- a/chalice/cli/__init__.py +++ b/chalice/cli/__init__.py @@ -294,6 +294,34 @@ def package(ctx, single_file, stage, out): packager.package_app(config, out) +@cli.command('generate-pipeline') +@click.argument('filename') +@click.pass_context +def generate_pipeline(ctx, filename): + # type: (click.Context, str) -> None + """Generate a cloudformation template for a starter CD pipeline. + + This command will write a starter cloudformation template to + the filename you provide. It contains a CodeCommit repo, + a CodeBuild stage for packaging your chalice app, and a + CodePipeline stage to deploy your application using cloudformation. + + You can use any AWS SDK or the AWS CLI to deploy this stack. + Here's an example using the AWS CLI: + + \b + $ chalice generate-pipeline pipeline.json + $ aws cloudformation deploy --stack-name mystack \b + --template-file pipeline.json --capabilities CAPABILITY_IAM + """ + from chalice.pipeline import create_pipeline_template + factory = ctx.obj['factory'] # type: CLIFactory + config = factory.create_config_obj() + output = create_pipeline_template(config) + with open(filename, 'w') as f: + f.write(json.dumps(output, indent=2, separators=(',', ': '))) + + def run_local_server(app_obj, port): # type: (Chalice, int) -> None from chalice.local import create_local_server diff --git a/chalice/constants.py b/chalice/constants.py index 8b0425f6c..f3033b1d7 100644 --- a/chalice/constants.py +++ b/chalice/constants.py @@ -70,3 +70,142 @@ def index(): ], "Resource": "arn:aws:logs:*:*:*" } + + +CODEBUILD_POLICY = { + "Version": "2012-10-17", + # This is the policy straight from the console. + "Statement": [ + { + "Action": [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Resource": "*", + "Effect": "Allow" + }, + { + "Action": [ + "s3:GetObject", + "s3:GetObjectVersion", + "s3:PutObject" + ], + "Resource": "arn:aws:s3:::*", + "Effect": "Allow" + } + ] +} + +CODEPIPELINE_POLICY = { + "Version": "2012-10-17", + # Also straight from the console setup. + "Statement": [ + { + "Action": [ + "s3:GetObject", + "s3:GetObjectVersion", + "s3:GetBucketVersioning" + ], + "Resource": "*", + "Effect": "Allow" + }, + { + "Action": [ + "s3:PutObject" + ], + "Resource": [ + "arn:aws:s3:::codepipeline*", + "arn:aws:s3:::elasticbeanstalk*" + ], + "Effect": "Allow" + }, + { + "Action": [ + "codecommit:CancelUploadArchive", + "codecommit:GetBranch", + "codecommit:GetCommit", + "codecommit:GetUploadArchiveStatus", + "codecommit:UploadArchive" + ], + "Resource": "*", + "Effect": "Allow" + }, + { + "Action": [ + "codedeploy:CreateDeployment", + "codedeploy:GetApplicationRevision", + "codedeploy:GetDeployment", + "codedeploy:GetDeploymentConfig", + "codedeploy:RegisterApplicationRevision" + ], + "Resource": "*", + "Effect": "Allow" + }, + { + "Action": [ + "elasticbeanstalk:*", + "ec2:*", + "elasticloadbalancing:*", + "autoscaling:*", + "cloudwatch:*", + "s3:*", + "sns:*", + "cloudformation:*", + "rds:*", + "sqs:*", + "ecs:*", + "iam:PassRole" + ], + "Resource": "*", + "Effect": "Allow" + }, + { + "Action": [ + "lambda:InvokeFunction", + "lambda:ListFunctions" + ], + "Resource": "*", + "Effect": "Allow" + }, + { + "Action": [ + "opsworks:CreateDeployment", + "opsworks:DescribeApps", + "opsworks:DescribeCommands", + "opsworks:DescribeDeployments", + "opsworks:DescribeInstances", + "opsworks:DescribeStacks", + "opsworks:UpdateApp", + "opsworks:UpdateStack" + ], + "Resource": "*", + "Effect": "Allow" + }, + { + "Action": [ + "cloudformation:CreateStack", + "cloudformation:DeleteStack", + "cloudformation:DescribeStacks", + "cloudformation:UpdateStack", + "cloudformation:CreateChangeSet", + "cloudformation:DeleteChangeSet", + "cloudformation:DescribeChangeSet", + "cloudformation:ExecuteChangeSet", + "cloudformation:SetStackPolicy", + "cloudformation:ValidateTemplate", + "iam:PassRole" + ], + "Resource": "*", + "Effect": "Allow" + }, + { + "Action": [ + "codebuild:BatchGetBuilds", + "codebuild:StartBuild" + ], + "Resource": "*", + "Effect": "Allow" + } + ] +} diff --git a/chalice/pipeline.py b/chalice/pipeline.py new file mode 100644 index 000000000..3912e0005 --- /dev/null +++ b/chalice/pipeline.py @@ -0,0 +1,440 @@ +import copy + +from typing import List, Dict, Any, Optional # noqa + +from chalice.config import Config # noqa +from chalice import constants + + +def create_pipeline_template(config): + # type: (Config) -> Dict[str, Any] + pipeline = CreatePipelineTemplate() + return pipeline.create_template(config.app_name) + + +class CreatePipelineTemplate(object): + + _BASE_TEMPLATE = { + "AWSTemplateFormatVersion": "2010-09-09", + "Parameters": { + "ApplicationName": { + "Default": "ChaliceApp", + "Type": "String", + "Description": "Enter the name of your application" + } + }, + "Resources": {}, + "Outputs": {}, + } + + def __init__(self): + # type: () -> None + pass + + def create_template(self, app_name): + # type: (str) -> Dict[str, Any] + t = copy.deepcopy(self._BASE_TEMPLATE) # type: Dict[str, Any] + t['Parameters']['ApplicationName']['Default'] = app_name + resources = [SourceRepository, CodeBuild, CodePipeline] + for resource_cls in resources: + resource_cls().add_to_template(t) + return t + + +class BaseResource(object): + def add_to_template(self, template): + # type: (Dict[str, Any]) -> None + raise NotImplementedError("add_to_template") + + +class SourceRepository(BaseResource): + def add_to_template(self, template): + # type: (Dict[str, Any]) -> None + resources = template.setdefault('Resources', {}) + resources['SourceRepository'] = { + "Type": "AWS::CodeCommit::Repository", + "Properties": { + "RepositoryName": { + "Ref": "ApplicationName" + }, + "RepositoryDescription": { + "Fn::Sub": "Source code for ${ApplicationName}" + } + } + } + template.setdefault('Outputs', {})['SourceRepoURL'] = { + "Value": { + "Fn::GetAtt": "SourceRepository.CloneUrlHttp" + } + } + + +class CodeBuild(BaseResource): + def add_to_template(self, template): + # type: (Dict[str, Any]) -> None + resources = template.setdefault('Resources', {}) + outputs = template.setdefault('Outputs', {}) + # Used to store the application source when the SAM + # template is packaged. + self._add_s3_bucket(resources, outputs) + self._add_codebuild_role(resources, outputs) + self._add_codebuild_policy(resources) + self._add_package_build(resources) + + def _add_package_build(self, resources): + # type: (Dict[str, Any]) -> None + resources['AppPackageBuild'] = { + "Type": "AWS::CodeBuild::Project", + "Properties": { + "Artifacts": { + "Type": "CODEPIPELINE" + }, + "Environment": { + "ComputeType": "BUILD_GENERAL1_SMALL", + "Image": "aws/codebuild/python:2.7.12", + "Type": "LINUX_CONTAINER", + "EnvironmentVariables": [ + { + "Name": "APP_S3_BUCKET", + "Value": { + "Ref": "ApplicationBucket" + } + } + ] + }, + "Name": { + "Fn::Sub": "${ApplicationName}Build" + }, + "ServiceRole": { + "Fn::GetAtt": "CodeBuildRole.Arn" + }, + "Source": { + "Type": "CODEPIPELINE", + "BuildSpec": ( + "version: 0.1\n" + "phases:\n" + " install:\n" + " commands:\n" + " - sudo pip install --upgrade awscli\n" + " - aws --version\n" + " - sudo pip install chalice\n" + " - chalice package /tmp/packaged\n" + " - aws cloudformation package" + " --template-file /tmp/packaged/sam.json" + " --s3-bucket ${APP_S3_BUCKET}" + " --output-template-file transformed.yaml\n" + "artifacts:\n" + " type: zip\n" + " files:\n" + " - transformed.yaml\n" + ) + } + } + } + + def _add_s3_bucket(self, resources, outputs): + # type: (Dict[str, Any], Dict[str, Any]) -> None + resources['ApplicationBucket'] = {'Type': 'AWS::S3::Bucket'} + outputs['S3ApplicationBucket'] = { + 'Value': {'Ref': 'ApplicationBucket'} + } + + def _add_codebuild_role(self, resources, outputs): + # type: (Dict[str, Any], Dict[str, Any]) -> None + resources['CodeBuildRole'] = { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": [ + "sts:AssumeRole" + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "codebuild.amazonaws.com" + ] + } + } + ] + } + } + } + outputs['CodeBuildRoleArn'] = { + "Value": { + "Fn::GetAtt": "CodeBuildRole.Arn" + } + } + + def _add_codebuild_policy(self, resources): + # type: (Dict[str, Any]) -> None + resources['CodeBuildPolicy'] = { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyName": "CodeBuildPolicy", + "PolicyDocument": constants.CODEBUILD_POLICY, + "Roles": [ + { + "Ref": "CodeBuildRole" + } + ] + } + } + + +class CodePipeline(BaseResource): + def add_to_template(self, template): + # type: (Dict[str, Any]) -> None + resources = template.setdefault('Resources', {}) + outputs = template.setdefault('Outputs', {}) + self._add_pipeline(resources) + self._add_bucket_store(resources, outputs) + self._add_codepipeline_role(resources, outputs) + self._add_cfn_deploy_role(resources, outputs) + + def _add_cfn_deploy_role(self, resources, outputs): + # type: (Dict[str, Any], Dict[str, Any]) -> None + outputs['CFNDeployRoleArn'] = { + 'Value': {'Fn::GetAtt': 'CFNDeployRole.Arn'} + } + resources['CFNDeployRole'] = { + 'Type': 'AWS::IAM::Role', + 'Properties': { + "Policies": [ + { + "PolicyName": "DeployAccess", + "PolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": "*", + "Resource": "*", + "Effect": "Allow" + } + ] + } + } + ], + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": [ + "sts:AssumeRole" + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "cloudformation.amazonaws.com" + ] + } + } + ] + } + } + } + + def _add_pipeline(self, resources): + # type: (Dict[str, Any]) -> None + properties = { + 'Name': { + 'Fn::Sub': '${ApplicationName}Pipeline' + }, + 'ArtifactStore': { + 'Type': 'S3', + 'Location': {'Ref': 'ArtifactBucketStore'}, + }, + 'RoleArn': { + 'Fn::GetAtt': 'CodePipelineRole.Arn', + }, + 'Stages': self._create_pipeline_stages(), + } + resources['AppPipeline'] = { + 'Type': 'AWS::CodePipeline::Pipeline', + 'Properties': properties + } + + def _create_pipeline_stages(self): + # type: () -> List[Dict[str, Any]] + # The goal is to eventually allow a user to configure + # the various stages they want created. For now, there's + # a fixed list. + stages = [ + self._create_source_stage(), + self._create_build_stage(), + self._create_beta_stage(), + ] + return stages + + def _create_source_stage(self): + # type: () -> Dict[str, Any] + return { + "Name": "Source", + "Actions": [ + { + "ActionTypeId": { + "Category": "Source", + "Owner": "AWS", + "Version": 1, + "Provider": "CodeCommit" + }, + "Configuration": { + "BranchName": "master", + "RepositoryName": { + "Fn::GetAtt": "SourceRepository.Name" + } + }, + "OutputArtifacts": [ + { + "Name": "SourceRepo" + } + ], + "RunOrder": 1, + "Name": "Source" + } + ] + } + + def _create_build_stage(self): + # type: () -> Dict[str, Any] + return { + "Name": "Build", + "Actions": [ + { + "InputArtifacts": [ + { + "Name": "SourceRepo" + } + ], + "Name": "CodeBuild", + "ActionTypeId": { + "Category": "Build", + "Owner": "AWS", + "Version": 1, + "Provider": "CodeBuild" + }, + "OutputArtifacts": [ + { + "Name": "CompiledCFNTemplate" + } + ], + "Configuration": { + "ProjectName": { + "Ref": "AppPackageBuild" + } + }, + "RunOrder": 1 + } + ] + } + + def _create_beta_stage(self): + # type: () -> Dict[str, Any] + return { + "Name": "Beta", + "Actions": [ + { + "ActionTypeId": { + "Category": "Deploy", + "Owner": "AWS", + "Version": 1, + "Provider": "CloudFormation" + }, + "InputArtifacts": [ + { + "Name": "CompiledCFNTemplate" + } + ], + "Name": "CreateBetaChangeSet", + "Configuration": { + "ActionMode": "CHANGE_SET_REPLACE", + "ChangeSetName": { + "Fn::Sub": "${ApplicationName}ChangeSet" + }, + "RoleArn": { + "Fn::GetAtt": "CFNDeployRole.Arn" + }, + "Capabilities": "CAPABILITY_IAM", + "StackName": { + "Fn::Sub": "${ApplicationName}BetaStack" + }, + "TemplatePath": "CompiledCFNTemplate::transformed.yaml" + }, + "RunOrder": 1 + }, + { + "RunOrder": 2, + "ActionTypeId": { + "Category": "Deploy", + "Owner": "AWS", + "Version": 1, + "Provider": "CloudFormation" + }, + "Configuration": { + "StackName": { + "Fn::Sub": "${ApplicationName}BetaStack" + }, + "ActionMode": "CHANGE_SET_EXECUTE", + "ChangeSetName": { + "Fn::Sub": "${ApplicationName}ChangeSet" + }, + "OutputFileName": "StackOutputs.json" + }, + "Name": "ExecuteChangeSet", + "OutputArtifacts": [ + { + "Name": "AppDeploymentValues" + } + ] + } + ] + } + + def _add_bucket_store(self, resources, outputs): + # type: (Dict[str, Any], Dict[str, Any]) -> None + resources['ArtifactBucketStore'] = { + 'Type': 'AWS::S3::Bucket', + 'Properties': { + 'VersioningConfiguration': { + 'Status': 'Enabled' + } + } + } + outputs['S3PipelineBucket'] = { + 'Value': {'Ref': 'ArtifactBucketStore'} + } + + def _add_codepipeline_role(self, resources, outputs): + # type: (Dict[str, Any], Dict[str, Any]) -> None + outputs['CodePipelineRoleArn'] = { + 'Value': {'Fn::GetAtt': 'CodePipelineRole.Arn'} + } + resources['CodePipelineRole'] = { + "Type": "AWS::IAM::Role", + "Properties": { + "Policies": [ + { + "PolicyName": "DefaultPolicy", + "PolicyDocument": constants.CODEPIPELINE_POLICY, + } + ], + "AssumeRolePolicyDocument": { + "Version": "2012-10-17", + "Statement": [ + { + "Action": [ + "sts:AssumeRole" + ], + "Effect": "Allow", + "Principal": { + "Service": [ + "codepipeline.amazonaws.com" + ] + } + } + ] + } + } + } diff --git a/tests/functional/cli/test_cli.py b/tests/functional/cli/test_cli.py index bf1440e44..b1a1b6b87 100644 --- a/tests/functional/cli/test_cli.py +++ b/tests/functional/cli/test_cli.py @@ -252,3 +252,19 @@ def test_error_when_no_deployed_record(runner, mock_cli_factory): cli_factory=mock_cli_factory) assert result.exit_code == 2 assert 'not find' in result.output + + +def test_can_generate_pipeline_for_all(runner): + with runner.isolated_filesystem(): + cli.create_new_project_skeleton('testproject') + os.chdir('testproject') + result = _run_cli_command( + runner, cli.generate_pipeline, ['pipeline.json']) + assert result.exit_code == 0, result.output + assert os.path.isfile('pipeline.json') + with open('pipeline.json', 'r') as f: + template = json.load(f) + # The actual contents are tested in the unit + # tests. Just a sanity check that it looks right. + assert "AWSTemplateFormatVersion" in template + assert "Outputs" in template diff --git a/tests/unit/test_pipeline.py b/tests/unit/test_pipeline.py new file mode 100644 index 000000000..10ee22bea --- /dev/null +++ b/tests/unit/test_pipeline.py @@ -0,0 +1,69 @@ +import pytest + +from chalice import pipeline + + +@pytest.fixture +def pipeline_gen(): + return pipeline.CreatePipelineTemplate() + + +def test_app_name_in_param_default(pipeline_gen): + template = pipeline_gen.create_template('appname') + assert template['Parameters']['ApplicationName']['Default'] == 'appname' + + +def test_source_repo_resource(pipeline_gen): + template = {} + pipeline.SourceRepository().add_to_template(template) + assert template == { + "Resources": { + "SourceRepository": { + "Type": "AWS::CodeCommit::Repository", + "Properties": { + "RepositoryName": { + "Ref": "ApplicationName" + }, + "RepositoryDescription": { + "Fn::Sub": "Source code for ${ApplicationName}" + } + } + } + }, + "Outputs": { + "SourceRepoURL": { + "Value": { + "Fn::GetAtt": "SourceRepository.CloneUrlHttp" + } + } + } + } + + +def test_codebuild_resource(pipeline_gen): + template = {} + pipeline.CodeBuild().add_to_template(template) + resources = template['Resources'] + assert 'ApplicationBucket' in resources + assert 'CodeBuildRole' in resources + assert 'CodeBuildPolicy' in resources + assert 'AppPackageBuild' in resources + assert resources['ApplicationBucket'] == {'Type': 'AWS::S3::Bucket'} + assert template['Outputs']['CodeBuildRoleArn'] == { + 'Value': {'Fn::GetAtt': 'CodeBuildRole.Arn'} + } + + +def test_codepipeline_resource(pipeline_gen): + template = {} + pipeline.CodePipeline().add_to_template(template) + resources = template['Resources'] + assert 'AppPipeline' in resources + assert 'ArtifactBucketStore' in resources + assert 'CodePipelineRole' in resources + assert 'CFNDeployRole' in resources + # Some basic sanity checks + resources['AppPipeline']['Type'] == 'AWS::CodePipeline::Pipeline' + resources['ArtifactBucketStore']['Type'] == 'AWS::S3::Bucket' + resources['CodePipelineRole']['Type'] == 'AWS::IAM::Role' + resources['CFNDeployRole']['Type'] == 'AWS::IAM::Role'