From 40cc803b50a11737d547dce12817ee01cb054e44 Mon Sep 17 00:00:00 2001 From: "Eric J. Holmes" Date: Fri, 9 Mar 2018 16:17:57 -0800 Subject: [PATCH] Add support for AWS profiles --- .circleci/config.yml | 1 + docs/config.rst | 50 ++++++++++++++++++++++- stacker/actions/base.py | 3 +- stacker/config/__init__.py | 2 + stacker/providers/aws/default.py | 4 +- stacker/session_cache.py | 14 +++---- stacker/stack.py | 1 + stacker/tests/actions/test_destroy.py | 1 + stacker/tests/factories.py | 2 +- stacker/tests/fixtures/mock_blueprints.py | 34 ++++++++++++++- stacker/ui.py | 9 ++++ tests/README.md | 2 +- tests/suite.bats | 25 ++++++++++++ tests/test_helper.bash | 20 +++++++++ 14 files changed, 153 insertions(+), 15 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 8fb021e72..ae761e051 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -48,4 +48,5 @@ jobs: export TERM=xterm export AWS_DEFAULT_REGION=us-east-1 export STACKER_NAMESPACE=stacker-functional-tests-$CIRCLE_BUILD_NUM + export STACKER_ROLE=arn:aws:iam::487039194316:role/stacker-functional-tests-stacke-FunctionalTestRole-JKTNS8IDNZZ4 sudo -E make test-functional diff --git a/docs/config.rst b/docs/config.rst index 7003eb99b..3fbaab917 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -360,7 +360,12 @@ A stack has the following keys: (optional): If provided, specifies the name of the region that the CloudFormation stack should reside in. If not provided, the default region will be used (``AWS_DEFAULT_REGION``, ``~/.aws/config`` or the ``--region`` - flag). + flag). If both ``region`` and ``profile`` are specified, the value here takes + precedence over the value in the profile. +**profile**: + (optional): If provided, specifies the name of a AWS profile to use when + performing AWS API calls for this stack. This can be used to provision stacks + in multiple accounts or regions. Here's an example from stacker_blueprints_, used to create a VPC:: @@ -482,6 +487,48 @@ Note: Doing this creates an implicit dependency from the *webservers* stack to the *vpc* stack, which will cause stacker to submit the *vpc* stack, and then wait until it is complete until it submits the *webservers* stack. +Multi Account/Region Provisioning +--------------------------------- + +You can use stacker to manage CloudFormation stacks in multiple accounts and +regions, and reference outputs across them. + +As an example, let's say you had 3 accounts you wanted to manage: + +#) OpsAccount: An AWS account that has IAM users for employees. +#) ProdAccount: An AWS account for a "production" environment. +#) StageAccount: An AWS account for a "staging" environment. + +You want employees with IAM user accounts in OpsAccount to be able to assume +roles in both the ProdAccount and StageAccount. You can use stacker to easily +manage this:: + + + stacks: + # Create some stacks in both the "prod" and "stage" accounts with IAM roles + # that employees can use. + - name: prod/roles + profile: prod + class_path: blueprints.Roles + - name: stage/roles + profile: stage + class_path: blueprints.Roles + + # Create a stack in the "ops" account and grant each employee access to + # assume the roles we created above. + - name: users + profile: ops + class_path: blueprints.IAMUsers + variables: + Users: + john-smith: + Roles: + - ${output prod/roles::EmployeeRoleARN} + - ${output stage/roles::EmployeeRoleARN} + + +Note how I was able to reference outputs from stacks in multiple accounts using the `output` plugin! + Environments ============ @@ -511,3 +558,4 @@ submitting it to CloudFormation. For more information, see the .. _Mappings: http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/mappings-section-structure.html .. _Outputs: http://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/outputs-section-structure.html .. _stacker_blueprints: https://github.com/remind101/stacker_blueprints +.. _`AWS profiles`: https://docs.aws.amazon.com/cli/latest/userguide/cli-multiple-profiles.html diff --git a/stacker/actions/base.py b/stacker/actions/base.py index 7b1de5872..65e8a490b 100644 --- a/stacker/actions/base.py +++ b/stacker/actions/base.py @@ -210,7 +210,8 @@ def post_run(self, *args, **kwargs): def build_provider(self, stack): """Builds a :class:`stacker.providers.base.Provider` suitable for operating on the given :class:`stacker.Stack`.""" - return self.provider_builder.build(region=stack.region) + return self.provider_builder.build(region=stack.region, + profile=stack.profile) @property def provider(self): diff --git a/stacker/config/__init__.py b/stacker/config/__init__.py index b0a83450e..bca00f7bd 100644 --- a/stacker/config/__init__.py +++ b/stacker/config/__init__.py @@ -275,6 +275,8 @@ class Stack(Model): region = StringType(serialize_when_none=False) + profile = StringType(serialize_when_none=False) + class_path = StringType(serialize_when_none=False) template_path = StringType(serialize_when_none=False) diff --git a/stacker/providers/aws/default.py b/stacker/providers/aws/default.py index 61c2a2807..3687104a0 100644 --- a/stacker/providers/aws/default.py +++ b/stacker/providers/aws/default.py @@ -424,13 +424,13 @@ def __init__(self, region=None, **kwargs): self.region = region self.kwargs = kwargs - def build(self, region=None): + def build(self, region=None, profile=None): # TODO(ejholmes): This class _could_ cache built providers, however, # the building of boto3 clients is _not_ threadsafe. See # https://github.com/boto/boto3/issues/801#issuecomment-245455979 if not region: region = self.region - session = get_session(region=region) + session = get_session(region=region, profile=profile) return Provider(session, region=region, **self.kwargs) diff --git a/stacker/session_cache.py b/stacker/session_cache.py index 7481b3aa8..593bbbcce 100644 --- a/stacker/session_cache.py +++ b/stacker/session_cache.py @@ -1,27 +1,27 @@ import json import os -import botocore import boto3 import logging +from .ui import ui -def get_session(region): +def get_session(region, profile=None): """Creates a boto3 session with a cache Args: region (str): The region for the session + profile (str): The profile for the session Returns: :class:`boto3.session.Session`: A boto3 session with credential caching """ - session = botocore.session.get_session() - if region is not None: - session.set_config_variable('region', region) - c = session.get_component('credential_provider') + session = boto3.Session(region_name=region, profile_name=profile) + c = session._session.get_component('credential_provider') provider = c.get_provider('assume-role') provider.cache = CredentialCache() - return boto3.session.Session(botocore_session=session) + provider._prompter = ui.getpass + return session logger = logging.getLogger(__name__) diff --git a/stacker/stack.py b/stacker/stack.py index 84811adbf..bcb59fad4 100644 --- a/stacker/stack.py +++ b/stacker/stack.py @@ -64,6 +64,7 @@ def __init__(self, definition, context, variables=None, mappings=None, self.name = definition.name self.fqn = context.get_fqn(definition.stack_name or self.name) self.region = definition.region + self.profile = definition.profile self.definition = definition self.variables = _gather_variables(definition) self.mappings = mappings diff --git a/stacker/tests/actions/test_destroy.py b/stacker/tests/actions/test_destroy.py index f41d0b90c..d829f1576 100644 --- a/stacker/tests/actions/test_destroy.py +++ b/stacker/tests/actions/test_destroy.py @@ -22,6 +22,7 @@ def __init__(self, name, tags=None, **kwargs): self.name = name self.fqn = name self.region = None + self.profile = None self.requires = [] diff --git a/stacker/tests/factories.py b/stacker/tests/factories.py index 417051f0b..898e805eb 100644 --- a/stacker/tests/factories.py +++ b/stacker/tests/factories.py @@ -15,7 +15,7 @@ def __init__(self, provider, region=None): self.provider = provider self.region = region - def build(self, region): + def build(self, region, profile): return self.provider diff --git a/stacker/tests/fixtures/mock_blueprints.py b/stacker/tests/fixtures/mock_blueprints.py index 408a0fa31..d2b2394bb 100644 --- a/stacker/tests/fixtures/mock_blueprints.py +++ b/stacker/tests/fixtures/mock_blueprints.py @@ -1,11 +1,12 @@ from troposphere import GetAtt, Output, Sub, Ref from troposphere import iam -from awacs.aws import Policy, Statement +from awacs.aws import Policy, Statement, AWSPrincipal import awacs import awacs.s3 import awacs.cloudformation import awacs.iam +import awacs.sts from troposphere.cloudformation import WaitCondition, WaitConditionHandle @@ -94,11 +95,36 @@ def create_template(self): awacs.cloudformation.DescribeStacks, awacs.cloudformation.DescribeStackEvents])])) + principal = AWSPrincipal(Ref("AWS::AccountId")) + role = t.add_resource( + iam.Role( + "FunctionalTestRole", + AssumeRolePolicyDocument=Policy( + Statement=[ + Statement( + Effect="Allow", + Action=[ + awacs.sts.AssumeRole], + Principal=principal)]), + Policies=[ + stacker_policy])) + + assumerole_policy = iam.Policy( + PolicyName="AssumeRole", + PolicyDocument=Policy( + Statement=[ + Statement( + Effect="Allow", + Resource=[GetAtt(role, "Arn")], + Action=[ + awacs.sts.AssumeRole])])) + user = t.add_resource( iam.User( "FunctionalTestUser", Policies=[ - stacker_policy])) + stacker_policy, + assumerole_policy])) key = t.add_resource( iam.AccessKey( @@ -112,6 +138,10 @@ def create_template(self): Output( "SecretAccessKey", Value=GetAtt("FunctionalTestKey", "SecretAccessKey"))) + t.add_output( + Output( + "FunctionalTestRole", + Value=GetAtt(role, "Arn"))) class Dummy(Blueprint): diff --git a/stacker/ui.py b/stacker/ui.py index 213f4640b..4df14033e 100644 --- a/stacker/ui.py +++ b/stacker/ui.py @@ -1,5 +1,6 @@ import threading import logging +from getpass import getpass logger = logging.getLogger(__name__) @@ -48,6 +49,14 @@ def ask(self, message): finally: self.unlock() + def getpass(self, *args): + """Wraps getpass to lock the UI.""" + try: + self.lock() + return getpass(*args) + finally: + self.unlock() + # Global UI object for other modules to use. ui = UI() diff --git a/tests/README.md b/tests/README.md index 23416438e..e41ab87f8 100644 --- a/tests/README.md +++ b/tests/README.md @@ -11,7 +11,6 @@ This directory contains the functional testing suite for stacker. It exercises a ```console $ export STACKER_NAMESPACE=my-stacker-test-namespace - $ export AWS_DEFAULT_REGION=us-east-1 ``` 3. Generate an IAM user for the test suite to use: @@ -24,6 +23,7 @@ This directory contains the functional testing suite for stacker. It exercises a $ ./stacker.yaml.sh | stacker info - $ export AWS_ACCESS_KEY_ID=access-key $ export AWS_SECRET_ACCESS_KEY=secret-access-key + $ export STACKER_ROLE= ``` 5. Run the test suite: diff --git a/tests/suite.bats b/tests/suite.bats index 068a28768..88837650e 100755 --- a/tests/suite.bats +++ b/tests/suite.bats @@ -909,3 +909,28 @@ EOF assert_has_line "${STACKER_NAMESPACE}-app: submitted (creating new stack)" assert_has_line "${STACKER_NAMESPACE}-app: complete (creating new stack)" } + +@test "stacker build - profiles" { + needs_aws + + config() { + cat <&2 echo "To run these tests, you must set a STACKER_ROLE environment variable" + exit 1 +fi + +# Setup a base .aws/config that can be use to test stack configurations that +# require stacker to assume a role. +export AWS_CONFIG_DIR=$(mktemp -d) +export AWS_CONFIG_FILE="$AWS_CONFIG_DIR/config" + +cat < "$AWS_CONFIG_FILE" +[default] +region = us-east-1 + +[profile stacker] +region = us-east-1 +role_arn = ${STACKER_ROLE} +credential_source = Environment +EOF + # Simple wrapper around the builtin bash `test` command. assert() { builtin test "$@"