diff --git a/README.md b/README.md index a9906fa..83d5459 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Tooling to deploy Safecast to AWS Elastic Beanstalk and work with AWS. Deploymen ## Installation -It's best if this is run in its own virtualenv. It seems that `wheel` must be installed prior to other requirements. +It's best if this is run in its own virtualenv; using [direnv](https://direnv.net/) is a common way to accomplish this. It seems that `wheel` must be installed prior to other requirements. ``` pip install wheel @@ -24,8 +24,18 @@ pip install --requirement requirements.txt Help on specific commands can be found by using `--help` with that command: `./deploy.py ssh --help` +### Shell completion + +Shell completion is supported via [argcomplete](https://github.com/kislyuk/argcomplete). I have not been able to find a way to make global argcomplete support work within the direnv; instead, each time I have to run `eval "$(register-python-argcomplete deploy.py)"`. + ## Known issues The scripts currently assume that a previous environment already exists, in all cases. When `new_env` is called, safecast_deploy creates a new environment from the existing application configuration templates stored in Elastic Beanstalk and named `dev`, `dev-wrk`, `prd`, `prd-wrk`, etc. The `new_env` command will set a new ARN for the environment; however, that new ARN is not saved back to the application template. This is not generally a problem, especially if we continue to use this tool for all new deployments. However, it does mean that the saved template does not accurately reflect what is being run any longer. We could create a task in the future to synchronize the saved templates to what is actually running. + +## Development + +Unit tests can be run with `python -m unittest`. + +Please run `pycodestyle` before committing. diff --git a/deploy.py b/deploy.py index 3a8b691..c579d7d 100755 --- a/deploy.py +++ b/deploy.py @@ -1,15 +1,14 @@ #!/usr/bin/env python3 -# Currently for deploying when you also want to create a new -# environment, not for redeploying to an existing environment. - import sys -if sys.version_info.major < 3 or sys.version_info.minor < 7: - print("Error: This script requires at least Python 3.7.", file=sys.stderr) +if sys.version_info.major < 3 or sys.version_info.minor < 8: + print("Error: This script requires at least Python 3.8.", file=sys.stderr) exit(1) +import argcomplete import argparse import boto3 +import json import pprint import re import safecast_deploy @@ -21,6 +20,10 @@ import safecast_deploy.state import time +from safecast_deploy.aws_state import AwsTierType, EnvType +from safecast_deploy.extended_json_encoder import ExtendedJSONEncoder +from safecast_deploy.result_logger import ResultLogger + def parse_args(): p = argparse.ArgumentParser() @@ -30,9 +33,10 @@ def parse_args(): list_arns_p.set_defaults(func=run_list_arns) apps = ['api', 'ingest', 'reporting'] - environments = ['dev', 'prd'] + environments = [type.value for type in list(EnvType)] + tiers = [type.value for type in list(AwsTierType)] - desc_metadata_p = ps.add_parser('desc_metadata', help="") + desc_metadata_p = ps.add_parser('desc_metadata', help="Describe the metadata available for this application.") desc_metadata_p.add_argument('app', choices=apps, help="The application to describe.",) @@ -50,7 +54,7 @@ def parse_args(): choices=apps, help="The target application to deploy to.",) new_env_p.add_argument('env', - choices=['dev', 'prd'], + choices=environments, help="The target environment to deploy to.",) new_env_p.add_argument('version', help="The new version to deploy.") new_env_p.add_argument('arn', help="The ARN the new deployment should use.") @@ -78,9 +82,9 @@ def parse_args(): save_configs_p.add_argument('-e', '--env', choices=environments, help="Limit the overwrite to a specific environment.") - save_configs_p.add_argument('-r', '--role', - choices=['web', 'wrk'], - help="Limit the overwrite to a specific role.") + save_configs_p.add_argument('-t', '--tier', + choices=tiers, + help="Limit the overwrite to a specific tier.") save_configs_p.set_defaults(func=safecast_deploy.config_saver.run_cli) ssh_p = ps.add_parser('ssh', help='SSH to the selected environment.') @@ -88,10 +92,10 @@ def parse_args(): choices=apps, help="The target application.",) ssh_p.add_argument('env', - choices=['dev', 'prd', ], + choices=environments, help="The target environment.",) - ssh_p.add_argument('role', - choices=['web', 'wrk', ], + ssh_p.add_argument('tier', + choices=tiers, help="The type of server.",) ssh_p.add_argument('-s', '--select', action='store_true', help="Choose a specific server. Otherwise, will connect to the first server found.",) @@ -110,6 +114,7 @@ def parse_args(): help="The target application.",) versions_p.set_defaults(func=run_versions) + argcomplete.autocomplete(p) args = p.parse_args() if 'func' in args: args.func(args) @@ -117,7 +122,6 @@ def parse_args(): p.error("too few arguments") - def run_list_arns(args): c = boto3.client('elasticbeanstalk') platforms = c.list_platform_versions( @@ -139,8 +143,9 @@ def run_list_arns(args): def run_desc_metadata(args): - state = safecast_deploy.state.State(args.app) - pprint.PrettyPrinter(stream=sys.stderr).pprint(state.env_metadata) + state = safecast_deploy.state.State(args.app, boto3.client('elasticbeanstalk')) + json.dump(state.old_aws_state.to_dict(), sys.stdout, sort_keys=True, indent=2) + print() def run_desc_template(args): @@ -149,43 +154,51 @@ def run_desc_template(args): ApplicationName=args.app, TemplateName=args.template, ) - pprint.PrettyPrinter(stream=sys.stderr).pprint(template) + json.dump(template, sys.stdout, sort_keys=True, indent=2, cls=ExtendedJSONEncoder) + print() def run_new_env(args): - state = safecast_deploy.state.State( - args.app, - args.env, - new_version=args.version, - new_arn=args.arn - ) - safecast_deploy.new_env.NewEnv(state, not args.no_update_templates).run() + eb_client = boto3.client('elasticbeanstalk') + result_logger = ResultLogger() + state = safecast_deploy.state.State(args.app, eb_client) + config_saver = safecast_deploy.config_saver.ConfigSaver(eb_client, result_logger) + safecast_deploy.new_env.NewEnv( + EnvType(args.env), + state.old_aws_state, + state.new_aws_state(new_version=args.version), + boto3.client('elasticbeanstalk'), + result_logger, + config_saver, + (not args.no_update_templates), + ).run() def run_same_env(args): - state = safecast_deploy.state.State( - args.app, - args.env, - new_version=args.version, - ) - safecast_deploy.same_env.SameEnv(state).run() + state = safecast_deploy.state.State(args.app, boto3.client('elasticbeanstalk')) + env_type = EnvType(args.env) + safecast_deploy.same_env.SameEnv( + env_type, + state.old_aws_state, + state.new_aws_state(env_type, new_version=args.version), + boto3.client('elasticbeanstalk'), + ResultLogger(), + ).run() def run_ssh(args): - state = safecast_deploy.state.State(args.app, args.env) - safecast_deploy.ssh.Ssh(state, args).run() + aws_state = safecast_deploy.state.State(args.app, boto3.client('elasticbeanstalk')).old_aws_state + safecast_deploy.ssh.ssh(aws_state, EnvType(args.env), AwsTierType(args.tier), args.select) def run_versions(args): - state = safecast_deploy.state.State(args.app) + state = safecast_deploy.state.State(args.app, boto3.client('elasticbeanstalk')) print(*state.available_versions, sep='\n') def main(): parse_args() # TODO method to switch to maintenance page - # - # TODO method to clean out old versions if __name__ == '__main__': diff --git a/requirements.txt b/requirements.txt index c084b9d..561db67 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ +argcomplete>=1.12.1,<2.0 boto3>=1.14.4,<2.0 +dataclasses-json>=0.5.2,<2.0 GitPython>=3.1.3,<4.0 pycodestyle diff --git a/safecast_deploy/aws_state.py b/safecast_deploy/aws_state.py new file mode 100644 index 0000000..35c60b8 --- /dev/null +++ b/safecast_deploy/aws_state.py @@ -0,0 +1,51 @@ +from dataclasses import dataclass +from dataclasses_json import dataclass_json +from enum import Enum, unique + + +@unique +class EnvType(str, Enum): + """Encodes Safecast's understanding of environment types. + """ + DEV = 'dev' + PROD = 'prd' + + def __repr__(self): + return '<%s.%s>' % (self.__class__.__name__, self.name) + + +@unique +class AwsTierType(str, Enum): + WEB = 'web' + WORKER = 'wrk' + + def __repr__(self): + return '<%s.%s>' % (self.__class__.__name__, self.name) + + +@dataclass_json +@dataclass(frozen=True) +class ParsedVersion: + app: str + circleci_build_num: int + clean_branch_name: str + git_commit: str + version_string: str + + +@dataclass_json +@dataclass(frozen=True) +class AwsState: + aws_app_name: str + envs: dict # dictionary mapping EnvTypes to a nested dictionary of AwsTierTypes to AwsTier objects + + +@dataclass_json +@dataclass(frozen=True) +class AwsTier: + tier_type: AwsTierType + platform_arn: str + parsed_version: ParsedVersion + name: str + environment_id: str # Note this is calculated by AWS and will not be available prior to new environment creation + num: int diff --git a/safecast_deploy/config_saver.py b/safecast_deploy/config_saver.py index 9aeacd5..135c8ce 100644 --- a/safecast_deploy/config_saver.py +++ b/safecast_deploy/config_saver.py @@ -1,65 +1,66 @@ +import boto3 import datetime import pprint import sys -from safecast_deploy import git_logger, state, verbose_sleep +from safecast_deploy import verbose_sleep +from safecast_deploy.aws_state import AwsTierType, EnvType +from safecast_deploy.result_logger import ResultLogger +from safecast_deploy.state import State def run_cli(args): - ConfigSaver(app=args.app, env=args.env, role=args.role).run() + app = args.app + env = EnvType(args.env) if args.env else None + tier = AwsTierType(args.tier) if args.tier else None + ConfigSaver(boto3.client('elasticbeanstalk'), ResultLogger()).run(app=app, env=env, tier=tier) class ConfigSaver: - def __init__(self, app=None, env=None, role=None): - self.app = app - self.env = env - self.role = role - self.states = {} + # This class is not thread-safe. + def __init__(self, eb_client, result_logger): + self._c = eb_client + self._result_logger = result_logger + self._completed_list = [] + + def run(self, app=None, env=None, tier=None): + all_apps = ['api', 'ingest', 'recording'] + states = {} if app is None: - self.states['api'] = state.State('api') - self.states['ingest'] = state.State('ingest') - self.states['reporting'] = state.State('reporting') - self._c = self.states['api'].eb_client + for default_app in all_apps: + states[default_app] = State(default_app, self._c).old_aws_state else: - self.states[app] = state.State(app) - self._c = self.states[app].eb_client - self.completed_list = [] + states[app] = State(app, self._c).old_aws_state - def run(self): - for app in self.states: - env_metadata = self.states[app].env_metadata - if self.app is None: - self.process_app('api') - self.process_app('ingest') - else: - self.process_app(app) - git_logger.log_result(self.completed_list) - pprint.PrettyPrinter(stream=sys.stderr).pprint(self.completed_list) + for state in states: + self.process_state(states[state], env, tier) + self._result_logger.log_result(self._completed_list) + self._completed_list = [] - def process_app(self, app): - if self.env is None: - self.process_env(app, 'dev') - self.process_env(app, 'prd') + def process_state(self, state, env, tier): + if env is None: + for available_env in state.envs: + self.process_env(state, available_env, tier) else: - self.process_env(app, self.env) + self.process_env(state, env, tier) - def process_env(self, app, env): + def process_env(self, state, env, tier): template_names = { - 'web': env, - 'wrk': '{}-wrk'.format(env), + AwsTierType.WEB: env.value, + AwsTierType.WORKER: f'{env.value}-wrk', } - if self.role is None: - self.process_role(app, env, 'web', template_names) - if self.states[app].has_worker: - self.process_role(app, env, 'wrk', template_names) + if tier is None: + for available_tier in state.envs[env]: + self.process_tier(state, env, available_tier, template_names) else: - self.process_role(app, env, self.role, template_names) + self.process_tier(state, env, tier, template_names) - def process_role(self, app, env, role, template_names): + def process_tier(self, state, env, tier, template_names): start_time = datetime.datetime.now(datetime.timezone.utc) - template_name = template_names[role] - env_id = self.states[app].env_metadata[template_name]['api_env']['EnvironmentId'] - env_name = self.states[app].env_metadata[template_name]['name'] + template_name = template_names[tier] + app = state.aws_app_name + env_id = state.envs[env][tier].environment_id + env_name = state.envs[env][tier].name print(f"Starting update of template {template_name} from {env_name}", file=sys.stderr) self._c.delete_configuration_template( ApplicationName=app, @@ -73,14 +74,15 @@ def process_role(self, app, env, role, template_names): ) print(f"Completed update of template {template_name} from {env_name}", file=sys.stderr) completed_time = datetime.datetime.now(datetime.timezone.utc) - self.completed_list.append({ + self._completed_list.append({ 'app': app, 'completed_at': completed_time, 'elapsed_time': (completed_time - start_time).total_seconds(), 'env': env, 'event': 'save_configs', - 'role': role, + 'tier': tier, + 'source_env_id': env_id, 'source_env_name': env_name, 'started_at': start_time, - 'template_name': template_name + 'template_name': template_name, }) diff --git a/safecast_deploy/exceptions.py b/safecast_deploy/exceptions.py new file mode 100644 index 0000000..1ff0800 --- /dev/null +++ b/safecast_deploy/exceptions.py @@ -0,0 +1,25 @@ +class InvalidVersionException(Exception): + def __init__(self, message, version): + self.message = message + self.version = version + + +class InvalidEnvStateException(Exception): + def __init__(self, message, env_type, tier_type): + self.message = message + self.env_type = env_type + self.tier_type = tier_type + + +class EnvNotHealthyException(Exception): + def __init__(self, message, env_name, health): + self.message = message + self.env_name = env_name + self.health = health + + +class EnvUpdateTimedOutException(Exception): + def __init__(self, message, env_name, timeout_length): + self.message = message + self.env_name = env_name + self.timeout_length = timeout_length diff --git a/safecast_deploy/extended_json_encoder.py b/safecast_deploy/extended_json_encoder.py new file mode 100644 index 0000000..f8172a6 --- /dev/null +++ b/safecast_deploy/extended_json_encoder.py @@ -0,0 +1,9 @@ +import datetime +import json + + +class ExtendedJSONEncoder(json.JSONEncoder): + def default(self, obj): + if isinstance(obj, (datetime.datetime)): + return obj.isoformat() + return json.JSONEncoder.default(self, obj) diff --git a/safecast_deploy/git_logger.py b/safecast_deploy/git_logger.py deleted file mode 100644 index 4dec07e..0000000 --- a/safecast_deploy/git_logger.py +++ /dev/null @@ -1,43 +0,0 @@ -import datetime -import git -import json -import os -import tempfile - - -class Iso8601DateTimeEncoder(json.JSONEncoder): - def default(self, obj): - if isinstance(obj, (datetime.datetime)): - return obj.isoformat() - return json.JSONEncoder.default(self, obj) - - -def log_result(result): - with tempfile.TemporaryDirectory() as temp_dir: - repo = git.Repo.clone_from('git@github.com:Safecast/deployment-history.git', temp_dir) - # TODO automatically create dirs and files as necessary - if isinstance(result, list): - for item in result: - write_entry(item, temp_dir, repo) - else: - write_entry(result, temp_dir, repo) - repo.index.commit("Updated entry.") - repo.remotes.origin.push() - - -def write_entry(result, temp_dir, repo): - log_file_path = os.path.join(temp_dir, result['app'], (result['env'] + '.json')) - - if not os.path.exists(os.path.dirname(log_file_path)): - os.mkdir(os.path.dirname(log_file_path)) - - if os.path.exists(log_file_path): - with open(log_file_path, 'r', encoding='utf-8', newline='\n') as f: - history = json.load(f) - else: - history = [] - - history.insert(0, result) - with open(log_file_path, 'w', encoding='utf-8', newline='\n') as f: - json.dump(history, f, indent=2, sort_keys=True, cls=Iso8601DateTimeEncoder) - repo.index.add(log_file_path) diff --git a/safecast_deploy/new_env.py b/safecast_deploy/new_env.py index 1bcc574..97ab17a 100644 --- a/safecast_deploy/new_env.py +++ b/safecast_deploy/new_env.py @@ -2,37 +2,71 @@ import pprint import sys -from safecast_deploy import config_saver, git_logger, verbose_sleep +from safecast_deploy import verbose_sleep +from safecast_deploy.aws_state import AwsTierType +from safecast_deploy.exceptions import EnvUpdateTimedOutException class NewEnv: - def __init__(self, state, update_templates): - self.state = state - self._c = state.eb_client - self.update_templates = update_templates + def __init__(self, target_env_type, old_aws_state, new_aws_state, eb_client, result_logger, + config_saver, update_templates, update_wait=70, total_update_wait=480): + self._target_env_type = target_env_type + self._aws_app_name = old_aws_state.aws_app_name + self._old_aws_env_state = old_aws_state.envs[target_env_type] + self._new_aws_env_state = new_aws_state.envs[target_env_type] + self._c = eb_client + self._result_logger = result_logger + self._config_saver = config_saver + self._update_templates = update_templates + self._update_wait = update_wait + self._total_update_wait = total_update_wait def run(self): - self.start_time = datetime.datetime.now(datetime.timezone.utc) - if self.update_templates: - config_saver.ConfigSaver( - app=self.state.app, env=self.state.env - ).run() - # Handle the worker environment first, to ensure that database + self._start_time = datetime.datetime.now(datetime.timezone.utc) + if self._update_templates: + self._config_saver.run() + + # Handle the worker tier first, to ensure that database # migrations are applied - self._calculate_new_envs() - if self.state.has_worker: - self._handle_worker() - self._handle_web() + if AwsTierType.WORKER in self._old_aws_env_state: + self._handle_tier(self._old_aws_env_state[AwsTierType.WORKER], self._new_aws_env_state[AwsTierType.WORKER]) + self._handle_tier(self._old_aws_env_state[AwsTierType.WEB], self._new_aws_env_state[AwsTierType.WEB]) + result = self._generate_result() - self._print_result(result) - git_logger.log_result(result) + self._result_logger.log_result(result) + + def _handle_tier(self, old_tier, new_tier): + template_name = f'{self._target_env_type}' if old_tier.tier_type is AwsTierType.WEB else f'{self._target_env_type}-{old_tier.tier_type}' + + if old_tier.tier_type is AwsTierType.WORKER: + print("Setting the worker tier to scale to 0, in order to stop it and avoid concurrent processing problems.", file=sys.stderr) + self._stop_tier(self._aws_app_name, old_tier.name) + + print(f"Creating the new environment {new_tier.name}.", file=sys.stderr) + self._c.create_environment( + ApplicationName=self._aws_app_name, + EnvironmentName=new_tier.name, + PlatformArn=new_tier.platform_arn, + TemplateName=template_name, + VersionLabel=new_tier.parsed_version.version_string, + ) + self._wait_for_green(new_tier.name) - def _handle_worker(self): - # First, turn off the current worker to avoid any concurrency issues - print("Setting the worker tier to scale to 0.", file=sys.stderr) + if new_tier.tier_type is AwsTierType.WEB: + print("Swapping web environment CNAMEs.", file=sys.stderr) + self._c.swap_environment_cnames( + SourceEnvironmentName=old_tier.name, + DestinationEnvironmentName=new_tier.name, + ) + + verbose_sleep(self._update_wait) + print(f"Terminating the old environment {old_tier.name}.", file=sys.stderr) + self._c.terminate_environment(EnvironmentName=old_tier.name) + + def _stop_tier(self, app_name, tier_name): self._c.update_environment( - ApplicationName=self.state.app, - EnvironmentName=self.state.env_metadata[self.state.subenvs['wrk']]['name'], + ApplicationName=app_name, + EnvironmentName=tier_name, OptionSettings=[ { 'ResourceName': 'AWSEBAutoScalingGroup', @@ -46,130 +80,68 @@ def _handle_worker(self): 'OptionName': 'MinSize', 'Value': '0' }, - ]) - verbose_sleep(480) - print("Creating the new worker environment.", file=sys.stderr) - self._c.create_environment( - ApplicationName=self.state.app, - EnvironmentName=self.new_env_metadata['wrk']['name'], - PlatformArn=self.state.new_arn, - TemplateName=self.state.subenvs['wrk'], - VersionLabel=self.state.new_version, - ) - self._wait_for_green(self.new_env_metadata['wrk']['name']) - print("Terminating the old worker environment.", file=sys.stderr) - self._c.terminate_environment(EnvironmentName=self.state.env_metadata[self.state.subenvs['wrk']]['name']) - - def _handle_web(self): - print("Creating the new Web environment.", file=sys.stderr) - self._c.create_environment( - ApplicationName=self.state.app, - EnvironmentName=self.new_env_metadata['web']['name'], - PlatformArn=self.state.new_arn, - TemplateName=self.state.subenvs['web'], - VersionLabel=self.state.new_version, - ) - self._wait_for_green(self.new_env_metadata['web']['name']) - print("Swapping web environment CNAMEs.", file=sys.stderr) - self._c.swap_environment_cnames( - SourceEnvironmentName=self.state.env_metadata[self.state.subenvs['web']]['name'], - DestinationEnvironmentName=self.new_env_metadata['web']['name'], + ], ) - verbose_sleep(120) - print("Terminating the old web environment.", file=sys.stderr) - self._c.terminate_environment(EnvironmentName=self.state.env_metadata[self.state.subenvs['web']]['name']) + verbose_sleep(480) def _generate_result(self): completed_time = datetime.datetime.now(datetime.timezone.utc) result = { - 'app': self.state.app, + 'app': self._aws_app_name, 'completed_at': completed_time, - 'elapsed_time': (completed_time - self.start_time).total_seconds(), - 'env': self.state.env, + 'elapsed_time': (completed_time - self._start_time).total_seconds(), + 'env': self._target_env_type.value, 'event': 'new_env', - 'started_at': self.start_time, - 'web': { - 'new_env': self.new_env_metadata['web']['name'], - 'new_version': self.state.new_version, - 'new_version_parsed': self.state.new_versions_parsed['web'], - 'old_env': self.state.env_metadata[self.state.subenvs['web']]['name'], - 'old_version': self.state.env_metadata[self.state.subenvs['web']]['version'], - 'old_version_parsed': self.state.old_versions_parsed['web'], - }, - + 'started_at': self._start_time, } - self._add_git('web', result) - - if self.state.has_worker: - result['wrk'] = { - 'env': self.state.env_metadata[self.state.subenvs['wrk']]['name'], - 'new_version': self.state.new_version, - 'new_version_parsed': self.state.new_versions_parsed['wrk'], - 'old_version': self.state.env_metadata[self.state.subenvs['wrk']]['version'], - 'old_version_parsed': self.state.old_versions_parsed['wrk'], - } - self._add_git('wrk', result) - + for new_tier_type, new_tier in self._new_aws_env_state.items(): + old_tier = self._old_aws_env_state[new_tier_type] + result.update( + { + f'{new_tier_type.value}': { + 'new_env': new_tier.name, + 'new_version_parsed': new_tier.parsed_version.to_dict(), + 'old_env': old_tier.name, + 'old_version_parsed': old_tier.parsed_version.to_dict(), + } + } + ) + self._add_git(new_tier_type, old_tier, new_tier, result) return result - def _add_git(self, tier, result): + def _add_git(self, new_tier_type, old_tier, new_tier, result): + # TODO move this out into a config file repo_names = { 'api': 'safecastapi', 'ingest': 'ingest', 'reporting': 'reporting', } - if 'git_commit' in self.state.old_versions_parsed[tier] \ - and 'git_commit' in self.state.new_versions_parsed[tier]: - result[tier]['github_diff'] = 'https://github.com/Safecast/{}/compare/{}...{}'.format( - repo_names[self.state.app], - self.state.old_versions_parsed[tier]['git_commit'], - self.state.new_versions_parsed[tier]['git_commit'] + if (old_tier.parsed_version.git_commit is not None) \ + and (new_tier.parsed_version.git_commit is not None): + result[new_tier_type.value]['github_diff'] = 'https://github.com/Safecast/{}/compare/{}...{}'.format( + repo_names[self._aws_app_name], + old_tier.parsed_version.git_commit, + new_tier.parsed_version.git_commit, ) - def _print_result(self, result): - pprint.PrettyPrinter(stream=sys.stderr).pprint(result) - print("Deployment completed.", file=sys.stderr) - - def _calculate_new_envs(self): - new_num = self._balance_env_num() - self.new_env_metadata = { - 'web': { - 'name': 'safecast{app}-{env}-{num:03}'.format( - app=self.state.app, - env=self.state.env, - num=new_num - ) - }, - 'wrk': { - 'name': 'safecast{app}-{env}-wrk-{num:03}'.format( - app=self.state.app, - env=self.state.env, - num=new_num - ) - }, - } - - def _balance_env_num(self): - web_num = (self.state.env_metadata[self.state.subenvs['web']]['num'] + 1) % 1000 - if self.state.has_worker: - wrk_num = (self.state.env_metadata[self.state.subenvs['wrk']]['num'] + 1) % 1000 - return max(web_num, wrk_num) - else: - return web_num - def _wait_for_green(self, env_name): - verbose_sleep(70) + print( + f"Waiting for {env_name} health to return to normal. Waiting {self._update_wait} seconds before first check to ensure an accurate starting point.", + file=sys.stderr + ) + verbose_sleep(self._update_wait) wait_seconds = 0 - while wait_seconds < 540: + while wait_seconds < self._total_update_wait: health = self._c.describe_environment_health( EnvironmentName=env_name, AttributeNames=['HealthStatus', ] )['HealthStatus'] if health == 'Ok': - print("Environment health has returned to normal.", file=sys.stderr) + print(f"{env_name} health has returned to normal.", file=sys.stderr) return - verbose_sleep(40) - wait_seconds += 40 - print("Environment health did not return to normal within 540 seconds. Aborting further operations.", - file=sys.stderr) - exit(1) + verbose_sleep(self._update_wait) + wait_seconds += self._update_wait + raise EnvUpdateTimedOutException( + "f{env_name} health did not return to normal within f{self._total_update_wait} seconds.", + env_name, self._total_update_wait + ) diff --git a/safecast_deploy/result_logger.py b/safecast_deploy/result_logger.py new file mode 100644 index 0000000..87b3599 --- /dev/null +++ b/safecast_deploy/result_logger.py @@ -0,0 +1,52 @@ +import datetime +import git +import json +import os +import sys +import tempfile + +from safecast_deploy.extended_json_encoder import ExtendedJSONEncoder + + +class ResultLogger(): + def __init__(self, stream=sys.stdout, log_git=True): + self.log_git = log_git + self.stream = stream + + def log_result(self, result): + self._write_stream(result) + if self.log_git: + self._log_git(result) + + def _log_git(self, result): + with tempfile.TemporaryDirectory() as temp_dir: + repo = git.Repo.clone_from('git@github.com:Safecast/deployment-history.git', temp_dir) + # TODO automatically create dirs and files as necessary + if isinstance(result, list): + for item in result: + self._write_git_entry(item, temp_dir, repo) + else: + self._write_git_entry(result, temp_dir, repo) + repo.index.commit("Updated entry.") + repo.remotes.origin.push() + + def _write_stream(self, result): + json.dump(result, self.stream, sort_keys=True, indent=2, cls=ExtendedJSONEncoder) + print(file=self.stream) + + def _write_git_entry(self, result, temp_dir, repo): + log_file_path = os.path.join(temp_dir, result['app'], (result['env'] + '.json')) + + if not os.path.exists(os.path.dirname(log_file_path)): + os.mkdir(os.path.dirname(log_file_path)) + + if os.path.exists(log_file_path): + with open(log_file_path, 'r', encoding='utf-8', newline='\n') as f: + history = json.load(f) + else: + history = [] + + history.insert(0, result) + with open(log_file_path, 'w', encoding='utf-8', newline='\n') as f: + json.dump(history, f, indent=2, sort_keys=True, cls=ExtendedJSONEncoder) + repo.index.add(log_file_path) diff --git a/safecast_deploy/same_env.py b/safecast_deploy/same_env.py index af3613f..731b414 100644 --- a/safecast_deploy/same_env.py +++ b/safecast_deploy/same_env.py @@ -2,106 +2,125 @@ import pprint import sys -from safecast_deploy import git_logger, verbose_sleep +from safecast_deploy import aws_state, verbose_sleep +from safecast_deploy.aws_state import AwsTierType +from safecast_deploy.exceptions import EnvNotHealthyException, EnvUpdateTimedOutException class SameEnv: - def __init__(self, state): - self.state = state - self._c = state.eb_client + def __init__(self, target_env_type, old_aws_state, new_aws_state, eb_client, result_logger, + update_wait=70, total_update_wait=480): + self._target_env_type = target_env_type + self._aws_app_name = old_aws_state.aws_app_name + self._old_aws_env_state = old_aws_state.envs[target_env_type] + self._new_aws_env_state = new_aws_state.envs[target_env_type] + self._c = eb_client + self._result_logger = result_logger + self._update_wait = update_wait + self._total_update_wait = total_update_wait def run(self): self.start_time = datetime.datetime.now(datetime.timezone.utc) - # Handle the worker environment first, to ensure that database + + self._check_environments() + + # Handle the worker tier first, to ensure that database # migrations are applied - self._handle_worker() - self._handle_web() - result = self._generate_result() - self._print_result(result) - git_logger.log_result(result) + self._update_environment(AwsTierType.WORKER) + self._update_environment(AwsTierType.WEB) - def _handle_worker(self): - if self.state.has_worker: - print("Deploying to the worker.", file=sys.stderr) - env_name = self.state.env_metadata[self.state.subenvs['wrk']]['name'] - self._update_environment(env_name) + result = self._generate_result() + self._result_logger.log_result(result) - def _handle_web(self): - print("Deploying to the web instances.", file=sys.stderr) - env_name = self.state.env_metadata[self.state.subenvs['web']]['name'] - self._update_environment(env_name) + # Make sure we're not trying to deploy on top of an environment in distress + def _check_environments(self): + for tier_type, tier in self._old_aws_env_state.items(): + health = self._c.describe_environment_health( + EnvironmentName=tier.name, + AttributeNames=['HealthStatus', ] + )['HealthStatus'] + if health != 'Ok': + raise EnvNotHealthyException( + f"Environment {tier.name} has a health status of {health} and cannot be deployed to.", + tier.name, + health + ) def _generate_result(self): completed_time = datetime.datetime.now(datetime.timezone.utc) result = { - 'app': self.state.app, + 'app': self._aws_app_name, 'completed_at': completed_time, 'elapsed_time': (completed_time - self.start_time).total_seconds(), - 'env': self.state.env, + 'env': self._target_env_type.value, 'event': 'same_env', 'started_at': self.start_time, - 'web': { - 'env': self.state.env_metadata[self.state.subenvs['web']]['name'], - 'new_version': self.state.new_version, - 'new_version_parsed': self.state.new_versions_parsed['web'], - 'old_version': self.state.env_metadata[self.state.subenvs['web']]['version'], - 'old_version_parsed': self.state.old_versions_parsed['web'], - }, } - self._add_git('web', result) - - if self.state.has_worker: - result['wrk'] = { - 'env': self.state.env_metadata[self.state.subenvs['wrk']]['name'], - 'new_version': self.state.new_version, - 'new_version_parsed': self.state.new_versions_parsed['wrk'], - 'old_version': self.state.env_metadata[self.state.subenvs['wrk']]['version'], - 'old_version_parsed': self.state.old_versions_parsed['wrk'], - } - self._add_git('wrk', result) + for new_tier_type, new_tier in self._new_aws_env_state.items(): + old_tier = self._old_aws_env_state[new_tier_type] + result.update( + { + f'{new_tier_type.value}': { + 'new_version_parsed': new_tier.parsed_version.to_dict(), + 'old_version_parsed': old_tier.parsed_version.to_dict(), + }, + } + ) + self._add_git(new_tier_type, old_tier, new_tier, result) return result - def _add_git(self, role, result): + def _add_git(self, new_tier_type, old_tier, new_tier, result): + # TODO move this out into a config file repo_names = { 'api': 'safecastapi', 'ingest': 'ingest', 'reporting': 'reporting', } - if 'git_commit' in self.state.old_versions_parsed[role] \ - and 'git_commit' in self.state.new_versions_parsed[role]: - result[role]['github_diff'] = 'https://github.com/Safecast/{}/compare/{}...{}'.format( - repo_names[self.state.app], - self.state.old_versions_parsed[role]['git_commit'], - self.state.new_versions_parsed[role]['git_commit'] + if (old_tier.parsed_version.git_commit is not None) \ + and (new_tier.parsed_version.git_commit is not None): + result[new_tier_type.value]['github_diff'] = 'https://github.com/Safecast/{}/compare/{}...{}'.format( + repo_names[self._aws_app_name], + old_tier.parsed_version.git_commit, + new_tier.parsed_version.git_commit, ) def _print_result(self, result): - pprint.PrettyPrinter(stream=sys.stderr).pprint(result) + print(json.dumps(result, sort_keys=True, indent=2)) print("Deployment completed.", file=sys.stderr) - def _update_environment(self, env_name): + def _update_environment(self, tier): + if tier not in self._new_aws_env_state: + return + + print(f"Deploying to the {tier.value} tier.", file=sys.stderr) + env_name = self._new_aws_env_state[tier].name self._c.update_environment( - ApplicationName=self.state.app, + ApplicationName=self._aws_app_name, EnvironmentName=env_name, - VersionLabel=self.state.new_version, + VersionLabel=self._new_aws_env_state[tier].parsed_version.version_string ) - print("Waiting for instance health to return to normal.", file=sys.stderr) + self._wait_for_green(env_name) def _wait_for_green(self, env_name): - verbose_sleep(70) + print( + f"Waiting for {env_name} health to return to normal. Waiting {self._update_wait} seconds before first check to ensure an accurate starting point.", + file=sys.stderr + ) + verbose_sleep(self._update_wait) wait_seconds = 0 - while wait_seconds < 480: + while wait_seconds < self._total_update_wait: health = self._c.describe_environment_health( EnvironmentName=env_name, AttributeNames=['HealthStatus', ] )['HealthStatus'] if health == 'Ok': - print("Environment health has returned to normal.", file=sys.stderr) + print(f"{env_name} health has returned to normal.", file=sys.stderr) return - verbose_sleep(40) - wait_seconds += 40 - print("Environment health did not return to normal within 480 seconds. Aborting further operations.", - file=sys.stderr) - exit(1) + verbose_sleep(self._update_wait) + wait_seconds += self._update_wait + raise EnvUpdateTimedOutException( + "f{env_name} health did not return to normal within f{self._total_update_wait} seconds.", + env_name, self._total_update_wait + ) diff --git a/safecast_deploy/ssh.py b/safecast_deploy/ssh.py index e5e6fdc..ccefd39 100644 --- a/safecast_deploy/ssh.py +++ b/safecast_deploy/ssh.py @@ -4,26 +4,22 @@ import sys -class Ssh: - def __init__(self, state, args): - self.state = state - self.role = args.role - self.select = args.select +def ssh(aws_state, env_type, tier_type, select_instance): + c = boto3.client('elasticbeanstalk') + ec2_c = boto3.client('ec2') - def run(self): - c = self.state.eb_client - ec2_c = boto3.client('ec2') - env_resources = c.describe_environment_resources( - EnvironmentName=self.state.env_metadata[self.state.subenvs[self.role]]['name'])['EnvironmentResources'] - if self.select and len(env_resources['Instances']) > 1: - choices = '' - for index, instance in enumerate(env_resources['Instances']): - choices += "{}) {}\n".format(index, instance['Id']) - env_num = int(input("Select from below instances:\n" + choices)) - else: - env_num = 0 - instance_id = env_resources['Instances'][env_num]['Id'] - instances = ec2_c.describe_instances(InstanceIds=[instance_id, ]) - public_dns = instances['Reservations'][0]['Instances'][0]['PublicDnsName'] - print("Connecting to " + public_dns, file=sys.stderr) - os.execvp('ssh', ['ssh', 'ec2-user@' + public_dns, ]) + env_resources = c.describe_environment_resources( + EnvironmentName=aws_state.envs[env_type][tier_type].name)['EnvironmentResources'] + instance_num = 0 + if select_instance and len(env_resources['Instances']) > 1: + choices = '' + for index, instance in enumerate(env_resources['Instances']): + choices += f"{index}) {instance['Id']}\n" + instance_num = int(input("Select from below instances:\n" + choices)) + + instance_id = env_resources['Instances'][instance_num]['Id'] + instances = ec2_c.describe_instances(InstanceIds=[instance_id, ]) + public_dns = instances['Reservations'][0]['Instances'][0]['PublicDnsName'] + + print(f"Connecting to {public_dns}", file=sys.stderr) + os.execvp('ssh', ['ssh', f'ec2-user@{public_dns}', ]) diff --git a/safecast_deploy/state.py b/safecast_deploy/state.py index 9a428f6..d91b2ef 100644 --- a/safecast_deploy/state.py +++ b/safecast_deploy/state.py @@ -1,104 +1,120 @@ import boto3 +import collections +import dataclasses import re import sys +from safecast_deploy import aws_state +from safecast_deploy.aws_state import AwsState, AwsTier, ParsedVersion, EnvType, AwsTierType +from safecast_deploy.exceptions import InvalidVersionException +from functools import lru_cache -class State: - def __init__( - self, - app, - env=None, - new_version=None, - new_arn=None): - self.app = app - self.env = env - self.new_version = new_version - self.new_arn = new_arn - self.subenvs = { - 'web': env, - 'wrk': '{}-wrk'.format(env), - } +class State: + def __init__(self, aws_app_name, eb_client): + self.aws_app_name = aws_app_name + self._c = eb_client - self.eb_client = boto3.client('elasticbeanstalk') - self._c = self.eb_client - self._identify_current_envs() self._classify_available_versions() - self._validate_version() - def _validate_version(self): - if self.new_version is None: + self.old_aws_state = AwsState( + aws_app_name=self.aws_app_name, + envs=self._build_envs(), + ) + + @lru_cache(typed=True) + def new_aws_state(self, target_env_type, new_version=None, new_arn=None): + self._validate_version(new_version) + parsed_version = self._parse_version(new_version) + new_envs = collections.defaultdict(dict) + for (tier_type, tier) in self.old_aws_state.envs[target_env_type].items(): + new_tier = dataclasses.replace(tier) + if new_version is not None: + new_tier = dataclasses.replace(new_tier, parsed_version=parsed_version) + if new_arn is not None: + # A new ARN implies a new environment + new_env_num = max([(tier.num + 1) % 1000 for tier in self.old_aws_state.envs[target_env_type].values()]) + worker_name = (AwsTierType.WORKER.value + "-") if tier_type is AwsTierType.WORKER else '' + new_tier = dataclasses.replace( + new_tier, + platform_arn=new_arn, + environment_id=None, + name=f'safecast{self.old_aws_state.aws_app_name}-{target_env_type.value}-{worker_name}{new_env_num:03}', + num=new_env_num + ) + new_envs[target_env_type][tier_type] = new_tier + return dataclasses.replace(self.old_aws_state, envs=new_envs) + + def _validate_version(self, version): + if version is None: return - if self.new_version in self.failed_versions: - print("ERROR: New version is marked as 'failed' at AWS and cannot be deployed.", file=sys.stderr) - exit(1) - if self.new_version not in self.available_versions: - print("ERROR: New version was not found at AWS.", file=sys.stderr) - exit(1) - self.old_versions_parsed = { - 'web': self._parse_version(self.env_metadata[self.subenvs['web']]['version']) - } - self.new_versions_parsed = { - 'web': self._parse_version(self.new_version) - } - if self.has_worker: - self.old_versions_parsed['wrk'] = self._parse_version(self.env_metadata[self.subenvs['wrk']]['version']) - self.new_versions_parsed['wrk'] = self._parse_version(self.new_version) + if version in self.failed_versions: + raise InvalidVersionException("Version is marked as 'failed' at AWS and cannot be deployed.", version) + if version not in self.available_versions: + raise InvalidVersionException("Version was not found at AWS.", version) + return def _parse_version(self, version_str): if version_str is None: - return + return None + git_hash_pattern = re.compile(r'^(?P(api|ingest|reporting))-(?P.+)-(?P\d+)-(?P[0-9a-f]{40})$') no_git_hash_pattern = re.compile(r'^(?P(api|ingest|reporting))-(?P.+)-(?P\d+)$') git_match = git_hash_pattern.match(version_str) no_git_match = no_git_hash_pattern.match(version_str) + git_commit = None if git_match: match = git_match - parsed_version = { - 'git_commit': match.group('commit') - } + git_commit = match.group('commit') elif no_git_match: match = no_git_match - parsed_version = {} - # TODO: var is undefined if an `eb deploy` bundle is in use, would be good have a fallback for that case - parsed_version.update({ - 'app': match.group('app'), - 'circleci_build_num': match.group('build_num'), - 'clean_branch_name': match.group('clean_branch_name'), - }) - return parsed_version + # TODO: var is undefined if an `eb deploy` bundle is in use, would be good have a fallback for that case + return ParsedVersion( + app=match.group('app'), + circleci_build_num=int(match.group('build_num')), + clean_branch_name=match.group('clean_branch_name'), + git_commit=git_commit, + version_string=version_str, + ) - def _identify_current_envs(self): + def _build_envs(self): api_envs = self._c.describe_environments( - ApplicationName=self.app, IncludeDeleted=False)['Environments'] - name_pattern = re.compile('safecast' + self.app + r'-(?P(dev|dev-wrk|prd|prd-wrk))-(?P\d{3})') - self.env_metadata = {} + ApplicationName=self.aws_app_name, + IncludeDeleted=False, + )['Environments'] + name_pattern = re.compile('safecast' + self.aws_app_name + r'-(?P(dev|dev-wrk|prd|prd-wrk))-(?P\d{3})') + envs = collections.defaultdict(dict) for api_env in api_envs: match = name_pattern.fullmatch(api_env['EnvironmentName']) if match is None: - print('WARN: unrecognized environment ' + api_env['EnvironmentName'], file=sys.stderr) + print(f"WARN: unrecognized environment {api_env['EnvironmentName']}, not processing", file=sys.stderr) continue - env = match.group('env') - if env in self.env_metadata: - print("More than one " - + env - + """ environment was found, which one is the current environment?\n - TODO implement this once it becomes a problem. Exiting. - """, file=sys.stderr) - exit(1) - self.env_metadata[match.group('env')] = { - 'api_env': api_env, - 'api_resources': self._c.describe_environment_resources(EnvironmentName=api_env['EnvironmentName'])['EnvironmentResources'], - 'name': api_env['EnvironmentName'], - 'num': int(match.group('num')), - 'version': api_env['VersionLabel'], - } - self.has_worker = self.subenvs['wrk'] in self.env_metadata + env_str = match.group('env') + tier_type = AwsTierType.WEB + if env_str.endswith('-wrk'): + tier_type = AwsTierType.WORKER + env_str = env_str[:-4] + env_type = EnvType(env_str) + tier = AwsTier( + tier_type=tier_type, + platform_arn=api_env['PlatformArn'], + parsed_version=self._parse_version(api_env['VersionLabel']), + environment_id=api_env['EnvironmentId'], + name=api_env['EnvironmentName'], + num=int(match.group('num')), + ) + if (env_type in envs) and (tier_type in envs[env_type]): + raise InvalidEnvStateException( + f"More than one {tier_type} tier in {env_type} environment was found, which one is the current environment?", + env_type, tier_type + ) + envs[env_type][tier_type] = tier + return envs def _classify_available_versions(self): self.api_versions = sorted( self._c.describe_application_versions( - ApplicationName=self.app + ApplicationName=self.aws_app_name )['ApplicationVersions'], key=lambda i: i['DateUpdated'], ) diff --git a/unit_tests/__init__.py b/unit_tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/unit_tests/test_config_saver.py b/unit_tests/test_config_saver.py new file mode 100644 index 0000000..b22fdf2 --- /dev/null +++ b/unit_tests/test_config_saver.py @@ -0,0 +1,38 @@ +import unittest +from unittest.mock import MagicMock + +from safecast_deploy.aws_state import AwsTierType, EnvType +from safecast_deploy.config_saver import ConfigSaver +from safecast_deploy.result_logger import ResultLogger + + +class TestConfigSaver(unittest.TestCase): + envs_return = { + 'Environments': [ + { + 'EnvironmentName': 'safecasttestapp-dev-021', + 'EnvironmentId': '1248234', + 'ApplicationName': 'testapp', + 'VersionLabel': 'api-unit_test-125-6b69384109c6f3348ccaf4d5e761808f710bd6a9', + 'PlatformArn': 'test-arn', + 'TemplateName': 'test-template', + 'Status': 'Ready', + 'Health': 'Green', + 'HealthStatus': 'Ok', + }, + ], + } + + def test_run_success_single_tier(self): + eb_client = MagicMock() + eb_client.describe_environments = MagicMock(return_value=self.envs_return) + result_logger = ResultLogger(log_git=False) + ConfigSaver(eb_client, result_logger).run( + app='testapp', env=EnvType.DEV, tier=AwsTierType.WEB, + ) + + def test_run_success_unqualified(self): + eb_client = MagicMock() + eb_client.describe_environments = MagicMock(return_value=self.envs_return) + result_logger = ResultLogger(log_git=False) + ConfigSaver(eb_client, result_logger).run(app='testapp') diff --git a/unit_tests/test_new_env.py b/unit_tests/test_new_env.py new file mode 100644 index 0000000..62923f4 --- /dev/null +++ b/unit_tests/test_new_env.py @@ -0,0 +1,60 @@ +import unittest +from unittest.mock import MagicMock + +from safecast_deploy.aws_state import AwsState, AwsTier, AwsTierType, EnvType, ParsedVersion +from safecast_deploy.new_env import NewEnv +from safecast_deploy.result_logger import ResultLogger + + +class TestNewEnv(unittest.TestCase): + def test_run_success(self): + old_aws_state = AwsState( + aws_app_name='api', + envs={ + EnvType.DEV: { + AwsTierType.WEB: + AwsTier( + tier_type=AwsTierType.WEB, + platform_arn='Test ARN', + parsed_version=ParsedVersion( + app='api', + circleci_build_num=123, + clean_branch_name='unit_test', + git_commit='5fcc3edf43a0adf59efb74f84dc2fbc455bedc74', + version_string='api-unit_test-123-5fcc3edf43a0adf59efb74f84dc2fbc455bedc74', + ), + environment_id='98765', + name='unit-test-env-021', + num=21, + ), + }, + }, + ) + new_aws_state = AwsState( + aws_app_name='api', + envs={ + EnvType.DEV: { + AwsTierType.WEB: + AwsTier( + tier_type=AwsTierType.WEB, + platform_arn='Test ARN', + parsed_version=ParsedVersion( + app='api', + circleci_build_num=125, + clean_branch_name='unit_test', + git_commit='6b69384109c6f3348ccaf4d5e761808f710bd6a9', + version_string='api-unit_test-125-6b69384109c6f3348ccaf4d5e761808f710bd6a9', + ), + environment_id=None, + name='unit-test-env-022', + num=22, + ) + } + }, + ) + eb_client = MagicMock() + eb_client.describe_environment_health = MagicMock(return_value={'HealthStatus': 'Ok'}) + result_logger = ResultLogger(log_git=False) + config_saver = MagicMock() + config_saver.run = MagicMock() + NewEnv(EnvType.DEV, old_aws_state, new_aws_state, eb_client, result_logger, config_saver, update_templates=True, update_wait=1).run() diff --git a/unit_tests/test_same_env.py b/unit_tests/test_same_env.py new file mode 100644 index 0000000..a87809d --- /dev/null +++ b/unit_tests/test_same_env.py @@ -0,0 +1,60 @@ +import unittest +from unittest.mock import MagicMock + +import boto3 + +from safecast_deploy.aws_state import AwsState, AwsTier, AwsTierType, EnvType, ParsedVersion +from safecast_deploy.result_logger import ResultLogger +from safecast_deploy.same_env import SameEnv + + +class TestSameEnv(unittest.TestCase): + def test_run_success(self): + old_aws_state = AwsState( + aws_app_name='api', + envs={ + EnvType.DEV: { + AwsTierType.WEB: + AwsTier( + tier_type=AwsTierType.WEB, + platform_arn='Test ARN', + parsed_version=ParsedVersion( + app='api', + circleci_build_num=123, + clean_branch_name='unit_test', + git_commit='5fcc3edf43a0adf59efb74f84dc2fbc455bedc74', + version_string='api-unit_test-123-5fcc3edf43a0adf59efb74f84dc2fbc455bedc74', + ), + environment_id='98765', + name='unit-test-env-021', + num=21, + ) + } + }, + ) + new_aws_state = AwsState( + aws_app_name='api', + envs={ + EnvType.DEV: { + AwsTierType.WEB: + AwsTier( + tier_type=AwsTierType.WEB, + platform_arn='Test ARN', + parsed_version=ParsedVersion( + app='api', + circleci_build_num=125, + clean_branch_name='unit_test', + git_commit='6b69384109c6f3348ccaf4d5e761808f710bd6a9', + version_string='api-unit_test-125-6b69384109c6f3348ccaf4d5e761808f710bd6a9', + ), + environment_id=None, + name='unit-test-env-021', + num=21, + ) + } + }, + ) + eb_client = MagicMock() + eb_client.describe_environment_health = MagicMock(return_value={'HealthStatus': 'Ok'}) + result_logger = ResultLogger(log_git=False) + SameEnv(EnvType.DEV, old_aws_state, new_aws_state, eb_client, result_logger, update_wait=1).run() diff --git a/unit_tests/test_state.py b/unit_tests/test_state.py new file mode 100644 index 0000000..4ac5856 --- /dev/null +++ b/unit_tests/test_state.py @@ -0,0 +1,63 @@ +import datetime +import sys +import unittest +from unittest.mock import MagicMock + +from safecast_deploy.aws_state import AwsTierType, EnvType +from safecast_deploy.state import State + + +class TestState(unittest.TestCase): + envs_return = { + 'Environments': [ + { + 'EnvironmentName': 'safecasttestapp-dev-021', + 'EnvironmentId': '1248234', + 'ApplicationName': 'testapp', + 'VersionLabel': 'api-unit_test-125-6b69384109c6f3348ccaf4d5e761808f710bd6a9', + 'PlatformArn': 'test-arn', + 'TemplateName': 'test-template', + 'Status': 'Ready', + 'Health': 'Green', + 'HealthStatus': 'Ok', + }, + ], + } + + versions_return = { + 'ApplicationVersions': [ + { + 'VersionLabel': 'api-unit_test-126-6b69384109c6f3348ccaf4d5e761808f821bd6a9', + 'DateUpdated': datetime.datetime(2020, 1, 1), + 'Status': 'Unprocessed' + }, + ], + } + + def test_new_version(self): + eb_client = MagicMock() + eb_client.describe_environments = MagicMock(return_value=self.envs_return) + eb_client.describe_application_versions = MagicMock(return_value=self.versions_return) + new_state = State('testapp', eb_client).new_aws_state(EnvType.DEV, new_version='api-unit_test-126-6b69384109c6f3348ccaf4d5e761808f821bd6a9') + self.assertEqual(new_state.envs[EnvType.DEV][AwsTierType.WEB].num, 21) + print(new_state, file=sys.stderr) + + def test_new_arn(self): + eb_client = MagicMock() + eb_client.describe_environments = MagicMock(return_value=self.envs_return) + eb_client.describe_application_versions = MagicMock(return_value=self.versions_return) + new_state = State('testapp', eb_client).new_aws_state(EnvType.DEV, new_arn='new-test-arn') + self.assertEqual(new_state.envs[EnvType.DEV][AwsTierType.WEB].num, 22) + print(new_state, file=sys.stderr) + + def test_new_arn_version(self): + eb_client = MagicMock() + eb_client.describe_environments = MagicMock(return_value=self.envs_return) + eb_client.describe_application_versions = MagicMock(return_value=self.versions_return) + new_state = State('testapp', eb_client).new_aws_state( + EnvType.DEV, + new_version='api-unit_test-126-6b69384109c6f3348ccaf4d5e761808f821bd6a9', + new_arn='new-test-arn' + ) + self.assertEqual(new_state.envs[EnvType.DEV][AwsTierType.WEB].num, 22) + print(new_state, file=sys.stderr)