def test_cfn_main() -> None: class MyCFNMain(CFNMain): def create_stack(self) -> Stack: return Stack(name="teststack") aws_env = AWSEnv(regions=["us-east-1"], stub=True) with default_region("us-east-1"): aws_env.client("cloudformation", region="us-east-1") stubber = aws_env.stub("cloudformation") stubber.add_response("validate_template", {}, {"TemplateBody": ANY}) stubber.add_response( "create_stack", {}, { "Capabilities": ["CAPABILITY_IAM", "CAPABILITY_NAMED_IAM"], "StackName": "teststack", "ClientRequestToken": ANY, "TemplateBody": ANY, }, ) stubber.add_response("describe_stacks", {}, {"StackName": "teststack"}) with stubber: m = MyCFNMain(regions=["us-east-1"]) m.execute(args=["push", "--no-wait"], aws_env=aws_env)
def test_cfn_main_multiple_stacks(): class MyCFNMain(CFNMain): def create_stack(self): return [Stack(name="first-stack"), Stack(name="second-stack")] aws_env = AWSEnv(regions=["us-east-1"], stub=True) with default_region("us-east-1"): aws_env.client("cloudformation", region="us-east-1") stubber = aws_env.stub("cloudformation") for stack_name in ("first-stack", "second-stack"): stubber.add_response("validate_template", {}, {"TemplateBody": ANY}) stubber.add_response( "create_stack", {}, { "Capabilities": ["CAPABILITY_IAM", "CAPABILITY_NAMED_IAM"], "StackName": f"{stack_name}", "TemplateBody": ANY, }, ) stubber.add_response("describe_stacks", {}, {"StackName": f"{stack_name}"}) with stubber: m = MyCFNMain(regions=["us-east-1"]) m.execute(args=["push", "--no-wait"], aws_env=aws_env)
def test_cfn_main_s3(): class MyCFNMain(CFNMain): def create_stack(self): return Stack(name='teststack') os.mkdir('data') aws_env = AWSEnv(regions=['us-east-1'], stub=True) with default_region('us-east-1'): aws_env.client('cloudformation', region='us-east-1') stubber = aws_env.stub('cloudformation') stubber.add_response('validate_template', {}, {'TemplateURL': ANY}) stubber.add_response('create_stack', {}, { 'Capabilities': ['CAPABILITY_IAM'], 'StackName': 'teststack', 'TemplateURL': ANY }) stubber.add_response('describe_stacks', {}, {'StackName': 'teststack'}) aws_env.client('s3', region='us-east-1') s3_stubber = aws_env.stub('s3') s3_stubber.add_response( 'put_object', DEFAULT_S3_ANSWER, { 'Bucket': 'superbucket', 'Body': ANY, 'Key': ANY, 'ServerSideEncryption': 'AES256' }) with stubber: with s3_stubber: m = MyCFNMain(regions=['us-east-1'], data_dir='data', s3_bucket='superbucket', s3_key='test_key') m.execute(args=['push'], aws_env=aws_env)
def test_create_change_set(): s = Stack(name="teststack") aws_env = AWSEnv(regions=["us-east-1"]) with default_region("us-east-1"): cfn_client = aws_env.client("cloudformation", region="us-east-1") stubber = Stubber(cfn_client) stubber.add_response( "create_change_set", {}, { "Capabilities": ["CAPABILITY_IAM", "CAPABILITY_NAMED_IAM"], "StackName": "teststack", "ChangeSetName": "name1", "TemplateBody": ANY, }, ) stubber.add_response( "create_change_set", {}, { "Capabilities": ["CAPABILITY_IAM", "CAPABILITY_NAMED_IAM"], "ChangeSetName": "name2", "StackName": "teststack", "TemplateURL": ANY, }, ) with stubber: s.create_change_set("name1") s.create_change_set("name2", url="noprotocol://nothing")
def test_create_change_set(): s = Stack(name='teststack') aws_env = AWSEnv(regions=['us-east-1']) with default_region('us-east-1'): cfn_client = aws_env.client('cloudformation', region='us-east-1') stubber = Stubber(cfn_client) stubber.add_response( 'create_change_set', {}, { 'Capabilities': ['CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM'], 'StackName': 'teststack', 'ChangeSetName': 'name1', 'TemplateBody': ANY }) stubber.add_response( 'create_change_set', {}, { 'Capabilities': ['CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM'], 'ChangeSetName': 'name2', 'StackName': 'teststack', 'TemplateURL': ANY }) with stubber: s.create_change_set('name1') s.create_change_set('name2', url='noprotocol://nothing')
def test_cfn_main_s3() -> None: class MyCFNMain(CFNMain): def create_stack(self) -> Stack: return Stack(name="teststack") os.mkdir("data") aws_env = AWSEnv(regions=["us-east-1"], stub=True) with default_region("us-east-1"): aws_env.client("cloudformation", region="us-east-1") stubber = aws_env.stub("cloudformation") stubber.add_response("validate_template", {}, {"TemplateURL": ANY}) stubber.add_response( "create_stack", {}, { "Capabilities": ["CAPABILITY_IAM", "CAPABILITY_NAMED_IAM"], "StackName": "teststack", "ClientRequestToken": ANY, "TemplateURL": ANY, }, ) stubber.add_response("describe_stacks", {}, {"StackName": "teststack"}) aws_env.client("s3", region="us-east-1") s3_stubber = aws_env.stub("s3") s3_stubber.add_response( "put_object", DEFAULT_S3_ANSWER, { "Bucket": "superbucket", "Body": ANY, "Key": ANY, "ServerSideEncryption": "AES256", }, ) with stubber: with s3_stubber: m = MyCFNMain( regions=["us-east-1"], data_dir="data", s3_bucket="superbucket", s3_key="test_key", ) m.execute(args=["push", "--no-wait"], aws_env=aws_env)
def test_cfn_main(): class MyCFNMain(CFNMain): def create_stack(self): return Stack(name='teststack') aws_env = AWSEnv(regions=['us-east-1'], stub=True) with default_region('us-east-1'): aws_env.client('cloudformation', region='us-east-1') stubber = aws_env.stub('cloudformation') stubber.add_response('validate_template', {}, {'TemplateBody': ANY}) stubber.add_response('create_stack', {}, { 'Capabilities': ['CAPABILITY_IAM'], 'StackName': 'teststack', 'TemplateBody': ANY }) stubber.add_response('describe_stacks', {}, {'StackName': 'teststack'}) with stubber: m = MyCFNMain(regions=['us-east-1']) m.execute(args=['push'], aws_env=aws_env)
def test_validate(): s = Stack(name='teststack') aws_env = AWSEnv(regions=['us-east-1']) with default_region('us-east-1'): cfn_client = aws_env.client('cloudformation', region='us-east-1') stubber = Stubber(cfn_client) stubber.add_response('validate_template', {}, {'TemplateBody': ANY}) stubber.add_response('validate_template', {}, {'TemplateURL': ANY}) with stubber: s.validate() s.validate(url='noprotocol://nothing')
def test_validate(): s = Stack(name="teststack") aws_env = AWSEnv(regions=["us-east-1"]) with default_region("us-east-1"): cfn_client = aws_env.client("cloudformation", region="us-east-1") stubber = Stubber(cfn_client) stubber.add_response("validate_template", {}, {"TemplateBody": ANY}) stubber.add_response("validate_template", {}, {"TemplateURL": ANY}) with stubber: s.validate() s.validate(url="noprotocol://nothing")
def test_create_stack(): s = Stack(name='teststack') aws_env = AWSEnv(regions=['us-east-1']) with default_region('us-east-1'): cfn_client = aws_env.client('cloudformation', region='us-east-1') stubber = Stubber(cfn_client) stubber.add_response('create_stack', {}, { 'Capabilities': ['CAPABILITY_IAM'], 'StackName': 'teststack', 'TemplateBody': ANY }) with stubber: s.create()
class CFNMain(Main, metaclass=abc.ABCMeta): """Main to handle CloudFormation stack from command line.""" def __init__(self, regions, force_profile=None, data_dir=None, s3_bucket=None, s3_key=''): """Initialize main. :param regions: list of regions on which we can operate :type regions: list[str] :param force_profile: if None then add ability to select a credential profile to use. Otherwise profile is set :type force_profile: str | None :param data_dir: directory containing files used by cfn-init :type data_dir: str | None :param s3_bucket: if defined S3 will be used as a proxy for resources. Template body will be uploaded to S3 before calling operation on it. This change the body limit from 50Ko to 500Ko. Additionally if data_dir is defined, the directory will be uploaded to the specified S3 bucket. :param s3_key: if s3_bucket is defined, then all uploaded resources will be stored under a subkey of s3_key. If not defined the root of the bucket is used. :type: str """ super(CFNMain, self).__init__(platform_args=False) self.argument_parser.add_argument('--profile', help='choose AWS profile', default='default') if len(regions) > 1: self.argument_parser.add_argument( '--region', help='choose region (default: %s)' % regions[0], default=regions[0]) else: self.argument_parser.set_defaults(region=regions[0]) subs = self.argument_parser.add_subparsers( title='commands', description='available commands') create_args = subs.add_parser('push', help='push a stack') create_args.add_argument( '--wait', action='store_true', default=False, help='if used then wait for stack creation completion') create_args.set_defaults(command='push') self.regions = regions self.data_dir = data_dir self.s3_bucket = s3_bucket self.s3_data_key = None self.s3_data_url = None self.s3_template_key = None self.s3_template_url = None self.timestamp = str(int(time.time())) if s3_bucket is not None: s3_root_key = '/'.join([s3_key.rstrip('/'), self.timestamp ]).strip('/') + '/' self.s3_data_key = s3_root_key + 'data/' self.s3_data_url = 'https://%s.s3.amazonaws.com/%s' % \ (self.s3_bucket, self.s3_data_key) self.s3_template_key = s3_root_key + 'template' self.s3_template_url = 'https://%s.s3.amazonaws.com/%s' % \ (self.s3_bucket, self.s3_template_key) def execute(self, args=None, known_args_only=False, aws_env=None): """Execute application and return exit status. See parse_args arguments. """ super(CFNMain, self).parse_args(args, known_args_only) if aws_env is not None: self.aws_env = aws_env else: self.aws_env = AWSEnv(regions=self.regions, profile=self.args.profile) self.aws_env.default_region = self.args.region try: if self.args.command == 'push': if self.data_dir is not None and self.s3_data_key is not None: s3 = self.aws_env.client('s3') # synchronize data to the bucket before creating the stack for f in find(self.data_dir): with open(f, 'rb') as fd: subkey = os.path.relpath(f, self.data_dir).replace( '\\', '/') logging.info('Upload %s to %s:%s%s', subkey, self.s3_bucket, self.s3_data_key, subkey) s3.put_object(Bucket=self.s3_bucket, Body=fd, ServerSideEncryption='AES256', Key=self.s3_data_key + subkey) s = self.create_stack() if self.s3_template_key is not None: logging.info('Upload template to %s:%s', self.s3_bucket, self.s3_template_key) s3.put_object(Bucket=self.s3_bucket, Body=s.body.encode('utf-8'), ServerSideEncryption='AES256', Key=self.s3_template_key) logging.info('Validate template for stack %s' % s.name) s.validate(url=self.s3_template_url) if s.exists(): changeset_name = 'changeset%s' % int(time.time()) logging.info('Push changeset: %s' % changeset_name) s.create_change_set(changeset_name, url=self.s3_template_url) result = s.describe_change_set(changeset_name) while result['Status'] in ('CREATE_PENDING', 'CREATE_IN_PROGRESS'): time.sleep(1.0) result = s.describe_change_set(changeset_name) if result['Status'] == 'FAILED': logging.error(result['StatusReason']) s.delete_change_set(changeset_name) return 1 else: print(yaml.safe_dump(result['Changes'])) return 0 else: logging.info('Create new stack') s.create(url=self.s3_template_url) state = s.state() if self.args.wait: while 'PROGRESS' in state['Stacks'][0]['StackStatus']: result = s.resource_status(in_progress_only=False) print(result) time.sleep(10.0) state = s.state() else: return 1 return 0 except botocore.exceptions.ClientError as e: logging.error(str(e)) return 1 @abc.abstractmethod def create_stack(self): """Create a stack.
def test_cfn_main_push_existing_stack(status: Tuple[str, str, int], monkeypatch: MonkeyPatch) -> None: """Test pushing an already existing stack. :param status: Tuple with status and status reason from describe_change_set response and associated expected execute return value. """ class MyCFNMain(CFNMain): def create_stack(self): return [Stack(name="existing-stack")] aws_env = AWSEnv(regions=["us-east-1"], stub=True) with default_region("us-east-1"): aws_env.client("cloudformation", region="us-east-1") stack_name = "existing-stack" stubber = aws_env.stub("cloudformation") stubber.add_response("validate_template", {}, {"TemplateBody": ANY}) stubber.add_response( "describe_stacks", service_response={ "Stacks": [{ "StackName": stack_name, "CreationTime": datetime(2016, 1, 20, 22, 9), "StackStatus": "CREATE_COMPLETE", "StackId": stack_name + "1", }] }, expected_params={"StackName": stack_name}, ) stubber.add_response( "create_change_set", {}, { "Capabilities": ["CAPABILITY_IAM", "CAPABILITY_NAMED_IAM"], "ChangeSetName": ANY, "StackName": stack_name, "TemplateBody": ANY, }, ) stubber = stubber stubber.add_response( "describe_change_set", { "StackName": stack_name, "Status": status[0], "StatusReason": status[1], "Changes": [], }, { "ChangeSetName": ANY, "StackName": stack_name }, ) if status[0] == "FAILED": stubber.add_response("delete_change_set", {}, { "ChangeSetName": ANY, "StackName": stack_name }) else: stubber.add_response( "execute_change_set", {}, { "ChangeSetName": ANY, "StackName": ANY, "ClientRequestToken": ANY }, ) stubber.add_response( "describe_stacks", service_response={ "Stacks": [{ "StackName": stack_name, "CreationTime": datetime(2016, 1, 20, 22, 9), "StackStatus": "UPDATE_COMPLETE", "StackId": stack_name + "1", }] }, expected_params={"StackName": ANY}, ) monkeypatch.setattr("builtins.input", lambda _: "Y") with stubber: m = MyCFNMain(regions=["us-east-1"]) assert m.execute(args=["update", "--no-wait"], aws_env=aws_env) == status[2]
class CFNMain(Main, metaclass=abc.ABCMeta): """Main to handle CloudFormation stack from command line.""" def __init__( self, regions, default_profile="default", data_dir=None, s3_bucket=None, s3_key="", assume_role=None, ): """Initialize main. :param regions: list of regions on which we can operate :type regions: list[str] :param default_profile: default AWS profile to use to create the stack :type default_region: str :param data_dir: directory containing files used by cfn-init :type data_dir: str | None :param s3_bucket: if defined S3 will be used as a proxy for resources. Template body will be uploaded to S3 before calling operation on it. This change the body limit from 50Ko to 500Ko. Additionally if data_dir is defined, the directory will be uploaded to the specified S3 bucket. :param s3_key: if s3_bucket is defined, then all uploaded resources will be stored under a subkey of s3_key. If not defined the root of the bucket is used. :type: str :param assume_role: tuple containing the two values that are passed to Session.assume_role() :type assume_role: str """ super(CFNMain, self).__init__(platform_args=False) self.argument_parser.add_argument( "--profile", help="choose AWS profile, default is {}".format(default_profile), default=default_profile, ) if len(regions) > 1: self.argument_parser.add_argument( "--region", help="choose region (default: %s)" % regions[0], default=regions[0], ) else: self.argument_parser.set_defaults(region=regions[0]) subs = self.argument_parser.add_subparsers( title="commands", description="available commands", dest="command") subs.required = True create_args = subs.add_parser("push", help="push a stack") create_args.add_argument( "--no-wait", action="store_false", default=True, dest="wait_stack_creation", help="do not wait for stack creation completion", ) create_args.set_defaults(command="push") update_args = subs.add_parser("update", help="update a stack") update_args.add_argument( "--no-apply", action="store_false", default=True, dest="apply_changeset", help="do not ask whether to apply the changeset", ) update_args.set_defaults(command="update") show_args = subs.add_parser("show", help="show the changeset content") show_args.set_defaults(command="show") protect_args = subs.add_parser( "protect", help="protect the stack against deletion") protect_args.set_defaults(command="protect") self.regions = regions self.data_dir = data_dir self.s3_bucket = s3_bucket self.s3_data_key = None self.s3_data_url = None self.s3_template_key = None self.s3_template_url = None self.assume_role = assume_role self.timestamp = str(int(time.time())) if s3_bucket is not None: s3_root_key = ( "/".join([s3_key.rstrip("/"), self.timestamp]).strip("/") + "/") self.s3_data_key = s3_root_key + "data/" self.s3_data_url = "https://%s.s3.amazonaws.com/%s" % ( self.s3_bucket, self.s3_data_key, ) self.s3_template_key = s3_root_key + "template" self.s3_template_url = "https://%s.s3.amazonaws.com/%s" % ( self.s3_bucket, self.s3_template_key, ) def execute(self, args=None, known_args_only=False, aws_env=None): """Execute application and return exit status. See parse_args arguments. """ super(CFNMain, self).parse_args(args, known_args_only) if aws_env is not None: self.aws_env = aws_env else: if self.assume_role: main_session = Session(regions=self.regions, profile=self.args.profile) self.aws_env = main_session.assume_role( self.assume_role[0], self.assume_role[1]) # ??? needed since we still use a global variable for AWSEnv Env().aws_env = self.aws_env else: self.aws_env = AWSEnv(regions=self.regions, profile=self.args.profile) self.aws_env.default_region = self.args.region try: if self.args.command in ("push", "update"): if self.data_dir is not None and self.s3_data_key is not None: s3 = self.aws_env.client("s3") # synchronize data to the bucket before creating the stack for f in find(self.data_dir): with open(f, "rb") as fd: subkey = os.path.relpath(f, self.data_dir).replace( "\\", "/") logging.info( "Upload %s to %s:%s%s", subkey, self.s3_bucket, self.s3_data_key, subkey, ) s3.put_object( Bucket=self.s3_bucket, Body=fd, ServerSideEncryption="AES256", Key=self.s3_data_key + subkey, ) s = self.create_stack() if self.s3_template_key is not None: logging.info("Upload template to %s:%s", self.s3_bucket, self.s3_template_key) s3.put_object( Bucket=self.s3_bucket, Body=s.body.encode("utf-8"), ServerSideEncryption="AES256", Key=self.s3_template_key, ) logging.info("Validate template for stack %s" % s.name) try: s.validate(url=self.s3_template_url) except Exception: logging.error("Invalid cloud formation template") logging.error(s.body) raise if s.exists(): changeset_name = "changeset%s" % int(time.time()) logging.info("Push changeset: %s" % changeset_name) s.create_change_set(changeset_name, url=self.s3_template_url) result = s.describe_change_set(changeset_name) while result["Status"] in ("CREATE_PENDING", "CREATE_IN_PROGRESS"): time.sleep(1.0) result = s.describe_change_set(changeset_name) if result["Status"] == "FAILED": logging.error(result["StatusReason"]) s.delete_change_set(changeset_name) return 1 else: for el in result["Changes"]: if "ResourceChange" not in el: continue logging.info( "%-8s %-32s: (replacement:%s)", el["ResourceChange"].get("Action"), el["ResourceChange"].get("LogicalResourceId"), el["ResourceChange"].get("Replacement", "n/a"), ) if self.args.apply_changeset: ask = input("Apply change (y/N): ") if ask[0] in "Yy": return s.execute_change_set( changeset_name=changeset_name, wait=True) return 0 else: logging.info("Create new stack") s.create(url=self.s3_template_url) state = s.state() if self.args.wait_stack_creation: logging.info("waiting for stack creation...") while "PROGRESS" in state["Stacks"][0]["StackStatus"]: result = s.resource_status(in_progress_only=False) time.sleep(10.0) state = s.state() logging.info("done") elif self.args.command == "show": s = self.create_stack() print(s.body) elif self.args.command == "protect": s = self.create_stack() # Enable termination protection result = s.enable_termination_protection() if self.stack_policy_body is not None: s.set_stack_policy(self.stack_policy_body) else: print("No stack policy to set") return 0 except botocore.exceptions.ClientError as e: logging.error(str(e)) return 1 @abc.abstractmethod def create_stack(self): """Create a stack. :return: Stack on which the application will operate :rtype: Stack """ pass @property def stack_policy_body(self): """Stack Policy that can be set by calling the command ``protect``. :return: the inline stack policy :rtype: str """ return None