├── .gitignore ├── .travis.yml ├── Dockerfile ├── Dockerfile.release ├── Dockerfile.whalebrew ├── LICENSE ├── Makefile ├── README.md ├── awsie ├── __init__.py └── cli.py ├── build-requirements.txt ├── docker-compose.yml ├── docs └── _index.md ├── scripts ├── start_docker ├── upload_to_pypi └── whalebrew ├── setup.cfg ├── setup.py └── tests ├── __init__.py └── test_cli.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | 91 | # IDE 92 | .idea 93 | 94 | # Pypi 95 | README.rst -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | sudo: false 3 | 4 | python: 5 | - 3.6 6 | 7 | install: 8 | - pip install -U -r build-requirements.txt 9 | - python setup.py develop 10 | 11 | script: 12 | - make test 13 | 14 | after_success: 15 | - coveralls 16 | 17 | 18 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.6 2 | 3 | WORKDIR /app 4 | 5 | RUN apt-get update -y -qq 6 | RUN apt-get install -y -qq groff pandoc 7 | 8 | RUN pip install -U wheel pygments twine 9 | RUN pip install -U awslogs awscli 10 | COPY build-requirements.txt build-requirements.txt 11 | RUN pip install -U -r build-requirements.txt 12 | 13 | COPY setup.py setup.py 14 | COPY setup.cfg setup.cfg 15 | COPY awsie/__init__.py awsie/__init__.py 16 | COPY README.md README.md 17 | 18 | RUN pandoc --from=markdown --to=rst --output=README.rst README.md 19 | 20 | RUN python setup.py develop 21 | -------------------------------------------------------------------------------- /Dockerfile.release: -------------------------------------------------------------------------------- 1 | FROM python:alpine 2 | 3 | RUN apk add --no-cache groff less mailcap 4 | RUN pip install -U awscli awsie 5 | 6 | LABEL io.whalebrew.name 'awsie' 7 | LABEL io.whalebrew.config.environment '["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "AWS_DEFAULT_REGION", "AWS_DEFAULT_PROFILE", "AWS_PROFILE", "AWS_CONFIG_FILE"]' 8 | LABEL io.whalebrew.config.volumes '["~/.aws:/.aws"]' 9 | ENTRYPOINT ["awsie"] 10 | -------------------------------------------------------------------------------- /Dockerfile.whalebrew: -------------------------------------------------------------------------------- 1 | FROM python:alpine 2 | 3 | LABEL io.whalebrew.name 'awsie' 4 | LABEL io.whalebrew.config.environment '["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN", "AWS_DEFAULT_REGION", "AWS_DEFAULT_PROFILE", "AWS_CONFIG_FILE"]' 5 | LABEL io.whalebrew.config.volumes '["~/.aws:/.aws"]' 6 | ENTRYPOINT ["awsie"] 7 | 8 | RUN apk add --no-cache groff less mailcap 9 | 10 | RUN pip install -U awscli 11 | 12 | WORKDIR /app 13 | 14 | COPY ./ ./ 15 | 16 | RUN pip install . -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Florian Motlik 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | release-docker: 2 | docker build --no-cache -t flomotlik/awsie -f Dockerfile.release . 3 | docker push flomotlik/awsie 4 | 5 | release-pypi: 6 | docker-compose run awsie bash -c "python setup.py sdist bdist_wheel && pandoc --from=markdown --to=rst --output=build/README.rst README.md && twine upload dist/*" 7 | 8 | release: release-pypi release-docker 9 | 10 | build-dev: 11 | docker-compose build awsie 12 | 13 | clean: 14 | rm -fr dist/* build/* awsie.egg-info/* .pytest_cache/* 15 | 16 | dev: build-dev 17 | docker-compose run awsie bash 18 | 19 | test: 20 | pycodestyle . 21 | pyflakes . 22 | grep -r 'print(' awsie; [ "$$?" -gt 0 ] 23 | py.test --cov=awsie tests -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AWSIE 2 | 3 | [![Build Status](https://travis-ci.org/theserverlessway/awsie.svg?branch=master)](https://travis-ci.org/theserverlessway/awsie) 4 | [![PyPI version](https://badge.fury.io/py/awsie.svg)](https://pypi.python.org/pypi/awsie) 5 | [![license](https://img.shields.io/github/license/theserverlessway/awsie.svg)](LICENSE) 6 | [![Coverage Status](https://coveralls.io/repos/github/theserverlessway/awsie/badge.svg?branch=master)](https://coveralls.io/github/theserverlessway/awsie?branch=master) 7 | 8 | pronounced /ˈɒzi/ oz-ee like our great friends from down under. 9 | 10 | AWSIE is a CloudFormation aware wrapper on top of the AWS CLI. It help you to call an awscli command (or any command), but instead of the actual physical ID of the resource you use the LogicalId, OutputId or ExportName which will be replaced when executing the actual command. 11 | 12 | For many different resources AWS can automatically set a random name when creating the resource through Cloudformation. While this has a big upside with resources not clashing when the same stack gets deployed multipe times, a downside is that running a command against a specific resource means you have to write lookup code or use the resource name by hand. 13 | 14 | Awsie helps you to do that lookup and call the awscli without any potential for clashes. By supporting both LogicalIds, Output and Export variables you have a lot of flexibility for your automation scripts. 15 | 16 | ## Installation 17 | 18 | Before installing make sure you have the `awscli` installed as awsie depends on it. We don't install it ourselves so you're able to install the exact version you want to use. 19 | 20 | Awsie can be installed through pip: 21 | 22 | ```shell 23 | pip3 install -U awscli awsie 24 | ``` 25 | 26 | ## Quick example 27 | 28 | For example when you deploy a CloudFormation stack: 29 | 30 | ```json 31 | { 32 | "Resources": { 33 | "DeploymentBucket": { 34 | "Type": "AWS::S3::Bucket" 35 | } 36 | } 37 | } 38 | ``` 39 | 40 | and then want to list the content of the bucket you can use `awsie`: 41 | 42 | ```shell 43 | awsie example-stack s3 ls s3://cf:DeploymentBucket: --region us-west-1 44 | ``` 45 | 46 | ## Documentation 47 | 48 | Check out the full [Documentation and Quickstart on TheServerlessWay.com](https://theserverlessway.com/tools/awsie/) 49 | -------------------------------------------------------------------------------- /awsie/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | 4 | __version__ = '0.4.2' 5 | 6 | logger = logging.getLogger('awsie') 7 | handler = logging.StreamHandler(sys.stdout) 8 | formatter = logging.Formatter('%(message)s') 9 | handler.setFormatter(formatter) 10 | logger.addHandler(handler) 11 | logger.setLevel(logging.INFO) 12 | -------------------------------------------------------------------------------- /awsie/cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import logging 3 | import os 4 | import re 5 | import subprocess 6 | import sys 7 | 8 | import botocore 9 | import botocore.session 10 | import yaml 11 | from boto3.session import Session 12 | from botocore import credentials 13 | 14 | from . import __version__ 15 | 16 | cli_cache = os.path.join(os.path.expanduser('~'), '.aws/cli/cache') 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | def main(): 22 | parsed_arguments = parse_arguments(sys.argv[1:]) 23 | arguments = parsed_arguments[0] 24 | remaining = parsed_arguments[1] 25 | stack_region = arguments.region 26 | 27 | if arguments.no_stack: 28 | stack = '' 29 | remaining = [arguments.stack] + remaining 30 | else: 31 | stack = arguments.stack 32 | 33 | try: 34 | if stack and os.path.isfile(stack): 35 | with open(stack, 'r') as file: 36 | config = yaml.safe_load(file) 37 | stack = config.get('stack') 38 | config_region = config.get('region') 39 | if config_region: 40 | stack_region = config_region 41 | 42 | if not stack: 43 | logger.info('Config file does not contain stack option.') 44 | sys.exit(1) 45 | 46 | session = create_session(region=stack_region, profile=arguments.profile) 47 | ids = get_resource_ids(session, stack) 48 | if arguments.debug or arguments.verbose: 49 | logger.info('Replacements:') 50 | for key, value in ids.items(): 51 | logger.info(" {}: {}".format(key, value)) 52 | logger.info('') 53 | except Exception as e: 54 | logger.info(e) 55 | sys.exit(1) 56 | 57 | def replacement(matchobject): 58 | match_name = matchobject.group(1) 59 | if not ids.get(match_name): 60 | logger.info('Resource with logical ID "' + match_name + '" does not exist') 61 | sys.exit(1) 62 | return ids[match_name] 63 | 64 | if arguments.command: 65 | command = remaining 66 | else: 67 | command = ['aws'] + remaining 68 | if arguments.region: 69 | command.extend(['--region', arguments.region]) 70 | if arguments.profile: 71 | command.extend(['--profile', arguments.profile]) 72 | new_args = [re.sub('cf:([a-zA-Z0-9-]+(:[a-zA-Z0-9-]+)*):', replacement, argument) for argument in command] 73 | 74 | if arguments.debug or arguments.verbose: 75 | logger.info('Command:') 76 | logger.info(' ' + ' '.join(new_args)) 77 | logger.info('') 78 | 79 | try: 80 | if arguments.debug: 81 | result = 0 82 | else: 83 | result = subprocess.call(new_args) 84 | except OSError: 85 | logger.info('Please make sure "{}" is installed and available in the PATH'.format(command[0])) 86 | sys.exit(1) 87 | 88 | sys.exit(result) 89 | 90 | 91 | def get_resource_ids(session, stack): 92 | try: 93 | ids = {} 94 | client = session.client('cloudformation') 95 | if stack: 96 | paginator = client.get_paginator('list_stack_resources').paginate(StackName=stack) 97 | for page in paginator: 98 | for resource in page.get('StackResourceSummaries', []): 99 | ids[resource['LogicalResourceId']] = resource['PhysicalResourceId'] 100 | 101 | describe_stack = client.describe_stacks(StackName=stack) 102 | stack_outputs = describe_stack['Stacks'][0].get('Outputs', []) 103 | for output in stack_outputs: 104 | ids[output['OutputKey']] = output['OutputValue'] 105 | 106 | paginator = client.get_paginator('list_exports').paginate() 107 | for page in paginator: 108 | for export in page.get('Exports', []): 109 | ids[export['Name']] = export['Value'] 110 | except botocore.exceptions.ClientError as e: 111 | logger.info(e) 112 | sys.exit(1) 113 | return ids 114 | 115 | 116 | def create_session(region, profile): 117 | cached_session = botocore.session.Session(profile=profile) 118 | cached_session.get_component('credential_provider').get_provider('assume-role').cache = credentials.JSONFileCache( 119 | cli_cache) 120 | return Session(botocore_session=cached_session, region_name=region) 121 | 122 | 123 | def parse_arguments(arguments): 124 | parser = argparse.ArgumentParser( 125 | description='Call AWS with substituted CloudFormation values. The first positional argument is used as the ' 126 | 'stack or config file name, all other arguments are forwarded to the AWS CLI. --region and ' 127 | '--profile are used ' 128 | 'to determine which stack to load the resources from and are passed on as well.\\nExample:\\n ' 129 | 'awsie example-stack s3 ls s3://cf:DeploymentBucket:') 130 | 131 | parser.add_argument('--version', action='version', version='{}'.format(__version__)) 132 | parser.add_argument('stack', help='Stack to load resources from') 133 | parser.add_argument('--region', help='The AWS Region to use') 134 | parser.add_argument('--profile', help='The AWS Profile to use') 135 | parser.add_argument('--command', action="store_true", help="If you run a non AWS CLI command") 136 | parser.add_argument('--no-stack', action="store_true", help="If you only use CFN Exports and no Stack data") 137 | parser.add_argument('--verbose', action="store_true", help="Print debug output before running the command") 138 | parser.add_argument('--debug', action="store_true", help="Print debug output and don't run the command") 139 | args = parser.parse_known_args(arguments) 140 | return args 141 | -------------------------------------------------------------------------------- /build-requirements.txt: -------------------------------------------------------------------------------- 1 | pycodestyle 2 | pyflakes 3 | autopep8 4 | coverage 5 | python-coveralls 6 | pytest 7 | pytest-cov 8 | pytest-mock 9 | mock 10 | setuptools 11 | pip 12 | path.py -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | services: 3 | awsie: 4 | build: . 5 | volumes: 6 | - .:/app 7 | - ~/.aws/:/root/.aws 8 | - /app/awsie.egg-info 9 | - /app/build 10 | - /app/dist 11 | - /app/.pytest_cache 12 | environment: 13 | - AWS_ACCESS_KEY_ID 14 | - AWS_SECRET_ACCESS_KEY 15 | - AWS_PROFILE 16 | - AWS_SESSION_TOKEN 17 | - AWS_SECURITY_TOKEN 18 | -------------------------------------------------------------------------------- /docs/_index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: AWSie 3 | subtitle: CloudFormation aware AWS CLI wrapper 4 | weight: 400 5 | disable_pagination: true 6 | --- 7 | 8 | [![Build Status](https://travis-ci.org/theserverlessway/awsie.svg?branch=master)](https://travis-ci.org/theserverlessway/awsie) 9 | [![PyPI version](https://badge.fury.io/py/awsie.svg)](https://pypi.python.org/pypi/awsie) 10 | [![license](https://img.shields.io/github/license/theserverlessway/awsie.svg)](https://github.com/theserverlessway/awsie/blob/master/LICENSE) 11 | [![Coverage Status](https://coveralls.io/repos/github/theserverlessway/awsie/badge.svg?branch=master)](https://coveralls.io/github/theserverlessway/awsie?branch=master) 12 | 13 | pronounced /ˈɒzi/ oz-ee like our great friends from down under. 14 | 15 | AWSIE is a CloudFormation aware wrapper on top of the AWS CLI. It help you to call an awscli command (or any command), but instead of the actual physical ID of the resource you use the LogicalId, OutputId or ExportName which will be replaced when executing the actual command. 16 | 17 | For many different resources AWS can automatically set a random name when creating the resource through Cloudformation. While this has a big upside with resources not clashing when the same stack gets deployed multipe times, a downside is that running a command against a specific resource means you have to write lookup code or use the resource name by hand. 18 | 19 | Awsie helps you to do that lookup and call the awscli without any potential for clashes. By supporting both LogicalIds, Output and Export variables you have a lot of flexibility for your automation scripts. 20 | 21 | ## Installation 22 | 23 | Before installing make sure you have the awscli installed as awsie depends on it. We don't install it ourselves so you're able to install the exact version you want to use. 24 | 25 | ```shell 26 | pip install awscli 27 | ``` 28 | 29 | awsie can be installed through pip: 30 | 31 | ```shell 32 | pip install awsie 33 | ``` 34 | 35 | Alternatively you can clone this repository and run 36 | 37 | ```shell 38 | python setup.py install 39 | ``` 40 | 41 | ## Quick example 42 | 43 | For example when you deploy a CloudFormation stack: 44 | 45 | ```json 46 | { 47 | "Resources": { 48 | "DeploymentBucket": { 49 | "Type": "AWS::S3::Bucket" 50 | } 51 | } 52 | } 53 | ``` 54 | 55 | and then want to list the content of the bucket you can use `awsie`: 56 | 57 | ```shell 58 | awsie example-stack s3 ls s3://cf:DeploymentBucket: --region us-west-1 59 | ``` 60 | 61 | or if you want to remove `somefile` from the `DeploymentBucket`: 62 | 63 | ```shell 64 | awsie example-stack s3 rm s3://cf:DeploymentBucket:/somefile --region us-west-1 65 | ``` 66 | 67 | which will replace `cf:DeploymentBucket:` with the actual name of the resource and run the awscli with all arguments you passed to awsie, except for the stack-name (which has to be the first argument): 68 | 69 | ```shell 70 | aws s3 ls s3://formica-example-stack-deploymentbucket-1jjzisylxreh9 --region us-west-1 71 | aws s3 rm s3://formica-example-stack-deploymentbucket-1jjzisylxreh9/somefile --region us-west-1 72 | ``` 73 | 74 | ## Replacement syntax 75 | 76 | The replacement syntax is `cf:LOGICAL_ID:` and will insert the PhysicalId of the resource with LOGICAL_ID through the data returned from the list-stack-resources API call. Make sure you don't forget the second colon at the end, its important to be able to separate the syntax when its embedded in another string. 77 | 78 | The Regex used is greedy, so `cf:vpc:VPC:` will look for `vpc:VPC` in the variables. That can lead to issues if you want to combine two values directly, e.g. `cf:vpc:VPC1:cf:vpc:VPC2:` which will get `vpc:VPC1:cf:vpc:VPC2` as the replacement key. You need to put a character other than `a-zA-Z0-9:` between the values to separate them, e.g. `cf:vpc:VPC1:-cf:vpc:VPC2:`. 79 | 80 | ## Arbitrary commands 81 | 82 | You can also use `awsie` to run arbitrary commands with replaced values. Simply use the `--command` option so aws isn't prepended. 83 | 84 | ```shell 85 | awsie STACK_NAME --command awsinfo logs cf:LogGroup: 86 | ``` 87 | 88 | ## Config File 89 | 90 | Having to use a specific stack name in the command itself can be an issue as you might change that name in a config file and have to remember to update it in the command as well (e.g. in your Makefile). To solve this awsie supports loading the stack name from a config file. 91 | 92 | If the first argument you're giving to awsie is an existing file it will parse the file with a yaml parser and look for the `stack` option. This makes it easy to combine awsie with tools like [`formica`](https://theserverlessway.com/tools/formica/). 93 | 94 | So if we have a file named `stack.config.yaml` with the following content: 95 | 96 | ```yaml 97 | stack: example-stack 98 | ``` 99 | 100 | then we can run the following command which will successfully load data from `example-stack` just like in the example above. 101 | 102 | ```bash 103 | awsie stack.config.yaml s3 ls s3://cf:DeploymentBucket: --region us-west-1 104 | ``` 105 | 106 | ## No Stack when just using Exports 107 | 108 | In case you want to run a command that just uses CloudFormation Exports as data and therefore don't want to configure a stack you can use the `--no-stack` option. 109 | 110 | ## Verbose Output 111 | 112 | With `--verbose` you'll get a list of all the Keys and Values awsie finds and uses for replacement and the command that will be run before executing it. This makes it easy to debug any potential issues. 113 | 114 | ## Usage 115 | 116 | ```bash 117 | usage: awsie [-h] [--version] [--region REGION] [--profile PROFILE] 118 | [--command] [--no-stack] [--verbose] [--debug] 119 | stack 120 | 121 | Call AWS with substituted CloudFormation values. The first positional argument 122 | is used as the stack or config file name, all other arguments are forwarded to 123 | the AWS CLI. --region and --profile are used to determine which stack to load 124 | the resources from and are passed on as well.\nExample:\n awsie example-stack 125 | s3 ls s3://cf:DeploymentBucket: 126 | 127 | positional arguments: 128 | stack Stack to load resources from 129 | 130 | optional arguments: 131 | -h, --help show this help message and exit 132 | --version show program's version number and exit 133 | --region REGION The AWS Region to use 134 | --profile PROFILE The AWS Profile to use 135 | --command If you run a non AWS CLI command 136 | --no-stack If you only use CFN Exports and no Stack data 137 | --verbose Print debug output before running the command 138 | --debug Print debug output and don't run the command 139 | ``` -------------------------------------------------------------------------------- /scripts/start_docker: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | docker-compose build awsie 6 | docker-compose run awsie bash 7 | -------------------------------------------------------------------------------- /scripts/upload_to_pypi: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | rm -fr dist 6 | 7 | rm -f README.rst 8 | pandoc --from=markdown --to=rst --output=README.rst README.md 9 | 10 | rm -fr dist 11 | python setup.py sdist bdist_wheel 12 | twine upload dist/* "$@" 13 | rm -fr dist -------------------------------------------------------------------------------- /scripts/whalebrew: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | docker build -t flomotlik/awsie -f Dockerfile.whalebrew . 6 | 7 | whalebrew install -f flomotlik/awsie -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | # setup.cfg 2 | [bdist_wheel] 3 | universal = 1 4 | 5 | [pycodestyle] 6 | max-line-length = 120 7 | ignore = E402 -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """Packaging settings.""" 2 | 3 | from os.path import abspath, dirname, join, isfile 4 | 5 | from setuptools import setup 6 | 7 | from awsie import __version__ 8 | 9 | this_dir = abspath(dirname(__file__)) 10 | path = join(this_dir, 'README.rst') 11 | long_description = '' 12 | if isfile(path): 13 | with open(path) as file: 14 | long_description = file.read() 15 | 16 | setup( 17 | name='awsie', 18 | version=__version__, 19 | description='CloudFormation aware aws cli wrapper.', 20 | long_description=long_description, 21 | url='https://github.com/flomotlik/awsie', 22 | author='Florian Motlik', 23 | author_email='flo@flomotlik.me', 24 | license='MIT', 25 | classifiers=[ 26 | 'Intended Audience :: Developers', 27 | 'Topic :: Utilities', 28 | 'License :: Public Domain', 29 | 'Natural Language :: English', 30 | 'Operating System :: OS Independent', 31 | 'Programming Language :: Python :: 2', 32 | 'Programming Language :: Python :: 2.6', 33 | 'Programming Language :: Python :: 2.7', 34 | 'Programming Language :: Python :: 3', 35 | 'Programming Language :: Python :: 3.2', 36 | 'Programming Language :: Python :: 3.3', 37 | 'Programming Language :: Python :: 3.4', 38 | ], 39 | keywords='aws, cloud, awscli', 40 | packages=['awsie'], 41 | install_requires=['boto3'], 42 | entry_points={ 43 | 'console_scripts': [ 44 | 'awsie=awsie.cli:main', 45 | ], 46 | } 47 | ) 48 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/theserverlessway/awsie/bbcbd153404ab06fb83c0eea646bb16f13a8e622/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_cli.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import boto3 4 | import pytest 5 | from awsie import cli 6 | from path import Path 7 | 8 | 9 | @pytest.fixture() 10 | def region(): 11 | return 'us-west-1' 12 | 13 | 14 | @pytest.fixture() 15 | def profile(): 16 | return 'testprofile' 17 | 18 | 19 | @pytest.fixture() 20 | def stack(): 21 | return 'teststack' 22 | 23 | 24 | @pytest.fixture() 25 | def sysexit(mocker): 26 | return mocker.patch.object(sys, 'exit') 27 | 28 | 29 | @pytest.fixture() 30 | def arguments(mocker): 31 | arguments = [] 32 | mocker.patch.object(sys, 'argv', arguments) 33 | return arguments 34 | 35 | 36 | describe_stack = { 37 | 'Stacks': [ 38 | {'Outputs': [ 39 | {'OutputKey': 'Output_Key_1', 40 | 'OutputValue': 'Output_Value_1'}, 41 | {'OutputKey': 'Output_Key_2', 42 | 'OutputValue': 'Output_Value_2'} 43 | ], 44 | } 45 | ] 46 | } 47 | 48 | 49 | @pytest.fixture 50 | def botocore_session(mocker): 51 | return mocker.patch('botocore.session.Session') 52 | 53 | 54 | @pytest.fixture 55 | def session(mocker, botocore_session): 56 | return mocker.patch('awsie.cli.Session') 57 | 58 | 59 | @pytest.fixture 60 | def client(session, mocker): 61 | client_mock = mocker.Mock() 62 | session.return_value.client.return_value = client_mock 63 | return client_mock 64 | 65 | 66 | def test_profile_argument_parsing(stack, region): 67 | arguments = cli.parse_arguments([stack, '--region', region])[0] 68 | assert arguments.region == region 69 | 70 | 71 | def test_region_argument_parsing(stack, profile): 72 | arguments = cli.parse_arguments([stack, '--profile', profile])[0] 73 | assert arguments.profile == profile 74 | 75 | 76 | def test_stack_argument_parsing(stack): 77 | arguments = cli.parse_arguments(['--profile', 'something', stack, 'something', 'else'])[0] 78 | assert arguments.stack == stack 79 | 80 | 81 | def test_fails_without_stack(): 82 | with pytest.raises(SystemExit): 83 | cli.parse_arguments([]) 84 | 85 | 86 | def test_session_creation(region, profile, session, botocore_session): 87 | new_session = cli.create_session(region, profile) 88 | botocore_session.assert_called_with(profile=profile) 89 | session.assert_called_with(botocore_session=botocore_session(), region_name=region) 90 | assert new_session == session() 91 | 92 | 93 | def test_loads_resources_and_outputs_and_exports(mocker, stack): 94 | session = mocker.Mock(spec=boto3.Session) 95 | client = session.client.return_value 96 | 97 | resources_mocks = mocker.Mock() 98 | resources_mocks.paginate.return_value = [{'StackResourceSummaries': [{ 99 | 'LogicalResourceId': 'LogicalResourceId' + str(i), 100 | 'PhysicalResourceId': 'PhysicalResourceId' + str(i) 101 | }]} for i in range(2)] 102 | 103 | exports_mocks = mocker.Mock() 104 | exports_mocks.paginate.return_value = [{'Exports': [{ 105 | 'Name': 'ExportName' + str(i), 106 | 'Value': 'ExportValue' + str(i) 107 | }]} for i in range(2)] 108 | 109 | client.get_paginator.side_effect = [resources_mocks, exports_mocks] 110 | print([resources_mocks, exports_mocks]) 111 | 112 | client.describe_stacks.return_value = describe_stack 113 | 114 | ids = cli.get_resource_ids(session=session, stack=stack) 115 | assert len(ids) == 6 116 | assert ids['LogicalResourceId0'] == 'PhysicalResourceId0' 117 | assert ids['LogicalResourceId1'] == 'PhysicalResourceId1' 118 | assert ids['Output_Key_1'] == 'Output_Value_1' 119 | assert ids['Output_Key_2'] == 'Output_Value_2' 120 | assert ids['ExportName0'] == 'ExportValue0' 121 | assert ids['ExportName1'] == 'ExportValue1' 122 | 123 | session.client.assert_called_with('cloudformation') 124 | client.get_paginator.assert_has_calls([mocker.call('list_stack_resources'), mocker.call('list_exports')]) 125 | resources_mocks.paginate.assert_called_with(StackName=stack) 126 | exports_mocks.paginate.assert_called_with() 127 | client.describe_stacks.assert_called_with(StackName=stack) 128 | 129 | 130 | def test_loads_resources_and_ignores_empty_outputs(mocker, stack): 131 | session = mocker.Mock(spec=boto3.Session) 132 | client = session.client.return_value 133 | 134 | resources_summaries = [] 135 | for i in range(2): 136 | resources = {'StackResourceSummaries': [{ 137 | 'LogicalResourceId': 'LogicalResourceId' + str(i), 138 | 'PhysicalResourceId': 'PhysicalResourceId' + str(i) 139 | 140 | }]} 141 | resources_summaries.append(resources) 142 | 143 | client.get_paginator.return_value.paginate.return_value = resources_summaries 144 | 145 | describe_stack = { 146 | 'Stacks': [{}] 147 | } 148 | 149 | client.describe_stacks.return_value = describe_stack 150 | 151 | ids = cli.get_resource_ids(session=session, stack=stack) 152 | assert len(ids) == 2 153 | assert ids['LogicalResourceId0'] == 'PhysicalResourceId0' 154 | assert ids['LogicalResourceId1'] == 'PhysicalResourceId1' 155 | 156 | client.describe_stacks.assert_called_with(StackName=stack) 157 | 158 | 159 | def test_main_replaces_and_calls_aws(mocker, stack, sysexit, arguments): 160 | arguments.extend(['awsie', stack, 'testcf:DeploymentBucket:', 'test2', 'test3']) 161 | get_resource_ids = mocker.patch.object(cli, 'get_resource_ids') 162 | mocker.patch.object(cli, 'create_session') 163 | subprocess = mocker.patch.object(cli, 'subprocess') 164 | 165 | get_resource_ids.return_value = {'DeploymentBucket': '1'} 166 | 167 | cli.main() 168 | 169 | subprocess.call.assert_called_with(['aws', 'test1', 'test2', 'test3']) 170 | sysexit.assert_called_with(subprocess.call.return_value) 171 | 172 | 173 | def test_main_replaces_and_calls_aws_with_profile_and_region(mocker, stack, sysexit, arguments): 174 | arguments.extend(['awsie', stack, 'testcf:DeploymentBucket:', '--profile', 'profile', '--region', 'region']) 175 | get_resource_ids = mocker.patch.object(cli, 'get_resource_ids') 176 | mocker.patch.object(cli, 'create_session') 177 | subprocess = mocker.patch.object(cli, 'subprocess') 178 | 179 | get_resource_ids.return_value = {'DeploymentBucket': '1'} 180 | 181 | cli.main() 182 | 183 | subprocess.call.assert_called_with(['aws', 'test1', '--region', 'region', '--profile', 'profile']) 184 | sysexit.assert_called_with(subprocess.call.return_value) 185 | 186 | 187 | def test_captures_colon_non_greedy(mocker, stack, sysexit, arguments): 188 | arguments.extend(['awsie', stack, 'testcf:A:B:C::12345_12345:']) 189 | get_resource_ids = mocker.patch.object(cli, 'get_resource_ids') 190 | mocker.patch.object(cli, 'create_session') 191 | subprocess = mocker.patch.object(cli, 'subprocess') 192 | 193 | get_resource_ids.return_value = {'A:B:C': 'Replace'} 194 | 195 | cli.main() 196 | 197 | subprocess.call.assert_called_with(['aws', 'testReplace:12345_12345:']) 198 | sysexit.assert_called_with(subprocess.call.return_value) 199 | 200 | 201 | def test_captures_special_characters(mocker, stack, sysexit, arguments): 202 | arguments.extend(['awsie', stack, 'testcf:A-B:C-D:F::12345']) 203 | get_resource_ids = mocker.patch.object(cli, 'get_resource_ids') 204 | mocker.patch.object(cli, 'create_session') 205 | subprocess = mocker.patch.object(cli, 'subprocess') 206 | 207 | get_resource_ids.return_value = {'A-B:C-D:F': 'Replace'} 208 | 209 | cli.main() 210 | 211 | subprocess.call.assert_called_with(['aws', 'testReplace:12345']) 212 | sysexit.assert_called_with(subprocess.call.return_value) 213 | 214 | 215 | def test_main_fails_for_missing_replacement(mocker, stack): 216 | arguments = ['awsie', stack, 'testcf:DeploymentBucket:'] 217 | mocker.patch.object(sys, 'argv', arguments) 218 | get_resource_ids = mocker.patch.object(cli, 'get_resource_ids') 219 | mocker.patch.object(cli, 'create_session') 220 | 221 | get_resource_ids.return_value = {} 222 | 223 | with pytest.raises(SystemExit): 224 | cli.main() 225 | 226 | 227 | def test_main_fails_for_missing_awscli(mocker, stack, arguments): 228 | arguments.extend(['awsie', stack]) 229 | mocker.patch.object(cli, 'create_session') 230 | subprocess = mocker.patch.object(cli, 'subprocess') 231 | subprocess.call.side_effect = OSError() 232 | 233 | with pytest.raises(SystemExit): 234 | cli.main() 235 | 236 | 237 | def test_main_replaces_and_calls_arbitrary_command(mocker, stack, sysexit, arguments): 238 | arguments.extend(['awsie', stack, '--command', 'testcommand', 'testcf:DeploymentBucket:', '--region', 'test']) 239 | 240 | get_resource_ids = mocker.patch.object(cli, 'get_resource_ids') 241 | mocker.patch.object(cli, 'create_session') 242 | subprocess = mocker.patch.object(cli, 'subprocess') 243 | 244 | get_resource_ids.return_value = {'DeploymentBucket': '1'} 245 | 246 | cli.main() 247 | 248 | subprocess.call.assert_called_with(['testcommand', 'test1']) 249 | sysexit.assert_called_with(subprocess.call.return_value) 250 | 251 | 252 | def test_no_subprocess_on_debug(mocker, stack, sysexit, arguments): 253 | arguments.extend(['awsie', stack, '--debug']) 254 | 255 | get_resource_ids = mocker.patch.object(cli, 'get_resource_ids') 256 | mocker.patch.object(cli, 'create_session') 257 | subprocess = mocker.patch.object(cli, 'subprocess') 258 | 259 | get_resource_ids.return_value = {'DeploymentBucket': '1'} 260 | 261 | cli.main() 262 | 263 | subprocess.call.assert_not_called() 264 | sysexit.assert_called_with(0) 265 | 266 | 267 | def test_called_with_verbose(mocker, stack, sysexit, arguments): 268 | arguments.extend(['awsie', stack, '--verbose']) 269 | mocker.patch.object(cli, 'create_session') 270 | subprocess = mocker.patch.object(cli, 'subprocess') 271 | 272 | cli.main() 273 | 274 | subprocess.call.assert_called_with(['aws']) 275 | 276 | 277 | def test_no_stack_argument(mocker, stack, sysexit, arguments): 278 | arguments.extend(['awsie', '--no-stack', 'ec2', 'something']) 279 | 280 | get_resource_ids = mocker.patch.object(cli, 'get_resource_ids') 281 | mocker.patch.object(cli, 'create_session') 282 | subprocess = mocker.patch.object(cli, 'subprocess') 283 | 284 | get_resource_ids.return_value = {} 285 | 286 | cli.main() 287 | 288 | subprocess.call.assert_called_with(['aws', 'ec2', 'something']) 289 | sysexit.assert_called_with(subprocess.call.return_value) 290 | 291 | 292 | def test_config_file_for_stack_loading(mocker, client, stack, arguments, tmpdir, sysexit): 293 | arguments.extend(['awsie', stack, '--command', 'testcommand']) 294 | 295 | subprocess = mocker.patch.object(cli, 'subprocess') 296 | 297 | client.get_paginator.return_value.paginate.return_value = [] 298 | client.describe_stacks.return_value = describe_stack 299 | 300 | with Path(tmpdir): 301 | with open('stack.config.yaml', 'w') as f: 302 | f.write('stack: {}'.format(stack)) 303 | 304 | cli.main() 305 | 306 | client.describe_stacks.assert_called_with(StackName=stack) 307 | 308 | subprocess.call.assert_called_with(['testcommand']) 309 | sysexit.assert_called_with(subprocess.call.return_value) 310 | --------------------------------------------------------------------------------