Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[SAM] Makes S3 Bucket parameter optional and creates bucket automatically in the deployment region #3040

Closed
wants to merge 27 commits into from
Closed
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
f51bcb2
initial commit for package enhancements - bucket creation
igngar Dec 6, 2017
cc7794e
Added first working version - Not clean code/exceptions yet
Dec 6, 2017
3d399b2
Added '-' between Region and Account to make it compliant as Hashing …
Dec 6, 2017
c11c21b
Updated exceptions and removed initial clutter
Dec 7, 2017
191b3a2
Adds check on whether deployment region is equals to s3 bucket specified
Dec 9, 2017
68ac314
Created up bucket creation code, removed prints and added stdout comp…
Dec 9, 2017
c294e59
Removed helper function to check bucket existence as it's already bei…
Dec 9, 2017
a645a77
pep8 lint as per contrib docs
Dec 9, 2017
9cde45e
Removed unnecessary exception as non-existent buckets exception is ca…
Dec 9, 2017
e5e55d3
Fixed NameError in nose tests - typo missing self._does_deploy_region…
Dec 9, 2017
671b3db
Simplified code with _get_bucket_region to make changes "testable"
Dec 14, 2017
34080bf
Fixed conflicts
Dec 14, 2017
39c37d3
Tests now pass with patched code for optional s3_bucket
Dec 14, 2017
7cca0fb
Added additional tests to make optional bucket code to work; all pass…
Dec 14, 2017
548407d
Fixed review comment on somehow incomplete .get()
Jan 11, 2018
34b34a0
Removes unnecessary brackets
Jan 11, 2018
63e5b20
Adds EmptyRegion exception, refactor region var, fix LocationConstrai…
Jan 11, 2018
8403b76
Covers Empty Region exception recently introduced
Jan 11, 2018
277e7a3
Adds test to cover boto exception s3 bucket not found
Jan 18, 2018
00fe5c8
Includes _get_bucket_region coverage without impacting test result
Jan 18, 2018
aab4cb1
Ran pep8 on test_package
Jan 18, 2018
e96d568
refactor to increase code coverage in unit test
Jan 18, 2018
fcb21ca
refactor to increase code coverage in unit test
Jan 18, 2018
3661e42
Clean up, refactor even original tests to reach 100% coverage
Jan 20, 2018
ad77c0f
Merge branch 'sam-package-enhanced' of https://github.com/adhorn/aws-…
Jan 20, 2018
37d1e22
Merge branch 'develop' into sam-package-enhanced
heitorlessa Jan 20, 2018
d552634
typo
Jan 20, 2018
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions awscli/customizations/cloudformation/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,12 @@ class DeployFailedError(CloudFormationCommandError):
"to fetch the list of events leading up to the failure"
"\n"
"aws cloudformation describe-stack-events --stack-name {stack_name}")


class PackageFailedRegionMismatchError(CloudFormationCommandError):
fmt = \
("Failed to create package as deployment and S3 bucket region mismatch"
"\n\n"
"\tS3 Bucket region: {bucket_region}"
"\n"
"\tDeployment region: {deploy_region}")
81 changes: 68 additions & 13 deletions awscli/customizations/cloudformation/package.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,18 @@
# ANY KIND, either express or implied. See the License for the specific
# language governing permissions and limitations under the License.

import os
import json
import logging
import os
import sys

import json

from botocore.client import Config

from awscli.customizations.cloudformation import exceptions
from awscli.customizations.cloudformation.artifact_exporter import Template
from awscli.customizations.cloudformation.yamlhelper import yaml_dump
from awscli.customizations.cloudformation import exceptions
from awscli.customizations.commands import BasicCommand
from awscli.customizations.s3uploader import S3Uploader
from botocore.client import Config
from botocore.exceptions import ClientError

LOG = logging.getLogger(__name__)

Expand All @@ -40,6 +39,10 @@ class PackageCommand(BasicCommand):
"--stack-name <YOUR STACK NAME>"
"\n")

MSG_PACKAGE_S3_BUCKET_CREATION = (
"Bucket {bucket} doesn't exist.\n"
"Creating s3://{bucket} at {region} region.\n")

NAME = "package"

DESCRIPTION = BasicCommand.FROM_FILE("cloudformation",
Expand All @@ -57,7 +60,7 @@ class PackageCommand(BasicCommand):

{
'name': 's3-bucket',
'required': True,
'required': False,
'help_text': (
'The name of the S3 bucket where this command uploads'
' the artifacts that are referenced in your template.'
Expand Down Expand Up @@ -112,19 +115,70 @@ class PackageCommand(BasicCommand):
}
]

def _get_bucket_region(self, s3_bucket, s3_client):
s3_loc = s3_client.get_bucket_location(
Bucket=s3_bucket).get("LocationConstraint", "us-east-1")
return s3_loc.get
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why another .get? Isn't s3_loc just a string?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It allows the function to be mocked for the tests easier.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sanath is right.. This code seems incomplete, I'm download the latest source code and will send another commit after checking tests, etc. That part should've been:

    def _get_bucket_region(self, s3_bucket, s3_client):
        s3_loc = s3_client.get_bucket_location(Bucket=s3_bucket)
        return s3_loc.get("LocationConstraint", "us-east-1")

instead of:

    def _get_bucket_region(self, s3_bucket, s3_client):
        s3_loc = s3_client.get_bucket_location(
            Bucket=s3_bucket).get("LocationConstraint", "us-east-1")
        return s3_loc.get


def _run_main(self, parsed_args, parsed_globals):
region = parsed_globals.region if parsed_globals.region else "us-east-1"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we error out if region is not supplied? Last thing we want is surprises. If they are using AWS CLI, they have most likely set the region. So this will be raising the error for the 1% case

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That’s a good idea.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That was our initial surprise when hacking that during the hackathon - If you don't supply "--region" during the package command "parsed_globals.region" will be "None" and we were expecting to be whatever was set in the AWS CLI - That's the reason we had this one-liner if.

What's set in the AWS CLI like regions for a profile (default, lab, etc.) only seems to work when you initiate a connection with a service (self._session <- contains a dict of regions set in a profile and likely use those in the absence of one).

Given that we need the region set as a parameter to run some additional logic we have two options here:

  1. Error out if not supplied and end execution there as simple as that
  2. Try capture the default region configured in the AWS CLI (there's gotta be an easy way other than parsing self._session from 'default' or from a 'profile' if set)

Implemented 1st option for now and commit to follow

Thoughts?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heitor, I see your point. But give how buckets are global, this might do more harm than good.

@jamesls Do you know of any option to do Option-2 above (reliably get the Region set in their creds chain)?

s3_client = self._session.create_client(
"s3",
config=Config(signature_version='s3v4'),
region_name=parsed_globals.region,
region_name=region,
verify=parsed_globals.verify_ssl)

template_path = parsed_args.template_file
if not os.path.isfile(template_path):
raise exceptions.InvalidTemplatePathError(
template_path=template_path)
template_path=template_path)

if (parsed_args.s3_bucket is not None):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bracket not needed

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

bucket = parsed_args.s3_bucket
s3_bucket_region = self._get_bucket_region(bucket, s3_client)
if not s3_bucket_region == region:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if s3_bucket_region is not region:

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's an exact word comparison and has to be "==" as "is not" isn't exactly the same here and can lead to surprised and make tests to fail.

raise exceptions.PackageFailedRegionMismatchError(
bucket_region=s3_bucket_region,
deploy_region=region
)
else:
sts_client = self._session.create_client(
"sts",
config=Config(signature_version='s3v4'),
verify=parsed_globals.verify_ssl
)
account = sts_client.get_caller_identity().get('Account', "")
bucket = "sam-{region}-{account}".format(
account=account,
region=region
)

bucket = parsed_args.s3_bucket
# Check if SAM deployment bucket already exists otherwise create it
try:
s3_client.head_bucket(Bucket=bucket)
except ClientError as e:
if e.response["Error"]["Code"] == "404":
sys.stdout.write(
self.MSG_PACKAGE_S3_BUCKET_CREATION.format(
bucket=bucket, region=region))

_s3_params = {
"all_regions": {
"Bucket": bucket
},
"us_standard": {
"Bucket": bucket,
"CreateBucketConfiguration": {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand. I thought the location constraint was for non-us-standard regions. Am I missing something?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right - Fixed.

"LocationConstraint": region
}
}
}

# Create bucket in specified region or else use us-east-1
if parsed_globals.region:
s3_client.create_bucket(**_s3_params['all_regions'])
else:
s3_client.create_bucket(**_s3_params['us_standard'])

self.s3_uploader = S3Uploader(s3_client,
bucket,
Expand All @@ -142,8 +196,8 @@ def _run_main(self, parsed_args, parsed_globals):

if output_file:
msg = self.MSG_PACKAGED_TEMPLATE_WRITTEN.format(
output_file_name=output_file,
output_file_path=os.path.abspath(output_file))
output_file_name=output_file,
output_file_path=os.path.abspath(output_file))
sys.stdout.write(msg)

sys.stdout.flush()
Expand All @@ -154,7 +208,8 @@ def _export(self, template_path, use_json):
exported_template = template.export()

if use_json:
exported_str = json.dumps(exported_template, indent=4, ensure_ascii=False)
exported_str = json.dumps(
exported_template, indent=4, ensure_ascii=False)
else:
exported_str = yaml_dump(exported_template)

Expand Down
62 changes: 62 additions & 0 deletions tests/unit/customizations/cloudformation/test_package.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from awscli.customizations.cloudformation.package import PackageCommand
from awscli.customizations.cloudformation.artifact_exporter import Template
from awscli.customizations.cloudformation.yamlhelper import yaml_dump
from awscli.customizations.cloudformation.exceptions import PackageFailedRegionMismatchError


class FakeArgs(object):
Expand Down Expand Up @@ -74,6 +75,8 @@ def test_main(self, mock_yaml_dump):
self.parsed_args.template_file = filename
self.parsed_args.use_json=use_json

self.package_command._get_bucket_region = MagicMock(return_value="us-east-1")

rc = self.package_command._run_main(self.parsed_args, self.parsed_globals)
self.assertEquals(rc, 0)

Expand All @@ -84,7 +87,63 @@ def test_main(self, mock_yaml_dump):
self.package_command._export.reset_mock()
self.package_command.write_output.reset_mock()

@patch("awscli.customizations.cloudformation.package.yaml_dump")
def test_main_without_bucket(self, mock_yaml_dump):
exported_template_str = "hello"

self.package_command.write_output = Mock()
self.package_command._export = Mock()
mock_yaml_dump.return_value = exported_template_str

# Create a temporary file and make this my template
with tempfile.NamedTemporaryFile() as handle:
for use_json in (False, True):
filename = handle.name
self.parsed_args.template_file = filename
self.parsed_args.use_json = use_json

self.package_command._get_bucket_region = MagicMock(
return_value="us-east-1")
self.parsed_args.s3_bucket = None

rc = self.package_command._run_main(
self.parsed_args, self.parsed_globals)
self.assertEquals(rc, 0)

self.package_command._export.assert_called_once_with(
filename, use_json)
self.package_command.write_output.assert_called_once_with(
self.parsed_args.output_template_file, mock.ANY)

self.package_command._export.reset_mock()
self.package_command.write_output.reset_mock()

@patch("awscli.customizations.cloudformation.package.yaml_dump")
def test_main_bucket_different_deployment_region(self, mock_yaml_dump):
exported_template_str = "hello"

self.package_command.write_output = Mock()
self.package_command._export = Mock()
mock_yaml_dump.return_value = exported_template_str

# Create a temporary file and make this my template
with tempfile.NamedTemporaryFile() as handle:
for use_json in (False, True):
filename = handle.name
self.parsed_args.template_file = filename
self.parsed_args.use_json = use_json

self.package_command._get_bucket_region = MagicMock(
return_value="us-east-1")
self.parsed_args.s3_bucket = "bucket-in-different-region"
self.parsed_globals.region = "eu-west-1"

with self.assertRaises(PackageFailedRegionMismatchError):
self.package_command._run_main(
self.parsed_args, self.parsed_globals)

self.package_command._export.reset_mock()
self.package_command.write_output.reset_mock()

def test_main_error(self):

Expand All @@ -96,6 +155,9 @@ def test_main_error(self):
filename = handle.name
self.parsed_args.template_file = filename

self.package_command._get_bucket_region = MagicMock(
return_value="us-east-1")

with self.assertRaises(RuntimeError):
self.package_command._run_main(self.parsed_args, self.parsed_globals)

Expand Down