diff --git a/chalice/awsclient.py b/chalice/awsclient.py index b08d4b776..d0bca529b 100644 --- a/chalice/awsclient.py +++ b/chalice/awsclient.py @@ -23,6 +23,8 @@ import botocore.session # noqa from typing import Any, Optional, Dict, Callable, List # noqa +from chalice.constants import DEFAULT_STAGE_NAME + class TypedAWSClient(object): @@ -156,12 +158,12 @@ def update_api_from_swagger(self, rest_api_id, swagger_document): restApiId=rest_api_id, body=json.dumps(swagger_document, indent=2)) - def deploy_rest_api(self, rest_api_id, stage_name): + def deploy_rest_api(self, rest_api_id, api_gateway_stage): # type: (str, str) -> None client = self._client('apigateway') client.create_deployment( restApiId=rest_api_id, - stageName=stage_name, + stageName=api_gateway_stage, ) def add_permission_for_apigateway_if_needed(self, function_name, @@ -244,20 +246,8 @@ def get_function_policy(self, function_name): policy = client.get_policy(FunctionName=function_name) return json.loads(policy['Policy']) - def get_sdk_download_stream(self, rest_api_id, stage='dev', - sdk_type='javascript'): - # type: (str, str, str) -> file - """Generate an SDK for a given SDK. - - Returns a file like object that streams a zip contents for the - generated SDK. - - """ - response = self._client('apigateway').get_sdk( - restApiId=rest_api_id, stageName=stage, sdkType=sdk_type) - return response['body'] - - def download_sdk(self, rest_api_id, output_dir, stage='dev', + def download_sdk(self, rest_api_id, output_dir, + api_gateway_stage=DEFAULT_STAGE_NAME, sdk_type='javascript'): # type: (str, str, str, str) -> None """Download an SDK to a directory. @@ -269,7 +259,8 @@ def download_sdk(self, rest_api_id, output_dir, stage='dev', """ zip_stream = self.get_sdk_download_stream( - rest_api_id, stage=stage, sdk_type=sdk_type) + rest_api_id, api_gateway_stage=api_gateway_stage, + sdk_type=sdk_type) tmpdir = tempfile.mkdtemp() with open(os.path.join(tmpdir, 'sdk.zip'), 'wb') as f: f.write(zip_stream.read()) @@ -291,6 +282,21 @@ def download_sdk(self, rest_api_id, output_dir, stage='dev', "The downloaded SDK had an unexpected directory structure: %s" % (', '.join(dirnames))) + def get_sdk_download_stream(self, rest_api_id, + api_gateway_stage=DEFAULT_STAGE_NAME, + sdk_type='javascript'): + # type: (str, str, str) -> file + """Generate an SDK for a given SDK. + + Returns a file like object that streams a zip contents for the + generated SDK. + + """ + response = self._client('apigateway').get_sdk( + restApiId=rest_api_id, stageName=api_gateway_stage, + sdkType=sdk_type) + return response['body'] + def add_permission_for_apigateway(self, function_name, region_name, account_id, rest_api_id, random_id): # type: (str, str, str, str, str) -> None diff --git a/chalice/cli/__init__.py b/chalice/cli/__init__.py index c22e4e8c9..ea9477c2f 100644 --- a/chalice/cli/__init__.py +++ b/chalice/cli/__init__.py @@ -3,7 +3,6 @@ Contains commands for deploying chalice. """ -import importlib import json import logging import os @@ -13,64 +12,51 @@ import botocore.exceptions import click -from typing import Dict, Any # noqa +from botocore.session import Session # noqa +from typing import Dict, Any, Optional # noqa from chalice import __version__ as chalice_version from chalice import prompts from chalice.app import Chalice # noqa from chalice.awsclient import TypedAWSClient -from chalice.cli.utils import create_botocore_session -from chalice.config import Config -from chalice.deploy import deployer +from chalice.cli.factory import CLIFactory +from chalice.config import Config # noqa from chalice.logs import LogRetriever -from chalice.package import create_app_packager from chalice.utils import create_zip_file, record_deployed_values +from chalice.constants import CONFIG_VERSION, TEMPLATE_APP, GITIGNORE +from chalice.constants import DEFAULT_STAGE_NAME -TEMPLATE_APP = """\ -from chalice import Chalice - -app = Chalice(app_name='%s') - - -@app.route('/') -def index(): - return {'hello': 'world'} - - -# The view function above will return {"hello": "world"} -# whenever you make an HTTP GET request to '/'. -# -# Here are a few more examples: -# -# @app.route('/hello/{name}') -# def hello_name(name): -# # '/hello/james' -> {"hello": "james"} -# return {'hello': name} -# -# @app.route('/users', methods=['POST']) -# def create_user(): -# # This is the JSON body the user sent in their POST request. -# user_as_json = app.json_body -# # Suppose we had some 'db' object that we used to -# # read/write from our database. -# # user_id = db.create_user(user_as_json) -# return {'user_id': user_id} -# -# See the README documentation for more examples. -# -""" -GITIGNORE = """\ -.chalice/deployments/ -.chalice/venv/ -""" +def create_new_project_skeleton(project_name, profile=None): + # type: (str, Optional[str]) -> None + chalice_dir = os.path.join(project_name, '.chalice') + os.makedirs(chalice_dir) + config = os.path.join(project_name, '.chalice', 'config.json') + cfg = { + 'version': CONFIG_VERSION, + 'app_name': project_name, + 'stages': { + DEFAULT_STAGE_NAME: { + 'api_gateway_stage': DEFAULT_STAGE_NAME, + } + } + } + if profile is not None: + cfg['profile'] = profile + with open(config, 'w') as f: + f.write(json.dumps(cfg, indent=2)) + with open(os.path.join(project_name, 'requirements.txt'), 'w'): + pass + with open(os.path.join(project_name, 'app.py'), 'w') as f: + f.write(TEMPLATE_APP % project_name) + with open(os.path.join(project_name, '.gitignore'), 'w') as f: + f.write(GITIGNORE) -def show_lambda_logs(config, max_entries, include_lambda_messages): - # type: (Config, int, bool) -> None - lambda_arn = config.lambda_arn - profile = config.profile - client = create_botocore_session(profile).create_client('logs') +def show_lambda_logs(session, lambda_arn, max_entries, + include_lambda_messages): + # type: (Session, str, int, bool) -> None + client = session.create_client('logs') retriever = LogRetriever.create_from_arn(client, lambda_arn) events = retriever.retrieve_logs( include_lambda_messages=include_lambda_messages, @@ -79,57 +65,6 @@ def show_lambda_logs(config, max_entries, include_lambda_messages): print event['timestamp'], event['logShortId'], event['message'].strip() -def load_project_config(project_dir): - # type: (str) -> Dict[str, Any] - """Load the chalice config file from the project directory. - - :raise: OSError/IOError if unable to load the config file. - - """ - config_file = os.path.join(project_dir, '.chalice', 'config.json') - with open(config_file) as f: - return json.loads(f.read()) - - -def load_chalice_app(project_dir): - # type: (str) -> Chalice - if project_dir not in sys.path: - sys.path.append(project_dir) - try: - app = importlib.import_module('app') - chalice_app = getattr(app, 'app') - except Exception as e: - exception = click.ClickException( - "Unable to import your app.py file: %s" % e - ) - exception.exit_code = 2 - raise exception - return chalice_app - - -def create_config_obj(ctx, stage_name=None, autogen_policy=None, profile=None): - # type: (click.Context, str, bool, str) -> Config - user_provided_params = {} # type: Dict[str, Any] - project_dir = ctx.obj['project_dir'] - default_params = {'project_dir': project_dir} - try: - config_from_disk = load_project_config(project_dir) - except (OSError, IOError): - click.echo("Unable to load the project config file. " - "Are you sure this is a chalice project?") - raise click.Abort() - app_obj = load_chalice_app(project_dir) - user_provided_params['chalice_app'] = app_obj - if stage_name is not None: - user_provided_params['stage'] = stage_name - if autogen_policy is not None: - user_provided_params['autogen_policy'] = autogen_policy - if profile is not None: - user_provided_params['profile'] = profile - config = Config(user_provided_params, config_from_disk, default_params) - return config - - @click.group() @click.version_option(version=chalice_version, message='%(prog)s %(version)s') @click.option('--project-dir', @@ -144,6 +79,7 @@ def cli(ctx, project_dir, debug=False): project_dir = os.getcwd() ctx.obj['project_dir'] = project_dir ctx.obj['debug'] = debug + ctx.obj['factory'] = CLIFactory(project_dir, debug) os.chdir(project_dir) @@ -152,7 +88,8 @@ def cli(ctx, project_dir, debug=False): @click.pass_context def local(ctx, port=8000): # type: (click.Context, int) -> None - app_obj = load_chalice_app(ctx.obj['project_dir']) + factory = ctx.obj['factory'] # type: CLIFactory + app_obj = factory.load_chalice_app() # When running `chalice local`, a stdout logger is configured # so you'll see the same stdout logging as you would when # running in lambda. This is configuring the root logger. @@ -167,32 +104,68 @@ def local(ctx, port=8000): default=True, help='Automatically generate IAM policy for app code.') @click.option('--profile', help='Override profile at deploy time.') -@click.argument('stage', nargs=1, required=False) +@click.option('--api-gateway-stage', + help='Name of the API gateway stage to deploy to.') +@click.option('--stage', default=DEFAULT_STAGE_NAME, + help=('Name of the Chalice stage to deploy to. ' + 'Specifying a new chalice stage will create ' + 'an entirely new set of AWS resources.')) +@click.argument('deprecated-api-gateway-stage', nargs=1, required=False) @click.pass_context -def deploy(ctx, autogen_policy, profile, stage): - # type: (click.Context, bool, str, str) -> None - config = create_config_obj( - # Note: stage_name is not the same thing as the chalice stage. - # This is a legacy artifact that just means "API gateway stage", - # or for our purposes, the URL prefix. - ctx, stage_name='dev', autogen_policy=autogen_policy, - profile=profile) - if stage is None: - stage = 'dev' - session = create_botocore_session(profile=config.profile, - debug=ctx.obj['debug']) - d = deployer.create_default_deployer(session=session, prompter=click) - try: - deployed_values = d.deploy(config, stage_name=stage) - record_deployed_values(deployed_values, os.path.join( - config.project_dir, '.chalice', 'deployed.json')) - except botocore.exceptions.NoRegionError: - e = click.ClickException("No region configured. " - "Either export the AWS_DEFAULT_REGION " - "environment variable or set the " - "region value in our ~/.aws/config file.") - e.exit_code = 2 - raise e +def deploy(ctx, autogen_policy, profile, api_gateway_stage, stage, + deprecated_api_gateway_stage): + # type: (click.Context, bool, str, str, str, str) -> None + if api_gateway_stage is not None and \ + deprecated_api_gateway_stage is not None: + raise _create_deprecated_stage_error(api_gateway_stage, + deprecated_api_gateway_stage) + if deprecated_api_gateway_stage is not None: + # The "chalice deploy " is deprecated and will be removed + # in future versions. We'll support it for now, but let the + # user know to stop using this. + _warn_pending_removal(deprecated_api_gateway_stage) + api_gateway_stage = deprecated_api_gateway_stage + factory = ctx.obj['factory'] # type: CLIFactory + factory.profile = profile + config = factory.create_config_obj( + chalice_stage_name=stage, autogen_policy=autogen_policy) + session = factory.create_botocore_session() + d = factory.create_default_deployer(session=session, prompter=click) + deployed_values = d.deploy(config, chalice_stage_name=stage) + record_deployed_values(deployed_values, os.path.join( + config.project_dir, '.chalice', 'deployed.json')) + + +def _create_deprecated_stage_error(option, positional_arg): + # type: (str, str) -> click.ClickException + message = ( + "You've specified both an '--api-gateway-stage' value of " + "'%s' as well as the positional API Gateway stage argument " + "'chalice deploy \"%s\"'.\n\n" + "The positional argument for API gateway stage ('chalice deploy " + "') is deprecated and support will be " + "removed in a future version of chalice.\nIf you want to " + "specify an API Gateway stage, just specify the " + "--api-gateway-stage option and remove the positional " + "stage argument.\n" + "If you want a completely separate set of AWS resources, " + "consider using the '--stage' argument." + ) % (option, positional_arg) + exception = click.ClickException(message) + exception.exit_code = 2 + return exception + + +def _warn_pending_removal(deprecated_stage): + # type: (str) -> None + click.echo("You've specified a deploy command of the form " + "'chalice deploy '\n" + "This form is deprecated and will be removed in a " + "future version of chalice.\n" + "You can use the --api-gateway-stage to achieve the " + "same functionality, or the newer '--stage' argument " + "if you want an entirely set of separate resources.", + err=True) @cli.command() @@ -201,11 +174,17 @@ def deploy(ctx, autogen_policy, profile, stage): @click.option('--include-lambda-messages/--no-include-lambda-messages', default=False, help='Controls whether or not lambda log messages are included.') +@click.option('--stage', default=DEFAULT_STAGE_NAME) @click.pass_context -def logs(ctx, num_entries, include_lambda_messages): - # type: (click.Context, int, bool) -> None - config = create_config_obj(ctx) - show_lambda_logs(config, num_entries, include_lambda_messages) +def logs(ctx, num_entries, include_lambda_messages, stage): + # type: (click.Context, int, bool, str) -> None + factory = ctx.obj['factory'] # type: CLIFactory + config = factory.create_config_obj(stage, False) + deployed = config.deployed_resources(stage) + if deployed is not None: + session = factory.create_botocore_session() + show_lambda_logs(session, deployed.api_handler_arn, num_entries, + include_lambda_messages) @cli.command('gen-policy') @@ -218,7 +197,7 @@ def gen_policy(ctx, filename): if filename is None: filename = os.path.join(ctx.obj['project_dir'], 'app.py') if not os.path.isfile(filename): - click.echo("App file does not exist: %s" % filename) + click.echo("App file does not exist: %s" % filename, err=True) raise click.Abort() with open(filename) as f: contents = f.read() @@ -234,63 +213,57 @@ def new_project(project_name, profile): if project_name is None: project_name = prompts.getting_started_prompt(click) if os.path.isdir(project_name): - click.echo("Directory already exists: %s" % project_name) + click.echo("Directory already exists: %s" % project_name, err=True) raise click.Abort() - chalice_dir = os.path.join(project_name, '.chalice') - os.makedirs(chalice_dir) - config = os.path.join(project_name, '.chalice', 'config.json') - cfg = { - 'app_name': project_name, - 'stage': 'dev' - } - if profile: - cfg['profile'] = profile - with open(config, 'w') as f: - f.write(json.dumps(cfg, indent=2)) - with open(os.path.join(project_name, 'requirements.txt'), 'w'): - pass - with open(os.path.join(project_name, 'app.py'), 'w') as f: - f.write(TEMPLATE_APP % project_name) - with open(os.path.join(project_name, '.gitignore'), 'w') as f: - f.write(GITIGNORE) + create_new_project_skeleton(project_name, profile) @cli.command('url') +@click.option('--stage', default=DEFAULT_STAGE_NAME) @click.pass_context -def url(ctx): - # type: (click.Context) -> None - config = create_config_obj(ctx) - session = create_botocore_session(profile=config.profile, - debug=ctx.obj['debug']) - c = TypedAWSClient(session) - rest_api_id = c.get_rest_api_id(config.app_name) - stage_name = config.stage - region_name = c.region_name - click.echo( - "https://{api_id}.execute-api.{region}.amazonaws.com/{stage}/" - .format(api_id=rest_api_id, region=region_name, stage=stage_name) - ) +def url(ctx, stage): + # type: (click.Context, str) -> None + factory = ctx.obj['factory'] # type: CLIFactory + config = factory.create_config_obj(stage) + deployed = config.deployed_resources(stage) + if deployed is not None: + click.echo( + "https://{api_id}.execute-api.{region}.amazonaws.com/{stage}/" + .format(api_id=deployed.rest_api_id, + region=deployed.region, + stage=deployed.api_gateway_stage) + ) + else: + e = click.ClickException( + "Could not find a record of deployed values to chalice stage: '%s'" + % stage) + e.exit_code = 2 + raise e @cli.command('generate-sdk') @click.option('--sdk-type', default='javascript', type=click.Choice(['javascript'])) +@click.option('--stage', default=DEFAULT_STAGE_NAME) @click.argument('outdir') @click.pass_context -def generate_sdk(ctx, sdk_type, outdir): - # type: (click.Context, str, str) -> None - config = create_config_obj(ctx) - session = create_botocore_session(profile=config.profile, - debug=ctx.obj['debug']) +def generate_sdk(ctx, sdk_type, stage, outdir): + # type: (click.Context, str, str, str) -> None + factory = ctx.obj['factory'] # type: CLIFactory + config = factory.create_config_obj(stage) + session = factory.create_botocore_session() client = TypedAWSClient(session) - rest_api_id = client.get_rest_api_id(config.app_name) - stage_name = config.stage - if rest_api_id is None: + deployed = config.deployed_resources(stage) + if deployed is None: click.echo("Could not find API ID, has this application " - "been deployed?") + "been deployed?", err=True) raise click.Abort() - client.download_sdk(rest_api_id, outdir, stage=stage_name, - sdk_type=sdk_type) + else: + rest_api_id = deployed.rest_api_id + api_gateway_stage = deployed.api_gateway_stage + client.download_sdk(rest_api_id, outdir, + api_gateway_stage=api_gateway_stage, + sdk_type=sdk_type) @cli.command('package') @@ -302,12 +275,14 @@ def generate_sdk(ctx, sdk_type, outdir): "package assets will be placed. If " "this argument is specified, a single " "zip file will be created instead.")) +@click.option('--stage', default=DEFAULT_STAGE_NAME) @click.argument('out') @click.pass_context -def package(ctx, single_file, out): - # type: (click.Context, bool, str) -> None - config = create_config_obj(ctx) - packager = create_app_packager(config) +def package(ctx, single_file, stage, out): + # type: (click.Context, bool, str, str) -> None + factory = ctx.obj['factory'] # type: CLIFactory + config = factory.create_config_obj(stage) + packager = factory.create_app_packager(config) if single_file: dirname = tempfile.mkdtemp() try: @@ -332,4 +307,14 @@ def main(): # 'obj' via the context object, so we're ignoring # these error messages from pylint because we know it's ok. # pylint: disable=unexpected-keyword-arg,no-value-for-parameter - return cli(obj={}) + try: + return cli(obj={}) + except botocore.exceptions.NoRegionError: + click.echo("No region configured. " + "Either export the AWS_DEFAULT_REGION " + "environment variable or set the " + "region value in our ~/.aws/config file.", err=True) + return 2 + except Exception as e: + click.echo(str(e), err=True) + return 2 diff --git a/chalice/cli/factory.py b/chalice/cli/factory.py new file mode 100644 index 000000000..f5ea64448 --- /dev/null +++ b/chalice/cli/factory.py @@ -0,0 +1,148 @@ +import sys +import os +import json +import importlib +import logging + +from botocore import session +from typing import Any, Optional, Dict # noqa + +from chalice import __version__ as chalice_version +from chalice.app import Chalice # noqa +from chalice.config import Config +from chalice.deploy import deployer +from chalice.package import create_app_packager +from chalice.package import AppPackager # noqa +from chalice.constants import DEFAULT_STAGE_NAME + + +def create_botocore_session(profile=None, debug=False): + # type: (str, bool) -> session.Session + s = session.Session(profile=profile) + _add_chalice_user_agent(s) + if debug: + s.set_debug_logger('') + _inject_large_request_body_filter() + return s + + +def _add_chalice_user_agent(session): + # type: (session.Session) -> None + suffix = '%s/%s' % (session.user_agent_name, session.user_agent_version) + session.user_agent_name = 'aws-chalice' + session.user_agent_version = chalice_version + session.user_agent_extra = suffix + + +def _inject_large_request_body_filter(): + # type: () -> None + log = logging.getLogger('botocore.endpoint') + log.addFilter(LargeRequestBodyFilter()) + + +class UnknownConfigFileVersion(Exception): + def __init__(self, version): + # type: (str) -> None + super(UnknownConfigFileVersion, self).__init__( + "Unknown version '%s' in config.json" % version) + + +class LargeRequestBodyFilter(logging.Filter): + def filter(self, record): + # type: (Any) -> bool + # Note: the proper type should be "logging.LogRecord", but + # the typechecker complains about 'Invalid index type "int" for "dict"' + # so we're using Any for now. + if record.msg.startswith('Making request'): + if record.args[0].name in ['UpdateFunctionCode', 'CreateFunction']: + # When using the ZipFile argument (which is used in chalice), + # the entire deployment package zip is sent as a base64 encoded + # string. We don't want this to clutter the debug logs + # so we don't log the request body for lambda operations + # that have the ZipFile arg. + record.args = (record.args[:-1] + + ('(... omitted from logs due to size ...)',)) + return True + + +class CLIFactory(object): + def __init__(self, project_dir, debug=False, profile=None): + # type: (str, bool, Optional[str]) -> None + self.project_dir = project_dir + self.debug = debug + self.profile = profile + + def create_botocore_session(self): + # type: () -> session.Session + return create_botocore_session(profile=self.profile, + debug=self.debug) + + def create_default_deployer(self, session, prompter): + # type: (session.Session, deployer.NoPrompt) -> deployer.Deployer + return deployer.create_default_deployer( + session=session, prompter=prompter) + + def create_config_obj(self, chalice_stage_name=DEFAULT_STAGE_NAME, + autogen_policy=True, api_gateway_stage=None): + # type: (str, bool, Optional[str]) -> Config + user_provided_params = {} # type: Dict[str, Any] + default_params = {'project_dir': self.project_dir} + try: + config_from_disk = self.load_project_config() + except (OSError, IOError): + raise RuntimeError("Unable to load the project config file. " + "Are you sure this is a chalice project?") + self._validate_config_from_disk(config_from_disk) + app_obj = self.load_chalice_app() + user_provided_params['chalice_app'] = app_obj + if autogen_policy is not None: + user_provided_params['autogen_policy'] = autogen_policy + if self.profile is not None: + user_provided_params['profile'] = self.profile + if api_gateway_stage is not None: + user_provided_params['api_gateway_stage'] = api_gateway_stage + config = Config(chalice_stage_name, user_provided_params, + config_from_disk, default_params) + return config + + def _validate_config_from_disk(self, config): + # type: (Dict[str, Any]) -> None + string_version = config.get('version', '1.0') + try: + version = float(string_version) + if version > 2.0: + raise UnknownConfigFileVersion(string_version) + except ValueError: + raise UnknownConfigFileVersion(string_version) + + def create_app_packager(self, config): + # type: (Config) -> AppPackager + return create_app_packager(config) + + def load_chalice_app(self): + # type: () -> Chalice + if self.project_dir not in sys.path: + sys.path.insert(0, self.project_dir) + try: + app = importlib.import_module('app') + chalice_app = getattr(app, 'app') + except SyntaxError as e: + message = ( + 'Unable to import your app.py file:\n\n' + 'File "%s", line %s\n' + ' %s\n' + 'SyntaxError: %s' + ) % (getattr(e, 'filename'), e.lineno, e.text, e.msg) + raise RuntimeError(message) + return chalice_app + + def load_project_config(self): + # type: () -> Dict[str, Any] + """Load the chalice config file from the project directory. + + :raise: OSError/IOError if unable to load the config file. + + """ + config_file = os.path.join(self.project_dir, '.chalice', 'config.json') + with open(config_file) as f: + return json.loads(f.read()) diff --git a/chalice/cli/utils.py b/chalice/cli/utils.py deleted file mode 100644 index 7d8a0b4a3..000000000 --- a/chalice/cli/utils.py +++ /dev/null @@ -1,48 +0,0 @@ -import logging - -import botocore.session -from typing import Any # noqa - -from chalice import __version__ as chalice_version - - -def create_botocore_session(profile=None, debug=False): - # type: (str, bool) -> botocore.session.Session - session = botocore.session.Session(profile=profile) - _add_chalice_user_agent(session) - if debug: - session.set_debug_logger('') - _inject_large_request_body_filter() - return session - - -def _add_chalice_user_agent(session): - # type: (botocore.session.Session) -> None - suffix = '%s/%s' % (session.user_agent_name, session.user_agent_version) - session.user_agent_name = 'aws-chalice' - session.user_agent_version = chalice_version - session.user_agent_extra = suffix - - -def _inject_large_request_body_filter(): - # type: () -> None - log = logging.getLogger('botocore.endpoint') - log.addFilter(LargeRequestBodyFilter()) - - -class LargeRequestBodyFilter(logging.Filter): - def filter(self, record): - # type: (Any) -> bool - # Note: the proper type should be "logging.LogRecord", but - # the typechecker complains about 'Invalid index type "int" for "dict"' - # so we're using Any for now. - if record.msg.startswith('Making request'): - if record.args[0].name in ['UpdateFunctionCode', 'CreateFunction']: - # When using the ZipFile argument (which is used in chalice), - # the entire deployment package zip is sent as a base64 encoded - # string. We don't want this to clutter the debug logs - # so we don't log the request body for lambda operations - # that have the ZipFile arg. - record.args = (record.args[:-1] + - ('(... omitted from logs due to size ...)',)) - return True diff --git a/chalice/config.py b/chalice/config.py index a7394ad2b..63ed98d02 100644 --- a/chalice/config.py +++ b/chalice/config.py @@ -3,19 +3,81 @@ from typing import Dict, Any, Optional # noqa from chalice.app import Chalice # noqa +from chalice.constants import DEFAULT_STAGE_NAME + StrMap = Dict[str, Any] class Config(object): - """Configuration information for a chalice app.""" + """Configuration information for a chalice app. + + Configuration values for a chalice app can come from + a number of locations, files on disk, CLI params, default + values, etc. This object is an abstraction that normalizes + these values. + + In general, there's a precedence for looking up + config values: + + * User specified params + * Config file values + * Default values + + A user specified parameter would mean values explicitly + specified by a user. Generally these come from command + line parameters (e.g ``--profile prod``), but for the purposes + of this object would also mean values passed explicitly to + this config object when instantiated. + + Additionally, there are some configurations that can vary + per chalice stage (note that a chalice stage is different + from an api gateway stage). For config values loaded from + disk, we allow values to be specified for all stages or + for a specific stage. For example, take ``environment_variables``. + You can set this as a top level key to specify env vars + to set for all stages, or you can set this value per chalice + stage to set stage-specific environment variables. Consider + this config file:: + + { + "environment_variables": { + "TABLE": "foo" + }, + "stages": { + "dev": { + "environment_variables": { + "S3BUCKET": "devbucket" + } + }, + "prod": { + "environment_variables": { + "S3BUCKET": "prodbucket", + "TABLE": "prodtable" + } + } + } + } + + If the currently configured chalice stage is "dev", then + the config.environment_variables would be:: + + {"TABLE": "foo", "S3BUCKET": "devbucket"} + + The "prod" stage would be:: + + {"TABLE": "prodtable", "S3BUCKET": "prodbucket"} + + """ def __init__(self, + chalice_stage=DEFAULT_STAGE_NAME, user_provided_params=None, config_from_disk=None, default_params=None): - # type: (StrMap, StrMap, StrMap) -> None + # type: (str, StrMap, StrMap, StrMap) -> None #: Params that a user provided explicitly, #: typically via the command line. + self.chalice_stage = chalice_stage if user_provided_params is None: user_provided_params = {} self._user_provided_params = user_provided_params @@ -28,9 +90,10 @@ def __init__(self, self._default_params = default_params @classmethod - def create(cls, **kwargs): - # type: (**Any) -> Config - return cls(user_provided_params=kwargs.copy()) + def create(cls, chalice_stage=DEFAULT_STAGE_NAME, **kwargs): + # type: (str, **Any) -> Config + return cls(chalice_stage=chalice_stage, + user_provided_params=kwargs.copy()) @property def profile(self): @@ -42,29 +105,6 @@ def app_name(self): # type: () -> str return self._chain_lookup('app_name') - @property - def stage(self): - # type: () -> str - return self._chain_lookup('stage') - - @property - def manage_iam_role(self): - # type: () -> bool - result = self._chain_lookup('manage_iam_role') - if result is None: - # To simplify downstream code, if manage_iam_role - # is None (indicating the user hasn't configured/specified this - # value anywhere), then we'll return a default value of True. - # Otherwise client code has to do an awkward - # "if manage_iam_role is None and not manage_iam_role". - return True - return result - - @property - def iam_role_arn(self): - # type: () -> str - return self._chain_lookup('iam_role_arn') - @property def project_dir(self): # type: () -> str @@ -75,24 +115,20 @@ def chalice_app(self): # type: () -> Chalice return self._chain_lookup('chalice_app') - @property - def autogen_policy(self): - # type: () -> bool - return self._chain_lookup('autogen_policy') - @property def config_from_disk(self): # type: () -> StrMap return self._config_from_disk - def _chain_lookup(self, name): - # type: (str) -> Any - all_dicts = [ - self._user_provided_params, - self._config_from_disk, - self._default_params - ] - for cfg_dict in all_dicts: + def _chain_lookup(self, name, varies_per_chalice_stage=False): + # type: (str, bool) -> Any + search_dicts = [self._user_provided_params] + if varies_per_chalice_stage: + search_dicts.append( + self._config_from_disk.get('stages', {}).get( + self.chalice_stage, {})) + search_dicts.extend([self._config_from_disk, self._default_params]) + for cfg_dict in search_dicts: if isinstance(cfg_dict, dict) and cfg_dict.get(name) is not None: return cfg_dict[name] @@ -101,7 +137,47 @@ def lambda_arn(self): # type: () -> str return self._chain_lookup('lambda_arn') - def deployed_resources(self, stage_name): + @property + def config_file_version(self): + # type: () -> str + return self._config_from_disk.get('version', '1.0') + + # These are all config values that can vary per + # chalice stage. + + @property + def api_gateway_stage(self): + # type: () -> str + return self._chain_lookup('api_gateway_stage', + varies_per_chalice_stage=True) + + @property + def iam_role_arn(self): + # type: () -> str + return self._chain_lookup('iam_role_arn', + varies_per_chalice_stage=True) + + @property + def manage_iam_role(self): + # type: () -> bool + result = self._chain_lookup('manage_iam_role', + varies_per_chalice_stage=True) + if result is None: + # To simplify downstream code, if manage_iam_role + # is None (indicating the user hasn't configured/specified this + # value anywhere), then we'll return a default value of True. + # Otherwise client code has to do an awkward + # "if manage_iam_role is None and not manage_iam_role". + return True + return result + + @property + def autogen_policy(self): + # type: () -> bool + return self._chain_lookup('autogen_policy', + varies_per_chalice_stage=True) + + def deployed_resources(self, chalice_stage_name): # type: (str) -> Optional[DeployedResources] """Return resources associated with a given stage. @@ -117,9 +193,9 @@ def deployed_resources(self, stage_name): return None with open(deployed_file, 'r') as f: data = json.load(f) - if stage_name not in data: + if chalice_stage_name not in data: return None - return DeployedResources.from_dict(data[stage_name]) + return DeployedResources.from_dict(data[chalice_stage_name]) class DeployedResources(object): diff --git a/chalice/constants.py b/chalice/constants.py new file mode 100644 index 000000000..8b0425f6c --- /dev/null +++ b/chalice/constants.py @@ -0,0 +1,72 @@ + +# This is the version that's written to the config file +# on a `chalice new-project`. It's also how chalice is able +# to know when to warn you when changing behavior is introduced. +CONFIG_VERSION = '2.0' + + +TEMPLATE_APP = """\ +from chalice import Chalice + +app = Chalice(app_name='%s') + + +@app.route('/') +def index(): + return {'hello': 'world'} + + +# The view function above will return {"hello": "world"} +# whenever you make an HTTP GET request to '/'. +# +# Here are a few more examples: +# +# @app.route('/hello/{name}') +# def hello_name(name): +# # '/hello/james' -> {"hello": "james"} +# return {'hello': name} +# +# @app.route('/users', methods=['POST']) +# def create_user(): +# # This is the JSON body the user sent in their POST request. +# user_as_json = app.json_body +# # Suppose we had some 'db' object that we used to +# # read/write from our database. +# # user_id = db.create_user(user_as_json) +# return {'user_id': user_id} +# +# See the README documentation for more examples. +# +""" + + +GITIGNORE = """\ +.chalice/deployments/ +.chalice/venv/ +""" + +DEFAULT_STAGE_NAME = 'dev' + + +LAMBDA_TRUST_POLICY = { + "Version": "2012-10-17", + "Statement": [{ + "Sid": "", + "Effect": "Allow", + "Principal": { + "Service": "lambda.amazonaws.com" + }, + "Action": "sts:AssumeRole" + }] +} + + +CLOUDWATCH_LOGS = { + "Effect": "Allow", + "Action": [ + "logs:CreateLogGroup", + "logs:CreateLogStream", + "logs:PutLogEvents" + ], + "Resource": "arn:aws:logs:*:*:*" +} diff --git a/chalice/deploy/deployer.py b/chalice/deploy/deployer.py index fe918c0c1..8de4ad795 100644 --- a/chalice/deploy/deployer.py +++ b/chalice/deploy/deployer.py @@ -18,29 +18,8 @@ from chalice.deploy.packager import LambdaDeploymentPackager from chalice.deploy.swagger import SwaggerGenerator from chalice.utils import OSUtils - -LAMBDA_TRUST_POLICY = { - "Version": "2012-10-17", - "Statement": [{ - "Sid": "", - "Effect": "Allow", - "Principal": { - "Service": "lambda.amazonaws.com" - }, - "Action": "sts:AssumeRole" - }] -} - - -CLOUDWATCH_LOGS = { - "Effect": "Allow", - "Action": [ - "logs:CreateLogGroup", - "logs:CreateLogStream", - "logs:PutLogEvents" - ], - "Resource": "arn:aws:logs:*:*:*" -} +from chalice.constants import DEFAULT_STAGE_NAME, LAMBDA_TRUST_POLICY +from chalice.constants import CLOUDWATCH_LOGS NULLARY = Callable[[], str] @@ -141,22 +120,22 @@ def __init__(self, apigateway_deploy, lambda_deploy): self._apigateway_deploy = apigateway_deploy self._lambda_deploy = lambda_deploy - def deploy(self, config, stage_name='dev'): + def deploy(self, config, chalice_stage_name=DEFAULT_STAGE_NAME): # type: (Config, str) -> Dict[str, Any] """Deploy chalice application to AWS. - :type config: Config - :param config: A dictionary of config values including: - - * project_dir - The directory containing the project - * config - A dictionary of config values loaded from the - project config file. + :param config: A chalice config object for the app + :param chalice_stage_name: The name of the chalice stage to deploy to. + If this chalice stage does not exist, a new stage will be created. + If the stage exists, a redeploy will occur. A chalice stage + is an entire collection of AWS resources including an API Gateway + rest api, lambda function, role, etc. """ validate_configuration(config) - existing_resources = config.deployed_resources(stage_name) + existing_resources = config.deployed_resources(chalice_stage_name) deployed_values = self._lambda_deploy.deploy( - config, existing_resources, stage_name) + config, existing_resources, chalice_stage_name) rest_api_id, region_name, apig_stage = self._apigateway_deploy.deploy( config, existing_resources) print ( @@ -171,7 +150,7 @@ def deploy(self, config, stage_name='dev'): 'chalice_version': chalice_version, }) return { - stage_name: deployed_values + chalice_stage_name: deployed_values } @@ -341,9 +320,9 @@ def _first_time_deploy(self, config): # for each rest API, but that would require injecting chalice stage # information into the swagger generator. rest_api_id = self._aws_client.import_rest_api(swagger_doc) - stage = config.stage or 'dev' - self._deploy_api_to_stage(rest_api_id, stage, config) - return rest_api_id, self._aws_client.region_name, stage + api_gateway_stage = config.api_gateway_stage or DEFAULT_STAGE_NAME + self._deploy_api_to_stage(rest_api_id, api_gateway_stage, config) + return rest_api_id, self._aws_client.region_name, api_gateway_stage def _create_resources_for_api(self, config, rest_api_id): # type: (Config, str) -> Tuple[str, str, str] @@ -351,14 +330,14 @@ def _create_resources_for_api(self, config, rest_api_id): config.lambda_arn) swagger_doc = generator.generate_swagger(config.chalice_app) self._aws_client.update_api_from_swagger(rest_api_id, swagger_doc) - stage = config.stage or 'dev' - self._deploy_api_to_stage(rest_api_id, stage, config) - return rest_api_id, self._aws_client.region_name, stage + api_gateway_stage = config.api_gateway_stage or DEFAULT_STAGE_NAME + self._deploy_api_to_stage(rest_api_id, api_gateway_stage, config) + return rest_api_id, self._aws_client.region_name, api_gateway_stage - def _deploy_api_to_stage(self, rest_api_id, stage, config): + def _deploy_api_to_stage(self, rest_api_id, api_gateway_stage, config): # type: (str, str, Config) -> None - print "Deploying to:", stage - self._aws_client.deploy_rest_api(rest_api_id, stage) + print "Deploying to:", api_gateway_stage + self._aws_client.deploy_rest_api(rest_api_id, api_gateway_stage) self._aws_client.add_permission_for_apigateway_if_needed( config.lambda_arn.split(':')[-1], self._aws_client.region_name, diff --git a/chalice/package.py b/chalice/package.py index e76dfa44f..3bab92941 100644 --- a/chalice/package.py +++ b/chalice/package.py @@ -82,12 +82,12 @@ def __init__(self, swagger_generator, policy_generator): self._policy_generator = policy_generator def generate_sam_template(self, app, code_uri='', - stage_name='dev'): + api_gateway_stage='dev'): # type: (Chalice, str, str) -> Dict[str, Any] template = copy.deepcopy(self._BASE_TEMPLATE) resources = { 'APIHandler': self._generate_serverless_function(app, code_uri), - 'RestAPI': self._generate_rest_api(app, stage_name), + 'RestAPI': self._generate_rest_api(app, api_gateway_stage), } template['Resources'] = resources return template @@ -125,11 +125,11 @@ def _generate_function_events(self, app): } return events - def _generate_rest_api(self, app, stage_name): + def _generate_rest_api(self, app, api_gateway_stage): # type: (Chalice, str) -> Dict[str, Any] swagger_definition = self._swagger_generator.generate_swagger(app) properties = { - 'StageName': stage_name, + 'StageName': api_gateway_stage, 'DefinitionBody': swagger_definition, } return { diff --git a/tests/conftest.py b/tests/conftest.py index e47f505f3..89cfa5c64 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,14 @@ import botocore.session from botocore.stub import Stubber +import pytest from pytest import fixture +def pytest_addoption(parser): + parser.addoption('--skip-slow', action='store_true', + help='Skip slow tests') + + class StubbedSession(botocore.session.Session): def __init__(self, *args, **kwargs): super(StubbedSession, self).__init__(*args, **kwargs) diff --git a/tests/functional/cli/test_cli.py b/tests/functional/cli/test_cli.py index e45264b53..bf1440e44 100644 --- a/tests/functional/cli/test_cli.py +++ b/tests/functional/cli/test_cli.py @@ -2,9 +2,36 @@ import zipfile import os +import pytest from click.testing import CliRunner +import mock from chalice import cli +from chalice.cli import factory +from chalice.deploy.deployer import Deployer +from chalice.config import Config +from chalice.utils import record_deployed_values + + +@pytest.fixture +def runner(): + return CliRunner() + + +@pytest.fixture +def mock_deployer(): + d = mock.Mock(spec=Deployer) + d.deploy.return_value = {} + return d + + +@pytest.fixture +def mock_cli_factory(mock_deployer): + cli_factory = mock.Mock(spec=factory.CLIFactory) + cli_factory.create_config_obj.return_value = Config.create(project_dir='.') + cli_factory.create_botocore_session.return_value = mock.sentinel.Session + cli_factory.create_default_deployer.return_value = mock_deployer + return cli_factory def assert_chalice_app_structure_created(dirname): @@ -15,18 +42,20 @@ def assert_chalice_app_structure_created(dirname): assert '.gitignore' in app_contents -def _run_cli_command(runner, function, args): +def _run_cli_command(runner, function, args, cli_factory=None): # Handles passing in 'obj' so we can get commands # that use @pass_context to work properly. # click doesn't support this natively so we have to duplicate # what 'def cli(...)' is doing. - result = runner.invoke(function, args, - obj={'project_dir': '.', 'debug': False}) + if cli_factory is None: + cli_factory = factory.CLIFactory('.') + result = runner.invoke( + function, args, obj={'project_dir': '.', 'debug': False, + 'factory': cli_factory}) return result -def test_create_new_project_creates_app(): - runner = CliRunner() +def test_create_new_project_creates_app(runner): with runner.isolated_filesystem(): result = runner.invoke(cli.new_project, ['testproject']) assert result.exit_code == 0 @@ -37,8 +66,7 @@ def test_create_new_project_creates_app(): assert_chalice_app_structure_created(dirname='testproject') -def test_create_project_with_prompted_app_name(): - runner = CliRunner() +def test_create_project_with_prompted_app_name(runner): with runner.isolated_filesystem(): result = runner.invoke(cli.new_project, input='testproject') assert result.exit_code == 0 @@ -46,8 +74,7 @@ def test_create_project_with_prompted_app_name(): assert_chalice_app_structure_created(dirname='testproject') -def test_error_raised_if_dir_already_exists(): - runner = CliRunner() +def test_error_raised_if_dir_already_exists(runner): with runner.isolated_filesystem(): os.mkdir('testproject') result = runner.invoke(cli.new_project, ['testproject']) @@ -55,28 +82,31 @@ def test_error_raised_if_dir_already_exists(): assert 'Directory already exists: testproject' in result.output -def test_can_load_project_config_after_project_creation(): - runner = CliRunner() +def test_can_load_project_config_after_project_creation(runner): with runner.isolated_filesystem(): result = runner.invoke(cli.new_project, ['testproject']) assert result.exit_code == 0 - config = cli.load_project_config('testproject') - assert config == {'app_name': 'testproject', 'stage': 'dev'} + config = factory.CLIFactory('testproject').load_project_config() + assert config == { + 'version': '2.0', + 'app_name': 'testproject', + 'stages': { + 'dev': {'api_gateway_stage': u'dev'} + } + } -def test_default_new_project_adds_index_route(): - runner = CliRunner() +def test_default_new_project_adds_index_route(runner): with runner.isolated_filesystem(): result = runner.invoke(cli.new_project, ['testproject']) assert result.exit_code == 0 - app = cli.load_chalice_app('testproject') + app = factory.CLIFactory('testproject').load_chalice_app() assert '/' in app.routes -def test_gen_policy_command_creates_policy(): - runner = CliRunner() +def test_gen_policy_command_creates_policy(runner): with runner.isolated_filesystem(): - runner.invoke(cli.new_project, ['testproject']) + cli.create_new_project_skeleton('testproject') os.chdir('testproject') result = runner.invoke(cli.cli, ['gen-policy'], obj={}) assert result.exit_code == 0 @@ -89,23 +119,21 @@ def test_gen_policy_command_creates_policy(): assert 'Statement' in parsed_policy -def test_can_package_command(): - runner = CliRunner() +def test_can_package_command(runner): with runner.isolated_filesystem(): - assert runner.invoke(cli.new_project, ['testproject']).exit_code == 0 + cli.create_new_project_skeleton('testproject') os.chdir('testproject') result = _run_cli_command(runner, cli.package, ['outdir']) - assert result.exit_code == 0 + assert result.exit_code == 0, result.output assert os.path.isdir('outdir') dir_contents = os.listdir('outdir') assert 'sam.json' in dir_contents assert 'deployment.zip' in dir_contents -def test_can_package_with_single_file(): - runner = CliRunner() +def test_can_package_with_single_file(runner): with runner.isolated_filesystem(): - assert runner.invoke(cli.new_project, ['testproject']).exit_code == 0 + cli.create_new_project_skeleton('testproject') os.chdir('testproject') result = _run_cli_command( runner, cli.package, ['--single-file', 'package.zip']) @@ -113,3 +141,114 @@ def test_can_package_with_single_file(): assert os.path.isfile('package.zip') with zipfile.ZipFile('package.zip', 'r') as f: assert f.namelist() == ['deployment.zip', 'sam.json'] + + +def test_can_deploy(runner, mock_cli_factory, mock_deployer): + deployed_values = { + 'dev': { + # We don't need to fill in everything here. + 'api_handler_arn': 'foo', + 'rest_api_id': 'bar', + } + } + mock_deployer.deploy.return_value = deployed_values + with runner.isolated_filesystem(): + cli.create_new_project_skeleton('testproject') + os.chdir('testproject') + result = _run_cli_command(runner, cli.deploy, [], + cli_factory=mock_cli_factory) + assert result.exit_code == 0 + # We should have also created the deployed JSON file. + deployed_file = os.path.join('.chalice', 'deployed.json') + assert os.path.isfile(deployed_file) + with open(deployed_file) as f: + data = json.load(f) + assert data == deployed_values + + +def test_warning_when_using_deprecated_arg(runner, mock_cli_factory): + with runner.isolated_filesystem(): + cli.create_new_project_skeleton('testproject') + os.chdir('testproject') + result = _run_cli_command(runner, cli.deploy, ['prod'], + cli_factory=mock_cli_factory) + assert result.exit_code == 0 + assert 'is deprecated and will be removed' in result.output + + +def test_can_specify_chalice_stage_arg(runner, mock_cli_factory, + mock_deployer): + with runner.isolated_filesystem(): + cli.create_new_project_skeleton('testproject') + os.chdir('testproject') + result = _run_cli_command(runner, cli.deploy, ['--stage', 'prod'], + cli_factory=mock_cli_factory) + assert result.exit_code == 0 + + config = mock_cli_factory.create_config_obj.return_value + mock_deployer.deploy.assert_called_with(config, chalice_stage_name='prod') + + +def test_api_gateway_mutex_with_positional_arg(runner, mock_cli_factory, + mock_deployer): + with runner.isolated_filesystem(): + cli.create_new_project_skeleton('testproject') + os.chdir('testproject') + result = _run_cli_command(runner, cli.deploy, + ['--api-gateway-stage', 'prod', 'prod'], + cli_factory=mock_cli_factory) + assert result.exit_code == 2 + assert 'is deprecated' in result.output + + assert not mock_deployer.deploy.called + + +def test_can_retrieve_url(runner, mock_cli_factory): + deployed_values = { + "dev": { + "rest_api_id": "rest_api_id", + "chalice_version": "0.7.0", + "region": "us-west-2", + "backend": "api", + "api_handler_name": "helloworld-dev", + "api_handler_arn": "arn:...", + "api_gateway_stage": "dev-apig" + }, + "prod": { + "rest_api_id": "rest_api_id_prod", + "chalice_version": "0.7.0", + "region": "us-west-2", + "backend": "api", + "api_handler_name": "helloworld-dev", + "api_handler_arn": "arn:...", + "api_gateway_stage": "prod-apig" + }, + } + with runner.isolated_filesystem(): + cli.create_new_project_skeleton('testproject') + os.chdir('testproject') + record_deployed_values(deployed_values, + os.path.join('.chalice', 'deployed.json')) + result = _run_cli_command(runner, cli.url, [], + cli_factory=mock_cli_factory) + assert result.exit_code == 0 + assert result.output == ( + 'https://rest_api_id.execute-api.us-west-2.amazonaws.com' + '/dev-apig/\n') + + prod_result = _run_cli_command(runner, cli.url, ['--stage', 'prod'], + cli_factory=mock_cli_factory) + assert prod_result.exit_code == 0 + assert prod_result.output == ( + 'https://rest_api_id_prod.execute-api.us-west-2.amazonaws.com' + '/prod-apig/\n') + + +def test_error_when_no_deployed_record(runner, mock_cli_factory): + with runner.isolated_filesystem(): + cli.create_new_project_skeleton('testproject') + os.chdir('testproject') + result = _run_cli_command(runner, cli.url, [], + cli_factory=mock_cli_factory) + assert result.exit_code == 2 + assert 'not find' in result.output diff --git a/tests/functional/cli/test_factory.py b/tests/functional/cli/test_factory.py new file mode 100644 index 000000000..c438e195c --- /dev/null +++ b/tests/functional/cli/test_factory.py @@ -0,0 +1,99 @@ +import os +import sys +import json +import logging + +import pytest +from pytest import fixture + +from chalice.cli import factory +from chalice.deploy.deployer import Deployer +from chalice.config import Config + + +@fixture +def clifactory(tmpdir): + appdir = tmpdir.mkdir('app') + appdir.join('app.py').write( + '# Test app\n' + 'import chalice\n' + 'app = chalice.Chalice(app_name="test")\n' + ) + chalice_dir = appdir.mkdir('.chalice') + chalice_dir.join('config.json').write('{}') + return factory.CLIFactory(str(appdir)) + + +def assert_has_no_request_body_filter(log_name): + log = logging.getLogger(log_name) + assert not any( + isinstance(f, factory.LargeRequestBodyFilter) for f in log.filters) + + +def assert_request_body_filter_in_log(log_name): + log = logging.getLogger(log_name) + assert any( + isinstance(f, factory.LargeRequestBodyFilter) for f in log.filters) + + +def test_can_create_botocore_session(): + session = factory.create_botocore_session() + assert session.user_agent().startswith('aws-chalice/') + + +def test_can_create_botocore_session_debug(): + log_name = 'botocore.endpoint' + assert_has_no_request_body_filter(log_name) + + factory.create_botocore_session(debug=True) + + assert_request_body_filter_in_log(log_name) + assert logging.getLogger('').level == logging.DEBUG + + +def test_can_create_botocore_session_cli_factory(clifactory): + clifactory.profile = 'myprofile' + session = clifactory.create_botocore_session() + assert session.profile == 'myprofile' + + +def test_can_create_default_deployer(clifactory): + session = clifactory.create_botocore_session() + deployer = clifactory.create_default_deployer(session, None) + assert isinstance(deployer, Deployer) + + +def test_can_create_config_obj(clifactory): + obj = clifactory.create_config_obj() + assert isinstance(obj, Config) + + +def test_cant_load_config_obj_with_bad_project(clifactory): + clifactory.project_dir = 'nowhere-asdfasdfasdfas' + with pytest.raises(RuntimeError): + clifactory.create_config_obj() + + +def test_error_raised_on_unknown_config_version(clifactory): + filename = os.path.join( + clifactory.project_dir, '.chalice', 'config.json') + with open(filename, 'w') as f: + f.write(json.dumps({"version": "100.0"})) + + with pytest.raises(factory.UnknownConfigFileVersion): + clifactory.create_config_obj() + + +def test_filename_and_lineno_included_in_syntax_error(clifactory): + filename = os.path.join(clifactory.project_dir, 'app.py') + with open(filename, 'w') as f: + f.write("this is a syntax error\n") + # If this app has been previously imported in another app + # we need to remove it from the cached modules to ensure + # we get the syntax error on import. + sys.modules.pop('app', None) + with pytest.raises(RuntimeError) as excinfo: + clifactory.load_chalice_app() + message = str(excinfo.value) + assert 'app.py' in message + assert 'line 1' in message diff --git a/tests/functional/cli/test_utils.py b/tests/functional/cli/test_utils.py deleted file mode 100644 index 82f537caa..000000000 --- a/tests/functional/cli/test_utils.py +++ /dev/null @@ -1,30 +0,0 @@ -import logging - -from chalice.cli import utils - - -def assert_has_no_request_body_filter(log_name): - log = logging.getLogger(log_name) - assert not any( - isinstance(f, utils.LargeRequestBodyFilter) for f in log.filters) - - -def assert_request_body_filter_in_log(log_name): - log = logging.getLogger(log_name) - assert any( - isinstance(f, utils.LargeRequestBodyFilter) for f in log.filters) - - -def test_can_create_botocore_session(): - session = utils.create_botocore_session() - assert session.user_agent().startswith('aws-chalice/') - - -def test_can_create_botocore_session_debug(): - log_name = 'botocore.endpoint' - assert_has_no_request_body_filter(log_name) - - utils.create_botocore_session(debug=True) - - assert_request_body_filter_in_log(log_name) - assert logging.getLogger('').level == logging.DEBUG diff --git a/tests/functional/test_deployer.py b/tests/functional/test_deployer.py index 64cd9ffbd..9c59d6e85 100644 --- a/tests/functional/test_deployer.py +++ b/tests/functional/test_deployer.py @@ -3,12 +3,18 @@ import botocore.session from pytest import fixture +import pytest import chalice.deploy.packager import chalice.utils from chalice.deploy import deployer +slow = pytest.mark.skipif( + pytest.config.getoption('--skip-slow'), + reason='Skipped due to --skip-slow') + + @fixture def chalice_deployer(): d = chalice.deploy.packager.LambdaDeploymentPackager() @@ -22,6 +28,7 @@ def _create_app_structure(tmpdir): return appdir +@slow def test_can_create_deployment_package(tmpdir, chalice_deployer): appdir = _create_app_structure(tmpdir) appdir.join('app.py').write('# Test app') @@ -33,6 +40,7 @@ def test_can_create_deployment_package(tmpdir, chalice_deployer): assert str(contents[0]).endswith('.zip') +@slow def test_can_inject_latest_app(tmpdir, chalice_deployer): appdir = _create_app_structure(tmpdir) appdir.join('app.py').write('# Test app v1') @@ -52,6 +60,7 @@ def test_can_inject_latest_app(tmpdir, chalice_deployer): assert contents == '# Test app NEW VERSION' +@slow def test_app_injection_still_compresses_file(tmpdir, chalice_deployer): appdir = _create_app_structure(tmpdir) appdir.join('app.py').write('# Test app v1') @@ -66,6 +75,7 @@ def test_app_injection_still_compresses_file(tmpdir, chalice_deployer): assert new_size < (original_size * 1.05) +@slow def test_no_error_message_printed_on_empty_reqs_file(tmpdir, chalice_deployer, capfd): @@ -99,6 +109,7 @@ def test_osutils_proxies_os_functions(tmpdir): assert not osutils.file_exists(app_file) +@slow def test_includes_app_and_chalicelib_dir(tmpdir, chalice_deployer): appdir = _create_app_structure(tmpdir) # We're now also going to create additional files @@ -127,6 +138,7 @@ def _assert_in_zip(path, contents, zip): assert zip.read(path) == contents +@slow def test_subsequent_deploy_replaces_chalicelib(tmpdir, chalice_deployer): appdir = _create_app_structure(tmpdir) chalicelib = appdir.mkdir('chalicelib') @@ -146,6 +158,7 @@ def test_subsequent_deploy_replaces_chalicelib(tmpdir, chalice_deployer): assert 'chalicelib/__init__.py' not in f.namelist() +@slow def test_vendor_dir_included(tmpdir, chalice_deployer): appdir = _create_app_structure(tmpdir) vendor = appdir.mkdir('vendor') @@ -156,6 +169,7 @@ def test_vendor_dir_included(tmpdir, chalice_deployer): _assert_in_zip('mypackage/__init__.py', '# Test package', f) +@slow def test_subsequent_deploy_replaces_vendor_dir(tmpdir, chalice_deployer): appdir = _create_app_structure(tmpdir) vendor = appdir.mkdir('vendor') @@ -180,6 +194,7 @@ def test_zip_filename_changes_on_vendor_update(tmpdir, chalice_deployer): assert first != second +@slow def test_chalice_runtime_injected_on_change(tmpdir, chalice_deployer): appdir = _create_app_structure(tmpdir) name = chalice_deployer.create_deployment_package(str(appdir)) diff --git a/tests/unit/deploy/test_deployer.py b/tests/unit/deploy/test_deployer.py index 05ca8ae2e..9df25e5b4 100644 --- a/tests/unit/deploy/test_deployer.py +++ b/tests/unit/deploy/test_deployer.py @@ -212,14 +212,21 @@ def test_can_deploy_apig_and_lambda(sample_app): apig_deploy.deploy.return_value = ('api_id', 'region', 'stage') d = Deployer(apig_deploy, lambda_deploy) - cfg = Config({'chalice_app': sample_app, 'project_dir': '.'}) + cfg = Config.create( + chalice_stage='dev', + chalice_app=sample_app, + project_dir='.') d.deploy(cfg) lambda_deploy.deploy.assert_called_with(cfg, None, 'dev') apig_deploy.deploy.assert_called_with(cfg, None) def test_deployer_returns_deployed_resources(sample_app): - cfg = Config({'chalice_app': sample_app, 'project_dir': '.'}) + cfg = Config.create( + chalice_stage='dev', + chalice_app=sample_app, + project_dir='.', + ) lambda_deploy = mock.Mock(spec=LambdaDeployer) apig_deploy = mock.Mock(spec=APIGatewayDeployer) @@ -263,9 +270,14 @@ def test_lambda_deployer_repeated_deploy(app_policy, sample_app): aws_client.update_function_code.return_value = {"FunctionArn": "myarn"} # And given we don't want chalice to manage our iam role for the lambda # function: - cfg = Config({'chalice_app': sample_app, 'manage_iam_role': False, - 'app_name': 'appname', 'iam_role_arn': True, - 'project_dir': './myproject'}) + cfg = Config.create( + chalice_stage='dev', + chalice_app=sample_app, + manage_iam_role=False, + app_name='appname', + iam_role_arn=True, + project_dir='./myproject' + ) d = LambdaDeployer(aws_client, packager, None, osutils, app_policy) # Doing a lambda deploy: diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index cd2b913ad..37aea038f 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -7,7 +7,17 @@ def test_config_create_method(): # Otherwise attributes default to None meaning 'not set'. assert c.lambda_arn is None assert c.profile is None - assert c.stage is None + assert c.api_gateway_stage is None + + +def test_default_chalice_stage(): + c = Config() + assert c.chalice_stage == 'dev' + + +def test_version_defaults_to_1_when_missing(): + c = Config() + assert c.config_file_version == '1.0' def test_default_value_of_manage_iam_role(): @@ -24,24 +34,24 @@ def test_manage_iam_role_explicitly_set(): def test_can_chain_lookup(): user_provided_params = { - 'stage': 'user_provided_params', + 'api_gateway_stage': 'user_provided_params', 'lambda_arn': 'user_provided_params', } config_from_disk = { - 'stage': 'config_from_disk', + 'api_gateway_stage': 'config_from_disk', 'lambda_arn': 'config_from_disk', 'app_name': 'config_from_disk', } default_params = { - 'stage': 'default_params', + 'api_gateway_stage': 'default_params', 'app_name': 'default_params', 'project_dir': 'default_params', } - c = Config(user_provided_params, config_from_disk, default_params) - assert c.stage == 'user_provided_params' + c = Config('dev', user_provided_params, config_from_disk, default_params) + assert c.api_gateway_stage == 'user_provided_params' assert c.lambda_arn == 'user_provided_params' assert c.app_name == 'config_from_disk' assert c.project_dir == 'default_params' @@ -50,9 +60,34 @@ def test_can_chain_lookup(): def test_user_params_is_optional(): - c = Config(config_from_disk={'stage': 'config_from_disk'}, - default_params={'stage': 'default_params'}) - assert c.stage == 'config_from_disk' + c = Config(config_from_disk={'api_gateway_stage': 'config_from_disk'}, + default_params={'api_gateway_stage': 'default_params'}) + assert c.api_gateway_stage == 'config_from_disk' + + +def test_can_chain_chalice_stage_values(): + disk_config = { + 'api_gateway_stage': 'dev', + 'stages': { + 'dev': { + }, + 'prod': { + 'api_gateway_stage': 'prod', + 'iam_role_arn': 'foobar', + 'manage_iam_role': False, + } + } + } + c = Config(chalice_stage='dev', + config_from_disk=disk_config) + assert c.api_gateway_stage == 'dev' + assert c.manage_iam_role + + prod = Config(chalice_stage='prod', + config_from_disk=disk_config) + assert prod.api_gateway_stage == 'prod' + assert prod.iam_role_arn == 'foobar' + assert not prod.manage_iam_role def test_can_create_deployed_resource_from_dict(): diff --git a/tests/unit/test_package.py b/tests/unit/test_package.py index e023b2f81..b81e65aad 100644 --- a/tests/unit/test_package.py +++ b/tests/unit/test_package.py @@ -48,7 +48,8 @@ def test_sam_generates_sam_template_basic(sample_app, mock_policy_generator): p = package.SAMTemplateGenerator(mock_swagger_generator, mock_policy_generator) - template = p.generate_sam_template(sample_app, 'code-uri', stage_name='dev') + template = p.generate_sam_template(sample_app, 'code-uri', + api_gateway_stage='dev') # Verify the basic structure is in place. The specific parts # are validated in other tests. assert template['AWSTemplateFormatVersion'] == '2010-09-09'