Odyssey
stack.py
1 """
2 AWS stack provisioning.
3 """
4 
5 import collections
6 import boto3
7 import getpass
8 import humanize
9 import json
10 import os
11 import re
12 import sys
13 import shellish
14 import shlex
15 import time
16 import uuid
17 from pwgen import pwgen
18 from . import s3, base, migrations, cloudformation, cumanage
19 from . import REGISTRIES
20 from . import get_client_oauth_data
21 
22 
23 RDS_CONFIG_BUCKET = 'homecu.odyssey.rds'
24 RDS_CONFIG_PATH_FMT = '%(region)s/%(ecs_cluster)s/%(rds_config)s.json'
25 
26 
28  """ Manage entire odyssey stacks in AWS. """
29 
30  name = 'stack'
31 
32  def setup_args(self, parser):
33  self.add_subcommand(ListCommand, default=True)
34  self.add_subcommand(ShowCommand)
35  self.add_subcommand(MonitorCommand)
36  self.add_subcommand(SetEnvCommand)
37  self.add_subcommand(GetEnvCommand)
38  self.add_subcommand(SetStackStateCommand)
39  self.add_subcommand(ScaleCommand)
40  self.add_subcommand(StartMaintCommand)
41  self.add_subcommand(EndMaintCommand)
42  self.add_subcommand(MigrationCommand)
43  self.add_subcommand(CreateCommand)
44  self.add_subcommand(UpgradeCommand)
45  self.add_subcommand(RemoveCommand)
46 
47 
49  """ List Odyssey stacks used in AWS. """
50 
51  name = 'ls'
52  state_style = {
53  'init': 'blue',
54  'ready': 'green',
55  'active': 'green',
56  'partial_deploy': 'red',
57  'corrupt': 'red',
58  'deploying': 'b',
59  'removing': 'b'
60  }
61 
62  def __init__(self, *args, **kwargs):
63  '''List Command Initialization'''
64  super().__init__(*args, **kwargs)
65  self.terse_headers = [
66  'Stack',
67  'Status',
68  'Version',
69  'URL(s)',
70  'Region',
71  'Last Deploy'
72  ]
73  self.verbose_headers = [
74  'Stack',
75  'Stack Version',
76  'Status',
77  'Version',
78  'Creator',
79  'URL(s)',
80  'Region',
81  'Created',
82  'Last Deploy'
83  ]
84 
85  def setup_args(self, parser):
86  self.table_args_parser = self.add_table_arguments()
87  self.add_argument('--verbose', '-v', action='store_true',
88  help='Enable verbose output')
89 
90  def run(self, args):
91  headers = self.verbose_headers if args.verbose else self.terse_headers
92  table_options = self.table_args_parser(args)
93  with shellish.Table(headers=headers, **table_options) as t:
94  # We get smarter table formatting if we get all the data first.
95  output = []
96  for summary in self.stacks.objects.all():
97  stack = self._get_stack_data(s3.StackTracker(summary))
98  output.append([y for (x, y) in stack.items() if x in headers])
99  t.print(output)
100 
101  def _get_stack_data(self, stack):
102  now = base.localnow()
103  if stack.deploy.get('updated'):
104  delta = now - base.parse_ts(stack.deploy.get('updated'))
105  deployed = '%s ago' % humanize.naturaldelta(delta)
106  else:
107  deployed = ''
108  style = self.state_style.get(stack.state, 'reverse')
109  status = '<%s>%s</%s>' % (style, stack.state, style)
110  if stack.env.get('MAINTENANCE_MODE') == '1':
111  status = '%s (<red>MAINTENANCE</red>)' % status
112  return collections.OrderedDict((
113  ('Stack', stack.name),
114  ('Stack Version', stack.stack_version),
115  ('Status', status),
116  ('Version', stack.version),
117  ('Creator', stack.creator.split('@', 1)[0]),
118  ('URL(s)', ' '.join('https://%s/' % x for x in stack.dns)),
119  ('Region', stack.region),
120  ('Created', humanize.naturaldate(base.parse_ts(stack.created))),
121  ('Last Deploy', deployed)
122  ))
123 
124 
126  """ Show full detail of a stack. """
127 
128  name = 'show'
129  min_version = '0.8.0'
130  use_pager = True
131  allowed_states = {'init', 'ready', 'active', 'partial_deploy', 'corrupt'}
132 
133  def setup_args(self, parser):
134  self.add_stack_argument()
135 
136  def run(self, args):
137  stack = self.get_stack(args.stack)
138  if stack is None:
139  raise SystemExit('Stack not found: %s' % args.stack)
140  self.check_requirements(stack)
141 
142  t = shellish.Table(headers=['Field', 'Value'],
143  title='Stack: %s' % args.stack)
144  t.print((
145  ('Name', stack.name),
146  ('State', stack.state),
147  ('DNS', ', '.join(stack.dns)),
148  ('Stack Version', stack.stack_version),
149  ('Creator', stack.creator),
150  ('Region', stack.region),
151  ('Cluster', stack.cluster),
152  ('Created', base.formatdatetime(base.parse_ts(stack.created))),
153  ('Deploy Count', stack.deploy.get('count', 0)),
154  ))
155  if hasattr(stack, 'db'): # >= 0.7 stacks only
156  t.print_row(('Database', '%s@%s:%d' % (stack.db['name'],
157  stack.db['host'], stack.db['port'])))
158  t.print_footer('')
159  t.close()
160 
161  if stack.deploy:
162  dep = stack.deploy
163  t = shellish.Table(headers=['Field', 'Value'],
164  title='Current Deployment')
165  t.print((
166  ('Updated', base.formatdatetime(base.parse_ts(
167  dep['updated']))),
168  ('Project', dep['project']),
169  ('User/Machine', dep['build_ident']),
170  ('GIT Rev', dep['git_rev']),
171  ('GIT Branch', dep['git_branch']),
172  ('GIT Repo', dep['git_repo']),
173  ('GitHub', '%s/commit/%s' % (
174  dep['git_repo'].rstrip('.git'), dep['git_rev'][:10])),
175  ('Docker Version', dep['docker_version']),
176  ))
177  t.print_footer('')
178  t.close()
179 
180  cfn_stack = cloudformation.describe_stack(stack)
181  services = [x['OutputValue'] for x in cfn_stack['Outputs']
182  if 'Service' in x['OutputKey']]
183 
184  ecs_api = boto3.client('ecs', region_name=stack.region)
185  services = ecs_api.describe_services(
186  cluster=stack.cluster,
187  services=services
188  )['services']
189  t = shellish.Table(headers=[
190  'Service',
191  'Status',
192  'Task Def',
193  'Desired Count',
194  'Pending Count',
195  'Running Count',
196  'Created',
197  'Updated'
198  ], title="Container Services")
199  rows = []
200  for service in services:
201  for deploy in service['deployments']:
202  rows.append([
203  service['serviceName'].rsplit('-')[-2],
204  service['status'],
205  deploy['taskDefinition'].rsplit('/', 1)[-1],
206  deploy['desiredCount'],
207  deploy['pendingCount'],
208  deploy['runningCount'],
209  humanize.naturaldelta(base.localnow() -
210  deploy['createdAt']),
211  humanize.naturaldelta(base.localnow() -
212  deploy['updatedAt']),
213  ])
214  t.print(rows)
215  t.print_footer('')
216  t.close()
217 
218  if stack.env:
219  t = shellish.Table(headers=['Variable', 'Value'],
220  title='Environment')
221  t.print(sorted(stack.env.items()))
222  t.print_footer('')
223  t.close()
224 
225  tbl_config = {
226  "title": 'Change Log',
227  "headers": ['Date', 'Change', 'Operator'],
228  "accessors": [
229  lambda x: base.formatdatetime(base.parse_ts(x['ts'])),
230  'msg',
231  'operator'
232  ]
233  }
234  with shellish.Table(**tbl_config) as t:
235  t.print(reversed(stack.get_changelog()))
236 
237 
239  """ Set the number of application containers to run in the cluster. """
240 
241  name = 'scale'
242  allowed_states = {'active', 'partial_deploy'}
243  min_version = '0.8'
244 
245  def setup_args(self, parser):
246  self.add_argument('container_count', type=int, help='Set the service '
247  '<i>desired</i> count to this value.')
248  self.add_stack_argument()
249 
250  def run(self, args):
251  stack = self.get_stack(args.stack)
252  if stack is None:
253  raise SystemExit('Stack not found: %s' % args.stack)
254  self.check_requirements(stack)
255  stack.check_passphrase()
256  stack.set('app_service_count', args.container_count)
257  stack_cf_params = create_cloudformation_stack_params(stack)
258 
259  cs_result = cloudformation.deploy_changeset(
260  stack,
261  os.environ.get('GIT_REV'),
262  stack_cf_params,
263  )
264 
265  if cs_result:
266  stack._save('Changed container count of `app` to %d' %
267  args.container_count)
268  else:
269  stack._save('Scaling app container failed')
270  stack.update_stack('corrupt')
271 
272 
274 
275  allowed_states = {'ready', 'active', 'partial_deploy'}
276 
277  def setup_args(self, parser):
278  self.add_stack_argument()
279 
280  def prerun(self, args):
281  super().prerun(args)
282  stack = self.get_stack(args.stack)
283  if stack is None:
284  raise SystemExit('Stack not found: %s' % args.stack)
285  self.check_requirements(stack)
286  stack.check_passphrase()
287  self.ecs = boto3.client('ecs', region_name=stack.region)
288  self.stack = stack
289 
290  def task_env_to_dict(self, task_env):
291  """ Convert task definition environment section to a dict. """
292  return dict((x['name'], x['value']) for x in task_env)
293 
294  def dict_to_task_env(self, env):
295  """ Convert task definition environment section to a dict. """
296  return [{
297  "name": key,
298  "value": value
299  } for key, value in env.items()]
300 
301  def get_task_definition(self, ident):
302  """ Get cleaned up task definition suitable for
303  register_task_definition. """
304  family = 'odyssey-%s-%s' % (self.stack.name, ident)
305  result = self.ecs.describe_task_definition(taskDefinition=family)
306  fulldef = result['taskDefinition']
307  whitelist = (
308  "family",
309  "networkMode",
310  "containerDefinitions",
311  "volumes",
312  "placementConstraints"
313  )
314  return dict((key, value) for key, value in fulldef.items()
315  if key in whitelist)
316 
318  """ Update app service by pointing it to the latest task def. """
319  common_name = 'odyssey-%s-app' % self.stack.name
320  self.ecs.update_service(cluster=self.stack.cluster,
321  service=common_name,
322  taskDefinition=common_name)
323 
324  def adj_sched_service_count(self, count):
325  sched_service = 'odyssey-%s-sched' % self.stack.name
326  self.ecs.update_service(cluster=self.stack.cluster,
327  service=sched_service, desiredCount=count)
328 
329  def taskdef_env_override(self, taskdef, key, value=None):
330  """ Patch the ENV for all containers in the app task def. """
331  for cdef in taskdef['containerDefinitions']:
332  env = self.task_env_to_dict(cdef['environment'])
333  if value is None:
334  try:
335  del env['MAINTENANCE_MODE']
336  except KeyError:
337  pass
338  else:
339  env['MAINTENANCE_MODE'] = value
340  cdef['environment'] = self.dict_to_task_env(env)
341 
342 
344  """ Put the stack into maintenance mode.
345 
346  <i>Note, the full meaning and extent of maintenance mode is not covered
347  here.</i>
348 
349  <b>Action Summary:</b>
350  1. Disable public access to web container.
351  2. Shutdown scheduler service.
352  """
353 
354  name = 'start-maint'
355  max_version = '0.7.1'
356 
357  def run(self, args):
358  if self.stack.env.get('MAINTENANCE_MODE') == '1':
359  raise SystemExit("Already in maintenance mode")
360  # Prevent new deploys from reactivating the stack by saving the maint
361  # mode in the stacks ENV config.
362  self.stack.env['MAINTENANCE_MODE'] = '1'
363  self.stack._save("Starting Maintenance")
365  appdef = self.get_task_definition('app')
366  self.taskdef_env_override(appdef, 'MAINTENANCE_MODE', '1')
367  self.ecs.register_task_definition(**appdef)
368  self.restart_app_service()
369 
370 
372  """ Resume normal operation of stack currently in maintenance mode. """
373 
374  name = 'end-maint'
375  max_version = '0.7.1'
376 
377  def setup_args(self, parser):
378  self.add_stack_argument()
379 
380  def run(self, args):
381  if self.stack.env.get('MAINTENANCE_MODE') != '1':
382  raise SystemExit("Maintenance mode is not active")
383  del self.stack.env['MAINTENANCE_MODE']
384  self.stack._save("Ending Maintenance")
386  appdef = self.get_task_definition('app')
387  self.taskdef_env_override(appdef, 'MAINTENANCE_MODE')
388  self.ecs.register_task_definition(**appdef)
389  self.restart_app_service()
390 
391 
393  """Migrate stack versions
394 
395  Usage: hosting stack migrate {stack-name} --to {target-version}
396 
397  All parameters are required.
398 
399  Backwards migration isn't necessarily supported, however, it's not
400  expressly prohibited either. Such a migration would simply be a
401  reversed expression.
402 
403  """
404 
405  name = 'migrate'
406  min_version = '0.8.0'
407 
408  def setup_args(self, parser):
409  self.add_stack_argument()
410  self.add_argument('--passphrase', env='ODYSSEY_STACK_PASSPHRASE',
411  help='To avoid being prompted for a passphrase on '
412  'protected stacks, you can set it on the command '
413  'line or via the env.')
414  self.add_argument('stack_version', help='Target version of migration')
415  self.add_argument('--verbose', '-v', action='store_true',
416  help='Show verbose output')
417 
418  def confirm_action(self, message):
419  confirm = input(message)
420  if confirm != 'yes':
421  raise SystemExit('Aborted')
422 
423  def run(self, args):
424  stack = self.get_stack(args.stack)
425  if stack is None:
426  raise SystemExit('Stack not found: %s' % args.stack)
427  passphrase = stack.check_passphrase(args.passphrase)
428  self.confirm_action('Are you sure you want to migrate stack'
429  ' from %s to %s'
430  ' [yes|NO]: ' % (
431  stack.stack_version, args.stack_version))
432  migrations.migrate_stack(
433  stack,
434  stack.stack_version,
435  args.stack_version,
436  verbose=args.verbose,
437  passphrase=passphrase,
438  )
439 
440 
442  """ Monitor a stack's event log. """
443 
444  name = 'monitor'
445  min_version = '0.8.0'
446  allowed_states = {'init', 'ready', 'active', 'corrupt', 'partial_deploy'}
447 
448  def setup_args(self, parser):
449  self.add_stack_argument()
450 
451  def run(self, args):
452  stack = self.get_stack(args.stack)
453  if stack is None:
454  raise SystemExit('Stack not found: %s' % args.stack)
455  if not stack.deploy:
456  raise SystemExit('No deployments for: %s' % args.stack)
457  self.check_requirements(stack)
458  ecs_api = boto3.client('ecs', region_name=stack.region)
459  seen = set()
460  cfn_stack = cloudformation.describe_stack(stack)
461  service_keys = [x['OutputValue'] for x in cfn_stack['Outputs']
462  if 'Service' in x['OutputKey']]
463  while True:
464  new_events = []
465  services = ecs_api.describe_services(
466  cluster=stack.cluster,
467  services=service_keys,
468  )['services']
469  for service in services:
470  for ev in service['events']:
471  if ev['id'] in seen:
472  continue
473  else:
474  new_events.append((ev['createdAt'], ev['message']))
475  seen.add(ev['id'])
476  new_events.sort()
477  for x in new_events:
478  dt = x[0].strftime('%I:%M %p %Z')
479  shellish.vtmlprint("[<cyan>%s</cyan>] %s" % (dt, x[1]))
480  time.sleep(1)
481 
482 
484  """ Set or clear an env variable in a stack configuration.
485 
486  Note that the env var won't take effect until the next deploy. """
487 
488  name = 'setenv'
489  allowed_states = {'ready', 'active', 'corrupt', 'partial_deploy'}
490 
491  def setup_args(self, parser):
492  self.add_stack_argument()
493  self.add_argument('key', help='Env key. Eg. `MY_ENV_VAR`')
494  self.add_argument('value', nargs='?', help='Env value to set. Omit to '
495  'clear an existing value.')
496 
497  def run(self, args):
498  stack = self.get_stack(args.stack)
499  if stack is None:
500  raise SystemExit('Stack not found: %s' % args.stack)
501  self.check_requirements(stack)
502  if args.value is None:
503  if args.key not in stack.env:
504  raise SystemExit("Not Found: %s" % args.key)
505  print("WARNING: Clearing env var:", args.key)
506  del stack.env[args.key]
507  else:
508  if not args.value:
509  print("WARNING: Setting empty env:", args.key)
510  stack.env[args.key] = args.value
511  stack.save('Set ENV variable: %s' % args.key)
512 
513 
515  """ Get the value of an env variable from a stack configuration. """
516 
517  name = 'getenv'
518  allowed_states = {'ready', 'active', 'corrupt', 'partial_deploy'}
519 
520  def setup_args(self, parser):
521  self.add_stack_argument()
522  self.add_argument('key', nargs='?', help='Env key. Eg. `MY_ENV_VAR`')
523 
524  def run(self, args):
525  stack = self.get_stack(args.stack)
526  if stack is None:
527  raise SystemExit('Stack not found: %s' % args.stack)
528  self.check_requirements(stack)
529  if args.key and args.key not in stack.env:
530  raise SystemExit("Not Found: %s" % args.key)
531  keys = [args.key] if args.key else stack.env.keys()
532  for key in keys:
533  print('%s=%s' % (key, shlex.quote(stack.env[key])))
534 
535 
537  '''Override stack status'''
538 
539  name = 'set-state'
540  allowed_states = {'init', 'ready', 'deploying', 'partial_deploy', 'corrupt'}
541 
542  def setup_args(self, parser):
543  self.add_stack_argument()
544  self.add_argument('state', help='State to set the stack to')
545  self.add_argument('--passphrase', env='ODYSSEY_STACK_PASSPHRASE',
546  help='To avoid being prompted for a passphrase on '
547  'protected stacks, you can set it on the command '
548  'line or via the env.')
549 
550  def run(self, args):
551  stack = self.get_stack(args.stack)
552  new_state = args.state
553  if stack is None:
554  raise SystemExit('Stack not found: %s' % args.stack)
555  if new_state not in ['active', 'test']:
556  raise SystemExit('Not a valid new state')
557  stack.check_passphrase(args.passphrase)
558  self.check_requirements(stack)
559 
560  old_state = stack.state
561 
562  if new_state == old_state:
563  raise SystemExit("Stack already at desired status")
564 
565  stack.update_state(new_state)
566  stack._save('Updated state from %s to %s by %s' % (
567  old_state,
568  new_state,
569  os.environ['OPERATOR_IDENT']))
570 
571 
573  """ Create a new Odyssey stack in AWS <b>[use caution]</b>. """
574 
575  name = 'create'
576  dns_compat = re.compile("(?!-)[A-Z\d-]{1,63}(?<!-)$", re.IGNORECASE)
577 
578  def setup_args(self, parser):
579  self.add_stack_argument()
580  self.add_argument('--verbose', '-v',
581  action='store_true', default=False)
582  self.add_argument('--region', default='us-east-2',
583  help='Region to host the stack.')
584  self.add_argument('--elb-security-group',
585  default='homecu-only-homer-elb-sg',
586  help='ELB security group name.')
587  self.add_argument('--eni-security-group', default='homer-ecs-sg',
588  help='ENI security group name.')
589  self.add_argument('--elb-int-security-group',
590  default='homer-int-elb-sg',
591  help='Internal ELB security group name. '
592  'This security group is used for internal '
593  'services of the stack, e.g., scheduler.')
594  self.add_argument('--pub-subnet-prefix', default='homer-public',
595  help='Naming prefix scheme for public subnets.')
596  self.add_argument('--priv-subnet-prefix', default='homer-private',
597  help='Naming prefix scheme for private subnets.')
598  self.add_argument('--domain', default='homecu.io', help='Default '
599  'domain used for cert selection and DNS.')
600  self.add_argument('--hostnames', nargs='+', help='Override the '
601  'hostname(s) registered for this stack. Use "" for '
602  'a domain-only DNS entry. By default the stack name '
603  'is used.')
604  self.add_argument('--https-cert-hostname', default='*',
605  help='ACM certificate hostname for HTTPS. Note, '
606  'value is combined with `--domain`.')
607  self.add_argument('--ecs-cluster', default='homer',
608  help='ECS cluster to provision services on.')
609  self.add_argument('--rds-config', default='default',
610  help='Name of config file in S3 used for RDS '
611  'configuration. The S3 URL is formed from the '
612  'triplet (region, ecs-cluster, rds-config). E.g. '
613  '`s3://homecu.odyssey.rds/us-west-2/default/'
614  'default.json`.')
615  self.add_argument('--env', nargs='+', metavar="KEY=VALUE",
616  help='Supplemental ECS task-definition environment '
617  'key=value pair(s) for this deploy. Note this is '
618  'applied to all task definitions for a stack.')
619  self.add_argument('--odyssey-version', default='latest',
620  help='Odyssey Tag or version to deploy')
621  self.add_argument('--environment', default='dev',
622  help=('Environment tag for stack, e.g., dev or prod'
623  '*Notice* This is not to be confused with the'
624  '--env flag'))
625  self.add_argument('--environment_name', default='homer',
626  help='Environment Name for the stack, e.g., homer')
627 
628  def check_name(self, name):
629  """ Raise exception if stack name is not valid. """
630  if len(name) > 12:
631  raise SystemExit('Name too long')
632  if not self.dns_compat.match(name):
633  raise SystemExit('Name is not DNS compatible')
634 
635  def create_passphrase(self):
636  while True:
637  first = getpass.getpass('Enter optional lock passphrase: ')
638  second = getpass.getpass('Re-enter optional lock passphrase: ')
639  if first != second:
640  shellish.vtmlprint("<b><red>Passphrases do not match")
641  continue
642  if not first:
643  shellish.vtmlprint("<b>WARNING: No passphrase will be "
644  "required for this stack.")
645  return first or None
646 
647  def run(self, args):
648  self.check_name(args.stack)
649  if self.get_stack(args.stack) is not None:
650  raise SystemExit('Stack already exists: %s' % args.stack)
651  if args.hostnames is None:
652  args.hostnames = [args.stack]
653  stack = s3.StackTracker(self.stacks.Object(args.stack))
654  passphrase = self.create_passphrase()
655  phash = passphrase and stack.gen_passphrase_hash(passphrase)
656 
657  stack.set('_passphrase_hash', phash or '', meta=True)
658  stack.set("stack_version", base.VERSION)
659  stack.set("uuid", str(uuid.uuid4()))
660  stack.set("state", 'init')
661  stack.set("creator", os.environ['OPERATOR_IDENT'])
662  stack.set("region", args.region)
663  stack.set("cluster", args.ecs_cluster)
664  stack.set("created", base.localnow().isoformat())
665  stack.set("name", args.stack)
666  stack.set("dns", ['%s.%s' % (x, args.domain) for x in args.hostnames])
667  env = dict(x.split('=') for x in args.env) if args.env else {}
668  stack.set("env", env)
669  stack.set("app_service_count", 1)
670  stack.set("deploy", {})
671  stack.set("changelog", [])
672  stack.set("aws", {})
673  stack.set("version", args.odyssey_version)
674  stack.set('environment', args.environment)
675  stack.set('environment_name', args.environment_name)
676  stack._save('Created Stack')
677 
679  stack,
680  region=args.region,
681  ecs_cluster=args.ecs_cluster,
682  elb_security_group=args.elb_security_group,
683  elb_internal_security_group=args.elb_int_security_group,
684  eni_security_group=args.eni_security_group,
685  rds_config=args.rds_config,
686  pub_subnet_prefix=args.pub_subnet_prefix,
687  priv_subnet_prefix=args.priv_subnet_prefix,
688  https_cert_hostname=args.https_cert_hostname,
689  domain=args.domain,
690  hostnames=args.hostnames,
691  verbose=args.verbose,
692  )
693 
694 
696  """Set the stack version and deploy CFN template"""
697 
698  name = 'upgrade'
699  min_version = '0.8.3'
700  allowed_states = {'ready', 'active', 'partial_deploy'}
701 
702  def setup_args(self, parser):
703  self.add_stack_argument()
704  self.add_argument('--verbose', '-v', action='store_true')
705  self.add_argument('--passphrase', env='ODYSSEY_STACK_PASSPHRASE',
706  help='To avoid being prompted for a passphrase on '
707  'protected stack, you can set it on the command '
708  'line or via the env.')
709  self.add_argument('new_version', action='store',
710  help='Version or tag to deploy')
711  self.add_argument('--run-db-migrations', action='store_true',
712  help='Run database migrations after upgrade. '
713  'Notice, this implies `--check-exit-status`.')
714  self.add_argument('--check-exit-status', action='store_true',
715  help='Wait and report exit status of update')
716 
717  def run(self, args):
718  stack = self.get_stack(args.stack)
719 
720  if stack is None:
721  raise SystemExit("Stack not found: %s" % args.stack)
722  passphrase = stack.check_passphrase(args.passphrase)
723  self.check_requirements(stack)
724  stack.update_state('deploying')
725  stack.set('version', args.new_version)
726  git_sha = os.environ['GIT_REV']
727  upgrade_cfn_stack(
728  stack,
729  git_sha,
730  passphrase,
731  verbose=args.verbose,
732  wait_until_complete=args.check_exit_status,
733  run_db_migrations=args.run_db_migrations,
734  )
735 
736 
738  """ Remove an Odyssey stack from AWS <b>[use caution]</b>. """
739 
740  name = 'rm'
741  min_version = "0.8.0"
742  allowed_states = {'init', 'ready', 'active', 'partial_deploy'}
743 
744  def setup_args(self, parser):
745  self.add_stack_argument()
746  self.add_argument('--force', '-f', action='store_true',
747  help='Force removal without confirmation.')
748  self.add_argument('--best-effort', action='store_true',
749  help='Attempt to remove corrupt stacks.')
750  self.add_argument('--drop-database', action='store_true',
751  default=False,
752  help='Drops the database and user. Use CAUTION, '
753  'This operation cannot be undone!')
754  self.add_argument('--check-exit-status', action='store_true',
755  help='Wait for completion of remove action')
756 
757  def confirm_action(self, message):
758  confirm = input(message)
759  if confirm != 'yes':
760  raise SystemExit('Aborted')
761 
762  def run(self, args):
763  stack = self.get_stack(args.stack)
764  if stack is None:
765  raise SystemExit('Stack not found: %s' % args.stack)
766  passphrase = stack.check_passphrase()
767  if args.best_effort:
768  print("WARNING: Skipping requirements check!")
769  else:
770  self.check_requirements(stack)
771  if not args.force:
772  self.confirm_action("Confirm removal of `%s` stack: [yes|NO] " %
773  args.stack)
774  if args.drop_database:
775  self.confirm_action("Dropping database as part of removal. "
776  "Are you sure? [yes|NO] ")
777 
778  wait_until_complete = args.check_exit_status
779 
780  stack.update_state('removing')
781  try:
782  cloudformation.delete_stack(
783  stack,
784  wait_until_complete=wait_until_complete)
785  if args.drop_database and stack.db:
786  print('Deleting database...')
787  self.remove_db(stack.db.get('name', None),
788  stack.region,
789  stack.cluster,
790  stack.db.get('rds_config', None))
791  except Exception as e:
792  if args.best_effort:
793  shellish.vtmlprint("<red><b>IGNORING ERROR: %s" % e)
794  else:
795  shellish.vtmlprint("<red><b>CRITICAL ERROR: %s" % e)
796  stack.update_state('corrupt')
797  raise e from None
798  else:
799  stack.delete(passphrase)
800  print('Done.')
801 
802  def remove_db(self, db_ident, region, cluster, rds_config=None):
803  if not db_ident:
804  raise ValueError('No database identity was provided, aborting!')
805  if not rds_config:
806  rds_config = 'default'
807  db_config = get_rds_config(region, cluster, rds_config)
808  sql = (
809  'DROP DATABASE IF EXISTS %s;' % db_ident,
810  'DROP USER IF EXISTS %s;' % db_ident,
811  )
812  submit_rsql_job(sql, db_config, region, cluster)
813 
814 
816  region,
817  ecs_cluster,
818  elb_security_group,
819  elb_internal_security_group,
820  eni_security_group,
821  rds_config,
822  pub_subnet_prefix,
823  priv_subnet_prefix,
824  https_cert_hostname,
825  domain,
826  hostnames,
827  **kwargs):
828  '''Create required AWS resources for given stack'''
829  verbose = kwargs.get('verbose', False)
830  db_config = get_rds_config(region,
831  ecs_cluster,
832  rds_config)
833 
834  def get_vpc_subnets(vpc, prefix):
835  def name_tag(tags):
836  return [x['Value'] for x in tags
837  if x['Key'] == 'Name'][0]
838  return [x for x in vpc.subnets.all()
839  if name_tag(x.tags).startswith(prefix)]
840 
841  def get_sg(name, region):
842  ec2_api = boto3.client('ec2', region_name=region)
843  return ec2_api.describe_security_groups(Filters=[{
844  'Name': 'group-name',
845  'Values': [name]
846  }])['SecurityGroups'][0]
847 
848  def get_https_cert(name, region):
849  acm_api = boto3.client('acm', region_name=region)
850  certs = acm_api.list_certificates()['CertificateSummaryList']
851  for x in certs:
852  if x['DomainName'] == name:
853  return x
854 
855  def get_hosted_zone_id(domain):
856  r53_api = boto3.client('route53')
857  response = r53_api.list_hosted_zones_by_name(
858  DNSName=domain,
859  MaxItems='1'
860  )
861  if len(response.get('HostedZones', [])) == 0:
862  raise SystemExit("Unable to find hosted zone by name")
863  return response['HostedZones'][0]['Id']
864 
865  elb_sg = get_sg(elb_security_group, region)
866  elb_int_sg = get_sg(elb_internal_security_group, region)
867  eni_sg = get_sg(eni_security_group, region)
868  ec2_res = boto3.resource('ec2', region_name=region)
869  vpc = ec2_res.Vpc(elb_sg['VpcId'])
870  elb_subnets = get_vpc_subnets(vpc, pub_subnet_prefix)
871  if not elb_subnets:
872  raise SystemExit("No usable subnets found")
873  eni_subnets = get_vpc_subnets(vpc, priv_subnet_prefix)
874  if not eni_subnets:
875  raise SystemExit("No usable private subnets found")
876 
877  cert_name = '%s.%s' % (https_cert_hostname, domain)
878  cert = get_https_cert(cert_name, region)
879 
880  hosted_zone_id = get_hosted_zone_id(domain)
881 
882  if cert is None:
883  raise SystemExit("No HTTPS cert found for: %s" % cert_name)
884 
885  stack.set("aws", {
886  'vpc_id': vpc.vpc_id,
887  'certificate_arn': cert['CertificateArn'],
888  'elb_security_groups': [elb_sg['GroupId']],
889  'elb_internal_security_groups': [elb_int_sg['GroupId']],
890  'eni_security_groups': [eni_sg['GroupId']],
891  'private_subnet_ids': [x.id for x in eni_subnets],
892  'subnet_ids': [x.id for x in elb_subnets],
893  'hosted_zone_id': hosted_zone_id,
894  })
895 
896  if kwargs.get('create_database', True):
897  db_creds = create_db(stack, db_config)
898  stack.set("db", {
899  "host": db_config['host'],
900  "port": db_config['port'],
901  "name": db_creds['db'],
902  "user": db_creds['user'],
903  "password": db_creds['password'],
904  "rds_config": rds_config,
905  })
906 
907  stack_cf_params = create_cloudformation_stack_params(stack, **kwargs)
908 
909  stack_cf_id = cloudformation.create_cloudformation_stack(
910  stack,
911  stack_cf_params,
912  verbose=verbose
913  )
914  if stack_cf_id is None:
915  raise SystemExit("Unable to create Cloudformation Stack for Stack")
916 
917  print("Created Cloudformation Stack: %s" % stack_cf_id)
918 
919  stack.deploy = _deploy_dictionary_(stack)
920 
921  stack.update_state('active')
922 
923 
924 def upgrade_cfn_stack(stack, git_sha, passphrase, **kwargs):
925  def deploy(stack, git_sha, **kwargs):
926  '''perform stack deployment'''
927  stack_cf_params = create_cloudformation_stack_params(stack, **kwargs)
928 
929  return cloudformation.deploy_changeset(
930  stack,
931  git_sha,
932  stack_cf_params,
933  **kwargs)
934  try:
935  run_db_migrations = kwargs.get('run_db_migrations', False)
936  wait_until_complete = (kwargs.get('wait_until_complete', False) or
937  run_db_migrations)
938  verbose = kwargs.get('verbose', False)
939  kws = {k: v for k, v in kwargs.items()
940  if k not in ['verbose',
941  'wait_until_complete']}
942  deploy_status = deploy(
943  stack,
944  git_sha,
945  verbose=verbose,
946  wait_until_complete=wait_until_complete,
947  **kws,
948  )
949  if deploy_status:
950  stack.deploy = _deploy_dictionary_(stack)
951  stack.update_state('active')
952  stack._save('Deployed %s from %s' % (
953  git_sha, os.environ['PROJECT_NAME']))
954  if run_db_migrations:
955  if verbose:
956  print('Submitting database migrations...')
957  cumanage.run_cumanage_task(stack,
958  ['db', 'migrate'],
959  wait_until_complete=True,
960  passphrase=passphrase)
961  else:
962  stack.update_state('partial_deploy')
963  stack._save('Deploy failure of %s from %s' % (
964  git_sha, os.environ['PROJECT_NAME']))
965  except Exception as ex:
966  if verbose:
967  print(ex, file=sys.stderr)
968  stack.update_state('partial_deploy')
969  stack._save('Deploy failure of %s from %s' % (
970  git_sha, os.environ['PROJECT_NAME']))
971  raise SystemExit("Failed to deploy stack")
972 
973 
974 def get_rds_config(region, cluster, rds_config):
975  s3 = boto3.resource('s3')
976  bucket = s3.Bucket(RDS_CONFIG_BUCKET)
977  config_obj = bucket.Object(
978  RDS_CONFIG_PATH_FMT % {'region': region,
979  'ecs_cluster': cluster,
980  'rds_config': rds_config})
981  data = config_obj.get()
982  return json.loads(data['Body'].read().decode())
983 
984 
985 def create_db(stack, db_config):
986  stack_name = re.sub('[^a-z0-9]', '', stack.name.lower())
987  user_uuid = str(stack.uuid).replace('-', '_')
988  ident = 'odyssey_%s_%s' % (stack_name, user_uuid)
989  password = pwgen(50)
990  sql = (
991  "CREATE USER %s WITH PASSWORD '%s';" % (ident, password),
992  "CREATE DATABASE %s;" % ident,
993  "REVOKE CONNECT ON DATABASE %s FROM PUBLIC;" % ident,
994  "GRANT ALL ON DATABASE %s TO %s;" % (ident, ident)
995  )
996  print('Creating RDS USER:', ident)
997  print('Creating RDS DB:', ident)
998  resp = submit_rsql_job(sql, db_config, stack.region, stack.cluster)
999 
1000  assert not resp['failures'], resp['failures']
1001  return {
1002  "db": ident,
1003  "user": ident,
1004  "password": password
1005  }
1006 
1007 
1008 def submit_rsql_job(sql, db_config, region, cluster, operator=None):
1009  if not operator:
1010  operator = os.environ['OPERATOR_IDENT']
1011  client = boto3.client('ecs', region_name=region)
1012  resp = client.run_task(**{
1013  "cluster": cluster,
1014  "taskDefinition": 'rds-psql',
1015  "startedBy": operator,
1016  "overrides": {
1017  "containerOverrides": [{
1018  "environment": [
1019  {
1020  "name": "PGHOST",
1021  "value": db_config['host']
1022  },
1023  {
1024  "name": "PGPORT",
1025  "value": str(db_config['port'])
1026  },
1027  {
1028  "name": "PGPASSWORD",
1029  "value": db_config['password']
1030  },
1031  {
1032  "name": "PGUSER",
1033  "value": db_config['user']
1034  },
1035  ],
1036  "name": 'psql',
1037  "command": [' && '.join('psql -c "%s"' % x for x in sql)]
1038  }]
1039  }
1040  })
1041  assert not resp['failures'], resp['failures']
1042  return resp
1043 
1044 
1046  '''Create necessary Params dictionary for cloudformation'''
1047  registry = REGISTRIES[stack.region]
1048  oauth_client = get_client_oauth_data(stack.dns[0])
1049  return {
1050  'VpcId': stack.aws['vpc_id'],
1051  'ECSCluster': stack.cluster,
1052  'StackName': stack.name,
1053  'StackVersion': stack.version,
1054  'StackDomain': stack.dns[0],
1055  'ContainerRegistry': registry,
1056  'StackDatabaseHost': stack.db['host'],
1057  'StackDatabasePort': str(stack.db['port']),
1058  'StackDatabaseName': stack.db['name'],
1059  'StackDatabaseUsername': stack.db['user'],
1060  'StackDatabasePassword': stack.db['password'],
1061  'Oauth2ClientId': oauth_client[0],
1062  'Oauth2ClientSecret': oauth_client[1],
1063  'AppServiceDesiredCount': str(stack.app_service_count),
1064  'CertificateArn': stack.aws['certificate_arn'],
1065  'HostedZoneId': stack.aws['hosted_zone_id'],
1066  'ELBSecurityGroupsIds': ','.join(stack.aws['elb_security_groups']),
1067  'InternalELBSecurityGroupIds': ','.join(
1068  stack.aws['elb_internal_security_groups']),
1069  'ENISecurityGroupIds': ','.join(stack.aws['eni_security_groups']),
1070  'PrivateSubnetIds': ','.join(stack.aws['private_subnet_ids']),
1071  'SubnetIds': ','.join(stack.aws['subnet_ids']),
1072  'IsMigration': kwargs.get('IsMigration', 'False'),
1073  'Environment': stack.environment,
1074  'EnvironmentName': stack.environment_name,
1075  }
1076 
1077 
1078 def _deploy_dictionary_(stack):
1079  count = stack.deploy.get('count', 0)
1080  return {
1081  'count': count + 1,
1082  'updated': base.localnow().isoformat(),
1083  'project': os.environ['PROJECT_NAME'],
1084  'build_ident': os.environ['OPERATOR_IDENT'],
1085  'git_rev': os.environ['GIT_REV'],
1086  'git_branch': os.environ['GIT_BRANCH'],
1087  'git_repo': os.environ['GIT_REPO'],
1088  'docker_version': os.environ['DOCKER_VERSION'],
1089  }
def confirm_action(self, message)
Definition: stack.py:418
def confirm_action(self, message)
Definition: stack.py:757
def get_task_definition(self, ident)
Definition: stack.py:301
def create_cloudformation_stack_params(stack, **kwargs)
Definition: stack.py:1045
def _get_stack_data(self, stack)
Definition: stack.py:101
def remove_db(self, db_ident, region, cluster, rds_config=None)
Definition: stack.py:802
dictionary state_style
Definition: stack.py:52
def check_name(self, name)
Definition: stack.py:628
def task_env_to_dict(self, task_env)
Definition: stack.py:290
def dict_to_task_env(self, env)
Definition: stack.py:294
def restart_app_service(self)
Definition: stack.py:317
def taskdef_env_override(self, taskdef, key, value=None)
Definition: stack.py:329
def adj_sched_service_count(self, count)
Definition: stack.py:324
def create_stack_aws_resources(stack, region, ecs_cluster, elb_security_group, elb_internal_security_group, eni_security_group, rds_config, pub_subnet_prefix, priv_subnet_prefix, https_cert_hostname, domain, hostnames, **kwargs)
Definition: stack.py:815
def get_client_oauth_data(domain)
Definition: __init__.py:45
def add_stack_argument(self, *args, env=DEFAULT_STACK_ENV, help=None, metavar='STACK_NAME', **kwargs)
Definition: base.py:56
def get_stack(self, name)
Definition: s3.py:136
def check_requirements(self, stack)
Definition: base.py:65
def create_passphrase(self)
Definition: stack.py:635
def __init__(self, *args, **kwargs)
Definition: stack.py:62