diff --git a/deployments/aws/bin/veraison b/deployments/aws/bin/veraison index 5045c088..a5d7898e 100755 --- a/deployments/aws/bin/veraison +++ b/deployments/aws/bin/veraison @@ -5,37 +5,46 @@ import argparse import asyncio import getpass import inspect -import io import logging import os import pprint +import random import re import shutil import socket import stat +import string import sys -import tarfile import time from asyncio.subprocess import Process, PIPE from copy import copy +from datetime import datetime, timedelta, timezone from urllib.parse import urlparse -import ar import boto3 import fabric import requests import xdg.BaseDirectory import yaml from botocore.exceptions import ClientError +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.x509.oid import NameOID +from envsubst import envsubst from sqlitedict import SqliteDict + COLOR_DARK_GREY = '\x1b[38;5;245m' +COLOR_MEDIUM_GREY = '\x1b[38;5;250m' COLOR_GREY = '\x1b[38;20m' COLOR_GREEN = '\x1b[38;5;2m' COLOR_YELLOW = '\x1b[33;20m' COLOR_RED = '\x1b[31;20m' COLOR_BOLD_RED = '\x1b[31;1m' COLOR_RESET = '\x1b[0m' +']]]]]]]]' # "close" the ['s above to fix nvim's auto-indent + class Aws: @@ -68,6 +77,11 @@ class Aws: self.close() +def randomword(n=32): + alphabet = string.ascii_letters + string.digits + return ''.join(random.choice(alphabet) for _ in range(n)) + + def get_public_ip_address(): resp = requests.get('http://ifconfig.me') if resp.status_code != 200: @@ -125,7 +139,7 @@ def revoke_security_group_rules_by_tag(aws, group_id, tag): ) -def update_dynamic_address_rules(aws, deployment_name, tag, ports): +def command_update_dynamic_address_rules(cmd, deployment_name, tag, ports): resp = aws.ec2.describe_security_groups( Filters=[{ 'Name': 'tag:veraison-deployment', @@ -133,35 +147,52 @@ def update_dynamic_address_rules(aws, deployment_name, tag, ports): }] ) + group_ids = [sgr['GroupId'] for sgr in resp['SecurityGroups']] if not group_ids: + cmd.logger.info(f'no groups found for deployment {deployment_name}') return my_addr = get_public_ip_address() - for group_id in group_ids: - revoke_security_group_rules_by_tag(aws, group_id, tag) + revoke_security_group_rules_by_tag(cmd.aws, group_id, tag) authorize_ports_for_address( - aws, group_id, my_addr, ports, tag, + cmd.aws, group_id, my_addr, ports, tag, deployment_tags=[{'Key': 'veraison-deployment', 'Value': deployment_name}]) -def get_deployment_info(aws, deployment_name): - resp = aws.cf.describe_stack_resources(StackName=deployment_name) - stack_resources = {sr['LogicalResourceId']: sr for sr in resp['StackResources']} +def get_stack_instances_info(aws, stack_name): + resp = aws.cf.describe_stack_resources(StackName=stack_name) - instance_id = stack_resources['VeraisonInstance']['PhysicalResourceId'] - resp = aws.ec2.describe_instances(InstanceIds=[instance_id]) - instance = resp['Reservations'][0]['Instances'][0] - pub_iface = instance['NetworkInterfaces'][0]['Association'] + info = {} + for resource in resp['StackResources']: + if resource['ResourceType'] != 'AWS::EC2::Instance': + continue + instance_id = resource['PhysicalResourceId'] + resp = aws.ec2.describe_instances(InstanceIds=[instance_id]) + instance = resp['Reservations'][0]['Instances'][0] - return { - 'instance': { - 'id': instance_id, - 'dns_name': pub_iface["PublicDnsName"], - 'ip_address': pub_iface["PublicIp"], - }, - } + instance_name = None + fallback_name = instance_id + for tag in instance['Tags']: + if tag['Key'] == 'deployment-instance-name': + instance_name = tag['Value'] + break + elif tag['Key'] == 'Name': + fallback_name = tag['Value'] + + if not instance_name: + instance_name = fallback_name + + pub_iface = instance['NetworkInterfaces'][0]['Association'] + + info[instance_name] = { + 'id': instance_id, + 'dns_name': pub_iface["PublicDnsName"], + 'ip_address': pub_iface["PublicIp"], + } + + return info def get_ami_id(aws, name): @@ -210,82 +241,131 @@ async def _run_in_shell_teed(cmd, logger): return rc, ''.join(stdout_buf), ''.join(stderr_buf) -def command_get_vpc_id(cmd, args): - if args.vpc_id: - cmd.logger.debug(f'writing {args.vpc_id} to cache') - cmd.cache['vpc_id'] = args.vpc_id - return args.vpc_id - - vpc_id = cmd.cache.get('vpc_id') - if vpc_id: - cmd.logger.debug(f'using VPC ID from cache: {vpc_id}') - return vpc_id - - cmd.logger.debug('no VPC ID specified; trying to identify from account...') - resp = cmd.aws.ec2.describe_vpcs( - Filters=[{ - 'Name': 'state', - 'Values': ['available'], - }] +def command_get_config(cmd, name): + try: + return cmd.cache[name] + except KeyError: + cmd.fail(f'could not get {name} from cache; has configure command been run?') + + +def command_create_image(cmd, args, name, packer_params=None): + if not shutil.which('packer'): + cmd.fail('packer must be installed on the system') + + if not os.path.isfile(args.template): + cmd.fail(f'template {args.template} does not exist') + + full_name = f'{args.deployment_name}-{name}' + cmd.logger.info(f'creating image: {full_name}...') + + cmd.logger.debug('checking for existing AMI with that name...') + existing_id = get_ami_id(cmd.aws, full_name) + if existing_id: + if not args.force: + cmd.fail(f'image {full_name} already exits (use -f to overwrite)') + cmd.logger.info('removing existing image...') + cmd.aws.ec2.deregister_image(ImageId=existing_id) + + cmd.logger.info('building using packer...') + vpc_id = command_get_config(cmd, 'vpc-id') + subnet_id = command_get_config(cmd, 'subnet-id') + region = command_get_config(cmd, 'region') + + params_dict = { + 'ami_name': full_name, + 'deployment_name': args.deployment_name, + 'instance_type': args.instance_type, + 'region': region, + 'vpc_id': vpc_id, + 'subnet_id': subnet_id, + } + params_dict.update(packer_params or {}) + + params_str = ' '.join(f'-var {k}={v}' for k, v in params_dict.items() if v is not None) + + packer_cmd = f'packer build {params_str} {args.template}' + cmd.logger.debug(packer_cmd) + exit_code, stdout, stderr = run_in_shell(packer_cmd, args.verbose) + if exit_code: + cmd.fail_shell('packer', exit_code, stdout, stderr) + + regex = re.compile(r'AMI: (?Pami-\w+)') + match = regex.search(stdout) + if not match: + cmd.fail('could not find AMI ID in packer output') + + images = cmd.cache.get('images', {}) + images[name] = match.group('id') + cmd.cache['images'] = images + + cmd.logger.info('done.') + + +def command_create_stack( + cmd, deployment_name, stack_name, template_path, extra_params, wait_period=60): + cmd.logger.info(f'creating stack {stack_name}...') + + # doing this to be compatible with AWS CLI which specifies the template path as + # file://path/to/template. + url = urlparse(template_path) + cmd.logger.debug(f'template: {url.path}') + with open(url.path) as fh: + template = fh.read() + + vpc_id = command_get_config(cmd, 'vpc-id') + subnet_id = command_get_config(cmd, 'subnet-id') + admin_cidr = command_get_config(cmd, 'admin-cidr') + + params = [ + {'ParameterKey': 'DeploymentName', 'ParameterValue': deployment_name}, + {'ParameterKey': 'VpcId', 'ParameterValue': vpc_id}, + {'ParameterKey': 'SubnetId', 'ParameterValue': subnet_id}, + {'ParameterKey': 'AdminCidr', 'ParameterValue': admin_cidr}, + ] + params.extend(extra_params) + + cmd.logger.debug(f'using params {params}') + resp = cmd.aws.cf.create_stack( + StackName=stack_name, + TemplateBody=template, + Parameters=params, + OnFailure='DELETE', ) - if len(resp['Vpcs']) == 1: - vpc_id = resp['Vpcs'][0]['VpcId'] - cmd.cache['vpc_id'] = vpc_id - return vpc_id - elif len(resp['Vpcs']) > 1: - vpc_ids = ', '.join(vpc['VpcId'] for vpc in resp['Vpcs']) - cmd.fail(f'multiple VPCs found: {vpc_ids}; use --vpc-id to specify ' - 'which one should be used') - else: - cmd.fail('no VPCs found in the account') - - -def command_get_subnet_id(cmd, args): - if args.subnet_id: - cmd.logger.debug(f'writing {args.subnet_id} to cache') - cmd.cache['subnet_id'] = args.subnet_id - return args.subnet_id + cmd.logger.debug(f'stack ID: {resp["StackId"]}') - subnet_id = cmd.cache.get('subnet_id') - if subnet_id: - cmd.logger.debug(f'using subnet ID from cache: {subnet_id}') - return subnet_id + cmd.logger.debug('waiting for the stack creation to complete...') + resp = cmd.aws.cf.describe_stacks(StackName=stack_name) + while resp['Stacks'][0]['StackStatus'] == 'CREATE_IN_PROGRESS': + time.sleep(wait_period) + resp = cmd.aws.cf.describe_stacks(StackName=stack_name) - cmd.logger.debug('no subnet ID specified; trying to identify from account...') - resp = cmd.aws.ec2.describe_subnets( - Filters=[{ - 'Name': 'state', - 'Values': ['available'], - }] - ) - if len(resp['Subnets']) == 1: - subnet_id = resp['Subnets'][0]['SubnetId'] - cmd.cache['subnet_id'] = subnet_id - return subnet_id - elif len(resp['Subnets']) > 1: - subnet_ids = ', '.join(subnet['SubnetId'] for subnet in resp['Subnets']) - cmd.fail(f'multiple subnets found: {subnet_ids}; use --subnet-id to specify ' - 'which one should be used') - else: - cmd.fail('no subnets found in the account') + stack_status = resp['Stacks'][0]['StackStatus'] + if stack_status == 'CREATE_COMPLETE': + instances = cmd.cache.get('instances', {}) + cmd.logger.debug(f'getting info for {stack_name}...') + stack_instance_info = get_stack_instances_info(cmd.aws, stack_name) -def command_get_region(cmd, args, subnet_id=None): - region = cmd.cache.get('region') - if region: - return region + cmd.logger.info('instances:') + for name, instance in stack_instance_info.items(): + cmd.logger.info(f' {name}: {instance['dns_name']} ({instance['ip_address']})') + instances[name] = instance - if subnet_id is None: - subnet_id = command_get_subnet_id(cmd, args) + cmd.cache['instances'] = instances - resp = cmd.aws.ec2.describe_subnets(SubnetIds=[subnet_id]) - zone_id = resp['Subnets'][0]['AvailabilityZoneId'] + cmd.logger.info('done.') + else: # stack_status != 'CREATE_COMPLETE' + cmd.logger.error(f'creation failed: {stack_status}') + resp = cmd.aws.cf.describe_stack_events(StackName=stack_name) - resp = cmd.aws.ec2.describe_availability_zones(ZoneIds=[zone_id]) - region = resp['AvailabilityZones'][0]['RegionName'] + for event in resp['StackEvents']: + if event['ResourceStatus'] == 'CREATE_IN_PROGRESS': + break + status = event['ResourceStatus'] + reason = event.get("ResourceStatusReason", '') + cmd.logger.error(f'{status} {reason}') - cmd.cache['region'] = region - return region + cmd.fail(f'could not create stack {stack_name}') def command_connect(cmd, instance_name, user='ubuntu', should_log=True): @@ -306,31 +386,27 @@ def command_connect(cmd, instance_name, user='ubuntu', should_log=True): ) -def command_update_ca_cert_form_deb(cmd): - deb_path = cmd.cache.get('deb') - if not deb_path: - cmd.fail('deb not found') - - with open(deb_path, 'rb') as fh: - deb = ar.Archive(fh) - data_buf = io.BytesIO(deb.open('data.tar.xz', 'rb').read()) - tf = tarfile.open(fileobj=data_buf, mode='r:xz') - cert_fh = tf.extractfile('./opt/veraison/certs/rootCA.crt') - - dest_path = os.path.join(cmd.cache.dir, 'ca-cert.crt') - with open(dest_path, 'wb') as wfh: - wfh.write(cert_fh.read()) - - cmd.cache['ca-cert'] = dest_path - - class DeploymentCache: @property def dir(self): return os.path.dirname(self.path) + @property + def certs_dir(self): + return os.path.join(self.dir, 'certs') + + @property + def ca_cert_path(self): + return os.path.join(self.certs_dir, 'rootCA.crt') + + @property + def ca_key_path(self): + return os.path.join(self.certs_dir, 'rootCA.key') + def __init__(self, name, cache_dir=None): + if not name: + raise ValueError('name cannot be empty') self.name = name if cache_dir is None: cache_dir = xdg.BaseDirectory.save_data_path('veraison/aws') @@ -380,8 +456,12 @@ class BaseCommand: self.update_arguments(parser) def execute(self, args): + if args.verbose and args.quiet: + self.fail('only one of -v/--verbose or -q/--quiet may be specfied at a time') if args.verbose: self.logger.setLevel(logging.DEBUG) + elif args.quiet: + self.logger.setLevel(logging.WARNING) self.cache = DeploymentCache(args.deployment_name, args.cache_dir) try: @@ -412,84 +492,41 @@ class BaseCommand: raise NotImplementedError() -class CreateStackCommand(BaseCommand): +class CreateCombinedStackCommand(BaseCommand): - name = 'create-stack' - desc = 'create deployment\'s cloudformation stack' + name = 'create-combined-stack' + desc = 'create deployment\'s cloudformation stack running services on one instance' def update_arguments(self, parser): - parser.add_argument('-a', '--admin-cidr') - parser.add_argument( - '-t', '--template-body', - default=os.path.abspath(os.path.join( - os.path.dirname(__file__), - '../templates/stack-combined.yaml', - )), - ) parser.add_argument('-k', '--key-name') - parser.add_argument('-i', '--image-id') - parser.add_argument('-s', '--subnet-id') - parser.add_argument('-V', '--vpc-id') def run(self, args): - self.logger.info(f'creating stack {args.deployment_name}...') - - # doing this to be compatible with AWS CLI which specifies the template path as - # file://path/to/template. - url = urlparse(args.template_body) - self.logger.debug(f'template: {url.path}') - with open(url.path) as fh: - template = fh.read() - - params = [ - {'ParameterKey': 'DeploymentName', 'ParameterValue': args.deployment_name}, - {'ParameterKey': 'KeyName', 'ParameterValue': self._get_key_name(args)}, - {'ParameterKey': 'InstanceImage', 'ParameterValue': self._get_image(args)}, - {'ParameterKey': 'VpcId', 'ParameterValue': self._get_vpc_id(args)}, - {'ParameterKey': 'SubnetId', 'ParameterValue': self._get_subnet_id(args)}, - {'ParameterKey': 'AdminCidr', 'ParameterValue': self._get_cidr(args)}, + stack_name = f'{args.deployment_name}-combined' + template_path = os.path.abspath(os.path.join( + os.path.dirname(__file__), + '../templates/stack-combined.yaml', + )) + + key_name = self._get_key_name(args) + + combined_image = self.cache.get('images', {}).get('combined') + if not combined_image: + self.fail('could not find combined image in cache; ' + 'please run create-combined-image command') + + keycloak_image = self.cache.get('images', {}).get('keycloak') + if not keycloak_image: + self.fail('could not find keycloak image in cache; ' + 'please run create-keycloak-image command') + + extra_params = [ + {'ParameterKey': 'KeyName', 'ParameterValue': key_name}, + {'ParameterKey': 'CombinedImage', 'ParameterValue': combined_image}, + {'ParameterKey': 'KeycloakImage', 'ParameterValue': keycloak_image}, ] - self.logger.debug(f'using params {params}') - resp = self.aws.cf.create_stack( - StackName=args.deployment_name, - TemplateBody=template, - Parameters=params, - OnFailure='DELETE', - ) - self.logger.debug(f'stack ID: {resp["StackId"]}') - - self.logger.debug('waiting for the stack creation to complete...') - resp = self.aws.cf.describe_stacks(StackName=args.deployment_name) - while resp['Stacks'][0]['StackStatus'] == 'CREATE_IN_PROGRESS': - time.sleep(args.wait_period) - resp = self.aws.cf.describe_stacks(StackName=args.deployment_name) - - stack_status = resp['Stacks'][0]['StackStatus'] - if stack_status == 'CREATE_COMPLETE': - self.logger.debug(f'getting info for {args.deployment_name}...') - deployment_info = get_deployment_info(self.aws, args.deployment_name) - instance = deployment_info['instance'] - self.logger.info(f'instance: {instance['dns_name']} ({instance['ip_address']})') - - self.logger.debug('updating cache') - instances = self.cache.get('instances', {}) - instances['combined'] = instance - self.cache['instances'] = instances - - self.logger.info('done.') - else: # stack_status != 'CREATE_COMPLETE' - self.logger.error(f'creation failed: {stack_status}') - resp = self.aws.cf.describe_stack_events(StackName=args.deployment_name) - - for event in resp['StackEvents']: - if event['ResourceStatus'] == 'CREATE_IN_PROGRESS': - break - status = event['ResourceStatus'] - reason = event.get("ResourceStatusReason", '') - self.logger.error(f'{status} {reason}') - - self.fail(f'could not create stack {args.deployment_name}') + command_create_stack(cmd, args.deployment_name, stack_name, + template_path, extra_params, args.wait_period) def _get_key_name(self, args): if args.key_name: @@ -502,58 +539,43 @@ class CreateStackCommand(BaseCommand): self.fail('could not find key name (specify with --key-name or run ' 'create-key-pair command)') - def _get_image(self, args): - if args.image_id: - return args.image_id - - image_id = self.cache.get('images', {}).get(f'{args.deployment_name}-combined') - if image_id: - return image_id - - self.fail('could not find IMA image ID (specify with --image-id or run ' - 'create-image command') - - def _get_cidr(self, args): - if args.admin_cidr: - return args.admin_cidr - - return f'{get_public_ip_address()}/32' - - def _get_vpc_id(self, args): - return command_get_vpc_id(self, args) - - def _get_subnet_id(self, args): - return command_get_subnet_id(self, args) - class DeleteStackCommand(BaseCommand): name = 'delete-stack' - desc = 'delete deployment\'s stack' + desc = 'delete a previously creates stack stack' + + def update_arguments(self, parser): + parser.add_argument('name') def run(self, args): - self.logger.info(f'deleting stack {args.deployment_name}...') - self.aws.cf.delete_stack(StackName=args.deployment_name) + stack_name = f'{args.deployment_name}-{args.name}' + self.logger.info(f'deleting stack {stack_name}...') + self.logger.debug(f'getting {stack_name} instances...') + stack_instances = get_stack_instances_info(self.aws, stack_name) + + self.aws.cf.delete_stack(StackName=stack_name) try: self.logger.debug('waiting for the stack deletion to complete...') - resp = self.aws.cf.describe_stacks(StackName=args.deployment_name) + resp = self.aws.cf.describe_stacks(StackName=stack_name) while resp['Stacks'][0]['StackStatus'] == 'DELETE_IN_PROGRESS': time.sleep(args.wait_period) - resp = self.aws.cf.describe_stacks(StackName=args.deployment_name) + resp = self.aws.cf.describe_stacks(StackName=stack_name) except ClientError as e: if 'does not exist' not in str(e): raise e - self.logger.debug('updating cache') + self.logger.debug('updating instances in cache') instances = self.cache.get('instances', {}) - del instances['combined'] + for instance_name in stack_instances: + del instances[instance_name] self.cache['instances'] = instances self.logger.info('done.') -class UpdateSecurityGroups(BaseCommand): +class UpdateSecurityGroupsCommand(BaseCommand): name = 'update-security-groups' desc = 'update security group(s) in deployment with current host\'s IP address' @@ -561,27 +583,28 @@ class UpdateSecurityGroups(BaseCommand): def update_arguments(self, parser): default_tag = f'{socket.gethostname()}-{getpass.getuser()}' parser.add_argument('-t', '--tag', default=default_tag) - parser.add_argument('-p', '--ports', action=StoreIntList, default=[8888, 8080, 8088, 22]) + parser.add_argument('-p', '--ports', action=StoreIntList, + default=[11111, 8888, 8080, 8088, 22]) def run(self, args): self.logger.info('updating deployment security groups with IP address for this host') try: - update_dynamic_address_rules(self.aws, args.deployment_name, args.tag, args.ports) + command_update_dynamic_address_rules( + self, args.deployment_name, args.tag, args.ports) except Exception as e: self.fail(e) self.logger.info('done.') -class CreateImage(BaseCommand): +class CreateCombinedImageCommand(BaseCommand): - name = 'create-image' + name = 'create-combined-image' desc = 'create IMA image for the Veraison services EC2 instance' def update_arguments(self, parser): parser.add_argument('-D', '--deb') - parser.add_argument('-s', '--subnet-id') parser.add_argument( '-t', '--template', default=os.path.abspath(os.path.join( @@ -590,85 +613,170 @@ class CreateImage(BaseCommand): )), ) parser.add_argument('-T', '--instance-type') - parser.add_argument('-V', '--vpc-id') def run(self, args): - if not shutil.which('packer'): - self.fail('packer must be installed on the system') - - if not os.path.isfile(args.template): - self.fail(f'template {args.template} does not exist') - deb_path = args.deb or self.cache['deb'] if not os.path.isfile(deb_path): self.fail(f'{deb_path} does not exist') self.cache['deb'] = deb_path - name = f'{args.deployment_name}-combined' - self.logger.info(f'creating image: {name}...') - - self.logger.debug('checking for existing AMI with that name...') - existing_id = get_ami_id(self.aws, name) - if existing_id: - if not args.force: - self.fail(f'image {name} already exits (use -f to overwrite)') - self.logger.info('removing existing image...') - self.aws.ec2.deregister_image(ImageId=existing_id) - - self.logger.info('building using packer...') - subnet_id = command_get_subnet_id(self, args) - region = command_get_region(self, args, subnet_id) - packer_build_args = ' '.join(f'-var {k}={v}' for k, v in { - 'ami_name': name, - 'deb': deb_path, - 'deployment_name': args.deployment_name, - 'instance_type': args.instance_type, - 'region': region, - 'vpc_id': command_get_vpc_id(self, args), - 'subnet_id': subnet_id, - }.items() if v is not None) - - packer_cmd = f'packer build {packer_build_args} {args.template}' - self.logger.debug(packer_cmd) - exit_code, stdout, stderr = run_in_shell(packer_cmd, args.verbose) - if exit_code: - self.fail_shell('packer', exit_code, stdout, stderr) + command_create_image(self, args, 'combined', {'deb': deb_path}) - regex = re.compile(r'AMI: (?Pami-\w+)') - match = regex.search(stdout) - if not match: - self.fail('could not find AMI ID in packer output') - images = self.cache.get('images', {}) - images[name] = match.group('id') - self.cache['images'] = images +class ConfigureCommand(BaseCommand): - self.logger.info('done.') + name = 'configure' + desc = 'configure deployment parameters' + def update_arguments(self, parser): + parser.add_argument('-a', '--admin-cidr') + parser.add_argument('-i', '--init', action='store_true', + help='initialize config not explicitly specified') + parser.add_argument('-r', '--region') + parser.add_argument('-s', '--subnet-id') + parser.add_argument('-v', '--vpc-id') -class DeleteImage(BaseCommand): + def run(self, args): + self._configure_vpc_id(args) + self._configure_subnet_id(args) + self._configure_region(args) + + if args.admin_cidr: + self.cache['admin-cidr'] = args.admin_cidr + elif args.init: + self.logger.warning('setting admin CIDR to 0.0.0.0/0; this is not recommended: ' + 're-run with --admin-cidr option') + self.cache['admin-cidr'] = '0.0.0.0/0' + + + def _configure_vpc_id(self, args): + if args.vpc_id: + self.logger.debug(f'writing {args.vpc_id} to cache') + self.cache['vpc-id'] = args.vpc_id + return + elif not args.init and self.cache.get('vpc-id'): + return # already set and not re-initializing + + self.logger.debug('no VPC ID specified; trying to identify from account...') + resp = self.aws.ec2.describe_vpcs( + Filters=[{ + 'Name': 'state', + 'Values': ['available'], + }] + ) + if len(resp['Vpcs']) == 1: + vpc_id = resp['Vpcs'][0]['VpcId'] + self.cache['vpc-id'] = vpc_id + elif len(resp['Vpcs']) > 1: + vpc_ids = ', '.join(vpc['VpcId'] for vpc in resp['Vpcs']) + self.fail(f'multiple VPCs found: {vpc_ids}; use --vpc-id to specify ' + 'which one should be used') + else: + self.fail('no VPCs found in the account') + + def _configure_subnet_id(self, args): + if args.subnet_id: + self.logger.debug(f'writing {args.subnet_id} to cache') + self.cache['subnet-id'] = args.subnet_id + return + elif not args.init and self.cache.get('subnet-id'): + return # already set and not re-initializing + + self.logger.debug('no subnet ID specified; trying to identify from account...') + resp = self.aws.ec2.describe_subnets( + Filters=[{ + 'Name': 'state', + 'Values': ['available'], + }] + ) + if len(resp['Subnets']) == 1: + subnet_id = resp['Subnets'][0]['SubnetId'] + self.cache['subnet-id'] = subnet_id + elif len(resp['Subnets']) > 1: + subnet_ids = ', '.join(subnet['SubnetId'] for subnet in resp['Subnets']) + self.fail(f'multiple subnets found: {subnet_ids}; use --subnet-id to specify ' + 'which one should be used') + else: + self.fail('no subnets found in the account') + + def _configure_region(self, args): + if args.region: + self.logger.debug(f'writing {args.region} to cache') + self.cache['region'] = args.region + return + elif not args.init and self.cache.get('region'): + return # already set and not re-initializing + + subnet_id = command_get_config(self, 'subnet-id') + + resp = self.aws.ec2.describe_subnets(SubnetIds=[subnet_id]) + zone_id = resp['Subnets'][0]['AvailabilityZoneId'] + + resp = self.aws.ec2.describe_availability_zones(ZoneIds=[zone_id]) + region = resp['AvailabilityZones'][0]['RegionName'] + + self.cache['region'] = region + + +class CreateKeycloakImageCommand(BaseCommand): + + name = 'create-keycloak-image' + desc = 'create IMA image for the Keycloak EC2 instance' + + def update_arguments(self, parser): + parser.add_argument( + '-c', '--keycloak-config-template', + default=os.path.abspath(os.path.join( + os.path.dirname(__file__), + '../templates/keycloak.conf.template', + )), + ) + parser.add_argument( + '-t', '--template', + default=os.path.abspath(os.path.join( + os.path.dirname(__file__), + '../templates/image-keycloak.pkr.hcl', + )), + ) + parser.add_argument('-T', '--instance-type') + + def run(self, args): + with open(args.keycloak_config_template) as fh: + config = envsubst(fh.read()) + + config_path = f'/tmp/{args.deployment_name}-keyloak.conf' + self.logger.debug(f'writing keycloak config to {config_path}') + with open(config_path, 'w') as wfh: + wfh.write(config) + + command_create_image(self, args, 'keycloak', {'conf': config_path}) + + +class DeleteImageCommand(BaseCommand): name = 'delete-image' desc = 'delete IMA image for the Veraison services EC2 instance' + def update_arguments(self, parser): + parser.add_argument('name') + def run(self, args): - name = f'{args.deployment_name}-combined' images = self.cache.get('images', {}) - iid = images.get(name) + iid = images.get(args.name) if iid is None: - self.fail(f'no entry for image {name} found in the deployment cache') + self.fail(f'no entry for image {args.name} found in the deployment cache') self.logger.info(f'deleting image {name} ({iid})...') self.aws.ec2.deregister_image(ImageId=iid) - self.logger.debug(f'removing image {name} from cache') - del images[name] + self.logger.debug(f'removing image {args.name} from cache') + del images[args.name] self.cache['images'] = images self.logger.info('done.') -class CreateKeyPair(BaseCommand): +class CreateKeyPairCommand(BaseCommand): name = 'create-key-pair' desc = 'create a key pair that will be used for SSH access to the deployment\'s instances' @@ -716,7 +824,7 @@ class CreateKeyPair(BaseCommand): self.logger.info('done.') -class DeleteKeyPair(BaseCommand): +class DeleteKeyPairCommand(BaseCommand): name = 'delete-key-pair' desc = 'create a key pair that will be used for SSH access to the deployment\'s instances' @@ -770,7 +878,7 @@ class DeleteKeyPair(BaseCommand): self.logger.info('done. (local files not touched)') -class CreateDeb(BaseCommand): +class CreateDebCommand(BaseCommand): name = 'create-deb' desc = 'create the Veraison Debian package' @@ -818,13 +926,10 @@ class CreateDeb(BaseCommand): self.logger.info(f'created {dest_path}') - self.logger.info('extracting ca-cert...') - command_update_ca_cert_form_deb(self) - self.logger.info('done.') -class DeleteDeb(BaseCommand): +class DeleteDebCommand(BaseCommand): name = 'delete-deb' desc = 'delete perviously created Debian package' @@ -840,23 +945,51 @@ class DeleteDeb(BaseCommand): os.remove(deb_path) del self.cache['deb'] - cert_path = self.cache.get('ca-cert') - if cert_path: - self.logger.debug(f'removing ca-cert {cert_path}') - os.remove(cert_path) - del self.cache['ca-cert'] - self.logger.info('done.') -class Shell(BaseCommand): +class CreateDebugStack(BaseCommand): + name = 'create-debug-stack' + desc = 'create a stack with a single instance that can be used for testing' + + def update_arguments(self, parser): + parser.add_argument('-k', '--key-name') + parser.add_argument('-i', '--image-id', default='ami-02c6977f57c0816de') + + def run(self, args): + stack_name = f'{args.deployment_name}-debug' + template_path = os.path.abspath(os.path.join( + os.path.dirname(__file__), + '../templates/stack-debug.yaml', + )) + key_name = self._get_key_name(args) + extra_params = [ + {'ParameterKey': 'KeyName', 'ParameterValue': key_name}, + {'ParameterKey': 'InstanceImage', 'ParameterValue': args.image_id}, + ] + + command_create_stack(cmd, args.deployment_name, stack_name, + template_path, extra_params, args.wait_period) + + def _get_key_name(self, args): + if args.key_name: + return args.key_name + + key_info = self.cache.get('key') + if key_info: + return key_info['name'] + + self.fail('could not find key name (specify with --key-name or run ' + 'create-key-pair command)') + +class ShellCommand(BaseCommand): name = 'shell' desc = 'start a shell on a deployment instance' def update_arguments(self, parser): + parser.add_argument('instance', choices=['combined', 'keycloak', 'test']) parser.add_argument('-k', '--ssh-key') - parser.add_argument('-i', '--instance' , default='combined', choices=['combined']) parser.add_argument('-u', '--user', default='ubuntu') parser.add_argument('-s', '--server-alive-interval', type=int, default=60) @@ -885,7 +1018,92 @@ class Shell(BaseCommand): os.system(ssh_cmd) -class UpdateCerts(BaseCommand): +class PushCommand(BaseCommand): + + name = 'push' + desc = 'copy a file/directory from the host to a deployment instance' + + def update_arguments(self, parser): + parser.add_argument('-k', '--ssh-key') + parser.add_argument('-i', '--instance' , default='combined', + choices=['combined', 'keycloak']) + parser.add_argument('-u', '--user', default='ubuntu') + parser.add_argument('-P', '--no-preserve-mode', action='store_true') + parser.add_argument('-s', '--sudo', action='store_true') + parser.add_argument('-o', '--owner', help='implies --sudo') + parser.add_argument('src') + parser.add_argument('dest') + + def run(self, args): + if args.owner: + args.sudo = True + + if not shutil.which('ssh'): + self.fail('ssh does not appear to be installed on the system') + + instance = self.cache.get('instances', {}).get(args.instance) + if not instance: + self.fail(f'{instance} instance not in cache; has the correct stack been created?') + + if args.ssh_key: + key = args.ssh_key + else: + key = self.cache.get('key', {}).get('path') + + if not key: + self.fail(f'key not found in cache specify with --ssh-key') + + self.logger.debug(f'copying {args.src} on the host to {args.dest} ' + f'on {args.instance} instance') + with command_connect(self, args.instance) as con: + if args.sudo: + temp_dest = randomword(32) + os.path.basename(args.dest) + con.put(args.src, remote=temp_dest, preserve_mode=(not args.no_preserve_mode)) + con.sudo(f'mv ~/{temp_dest} {args.dest}') + if args.owner: + con.sudo(f'chown -R {args.owner} {args.dest}') + pass + else: + con.put(args.src, remote=args.dest, preserve_mode=(not args.no_preserve_mode)) + + +class PullCommand(BaseCommand): + + name = 'pull' + desc = 'copy a file/directory from a deployment instance to the host' + + def update_arguments(self, parser): + parser.add_argument('-k', '--ssh-key') + parser.add_argument('-i', '--instance' , default='combined', + choices=['combined', 'keycloak']) + parser.add_argument('-u', '--user', default='ubuntu') + parser.add_argument('-P', '--no-preserve-mode', action='store_true') + parser.add_argument('src') + parser.add_argument('dest') + + def run(self, args): + if not shutil.which('ssh'): + self.fail('ssh does not appear to be installed on the system') + + instance = self.cache.get('instances', {}).get(args.instance) + if not instance: + self.fail(f'{instance} instance not in cache; has the correct stack been created?') + + if args.ssh_key: + key = args.ssh_key + else: + key = self.cache.get('key', {}).get('path') + + if not key: + self.fail(f'key not found in cache specify with --ssh-key') + + self.logger.debug(f'copying {args.src} on the host to {args.dest} ' + f'on {args.instance} instance') + with command_connect(self, args.instance) as con: + con.get(args.src, local=args.dest, preserve_mode=(not args.no_preserve_mode)) + + +class UpdateCertsCommand(BaseCommand): name = 'update-certs' description = 'update SSL certs in the combine instance to reflect its DNS name' @@ -914,7 +1132,7 @@ class UpdateCerts(BaseCommand): self.logger.info('done.') -class CreateClientConfig(BaseCommand): +class CreateClientConfigCommand(BaseCommand): name = 'create-client-config' desc = ''' @@ -923,14 +1141,14 @@ class CreateClientConfig(BaseCommand): all_clients = ['cocli', 'evcli', 'pocli'] def update_arguments(self, parser): - parser.add_argument('-c', '--client', action='append', choices=self.all_clients) + parser.add_argument('-c', '--client', + action='append', dest='clients', choices=self.all_clients) parser.add_argument('-o', '--output-dir', default=xdg.BaseDirectory.xdg_config_home) def run(self, args): self.logger.info('creating Veraison client config(s)...') - cert_path = self.cache.get('ca-cert') - if not cert_path: - self.fail('could not find ca-cert in cache') + if not os.path.isfile(self.cache.ca_cert_path): + self.fail('could not find ca-cert in cache; has create-certs been called?') with command_connect(self, 'combined') as con: self.logger.debug(f'getting services config from {con.host}...') @@ -941,7 +1159,7 @@ class CreateClientConfig(BaseCommand): if res.exited != 0: self.fail(f'could not read services config; got {res.exited}: {res.stderr}') - clients = args.client or self.all_clients + clients = args.clients or self.all_clients for client in clients: self.logger.info(f'generating {client} config...') outdir = os.path.join(args.output_dir, client) @@ -950,7 +1168,7 @@ class CreateClientConfig(BaseCommand): os.makedirs(outdir) srv_cfg = yaml.safe_load(res.stdout) - config = getattr(self, f'get_{client}_config')(srv_cfg, con.host, cert_path) + config = getattr(self, f'get_{client}_config')(srv_cfg, con.host) outfile = os.path.join(outdir, 'config.yaml') self.logger.debug(f'writing {outfile}') @@ -960,41 +1178,71 @@ class CreateClientConfig(BaseCommand): self.cache['client_config_dir'] = args.output_dir self.logger.info('done.') - def get_cocli_config(self, srv_cfg, host, cert_path): + def get_cocli_config(self, srv_cfg, host): port = int(srv_cfg['provisioning']['listen-addr'].split(':')[1]) return { - 'ca_cert': cert_path, + 'ca_cert': self.cache.ca_cert_path, 'api_server': f'https://{host}:{port}/endorsement-provisioning/v1/submit', } def get_evcli_config(self, srv_cfg, host, cert_path): port = int(srv_cfg['verification']['listen-addr'].split(':')[1]) return { - 'ca_cert': cert_path, + 'ca_cert': self.cache.ca_cert_path, 'api_server': f'https://{host}:{port}/challenge-response/v1/newSession', } def get_pocli_config(self, srv_cfg, host, cert_path): port = int(srv_cfg['management']['listen-addr'].split(':')[1]) return { - 'ca_cert': cert_path, + 'ca_cert': self.cache.ca_cert_path, 'tls': True, 'host': host, 'port': port, } -class Cache(BaseCommand): +class CacheCommand(BaseCommand): name = 'cache' desc = 'show cached info for the deployment' + def update_arguments(self, parser): + parser.add_argument('-q', '--query') + def run(self, args): - print(f'deployment: {args.deployment_name}') - pprint.pp(self.cache.as_dict()) + if args.query: + parts = args.query.split('.') + entry = self.cache + path = '' + + for part in parts[:len(parts)-1]: + entry = self._access_member(entry, part, path) + path = path + '.' + part + + val = self._access_member(entry, parts.pop(), path) + if val is not None: + print(val) + else: + print('') + else: + print(f'deployment: {args.deployment_name}') + pprint.pp(self.cache.as_dict()) + + def _access_member(self, entry, part, path): + try: # if part is an int, assume list entry + idx = int(part) + if (len(entry)-1) > idx: # pyright: ignore[reportArgumentType] + if path: + self.fail(f'index {idx} does not exist for cache entry "{path}"') + else: + self.fail(f'index {idx} does not exist in cache') + return entry[idx] + except ValueError: # part not an int, assume dict entry + return entry[part] -class CheckStores(BaseCommand): +class CheckStoresCommand(BaseCommand): name = 'check-stores' desc = 'output the contents of deployment\'s sqlite3 stores' aliases = ['stores'] @@ -1008,7 +1256,7 @@ class CheckStores(BaseCommand): print(res.stdout.rstrip('\n')) -class Status(BaseCommand): +class StatusCommand(BaseCommand): name = 'status' desc = 'show status of the deployment' @@ -1039,7 +1287,7 @@ class Status(BaseCommand): print(f' instance: {host} ({addr}) {COLOR_RED}down{COLOR_RESET}') -class ClearStores(BaseCommand): +class ClearStoresCommand(BaseCommand): name = 'clear-stores' desc = 'clear the contents of deployment\'s sqlite3 stores' @@ -1051,13 +1299,197 @@ class ClearStores(BaseCommand): self.fail(f'could not clear stores; got {res.exited}: {res.stdout}') +class CreateCertsCommand(BaseCommand): + name = 'create-certs' + desc = 'generate certificates for the deployment' + + all_services = ['vts', 'provisioning', 'verification', 'management', 'keycloak'] + + def update_arguments(self, parser): + parser.add_argument('-c', '--ca-cert') + parser.add_argument('-k', '--ca-cert-key') + parser.add_argument('-s', '--service', + action='append', dest='services', choices=self.all_services) + + def run(self, args): + self.logger.info('creating service certificates...') + if (not args.ca_cert) != (not args.ca_cert_key): + self.fail('if one of -c/--ca-cert and -k/--ca-cert-key is specified, ' + 'the other must be as well') + + combined_host = self.cache.get('instances', {}).get('combined', {}).get('dns_name') + if not combined_host: + self.fail('did not find DNS name for combined instance in cache; ' + 'has create-stack been called?') + + keycloak_host = self.cache.get('instances', {}).get('keycloak', {}).get('dns_name') + if not keycloak_host: + self.fail('did not find DNS name for keycloak instance in cache; ' + 'has create-stack been called?') + + if not os.path.isdir(self.cache.certs_dir): + self.logger.debug(f'creating {self.cache.certs_dir}') + os.makedirs(self.cache.certs_dir) + + if args.ca_cert: + self.logger.debug('copying CA cert to cache') + shutil.copyfile(args.ca_cert, self.cache.ca_cert_path) + shutil.copyfile(args.ca_cert_key, self.cache.ca_key_path) + os.chmod(self.cache.ca_key_path, stat.S_IRUSR | stat.S_IWUSR) + + with open(self.cache.ca_key_path, 'rb') as fh: + ca_key = serialization.load_pem_private_key(fh.read(), None) + with open(self.cache.ca_cert_path, 'rb') as fh: + ca_cert = x509.load_pem_x509_certificate(fh.read()) + else: + if os.path.isfile(self.cache.ca_cert_path) and not args.force: + self.logger.debug('using existing CA cert') + with open(self.cache.ca_key_path, 'rb') as fh: + ca_key = serialization.load_pem_private_key(fh.read(), None) + with open(self.cache.ca_cert_path, 'rb') as fh: + ca_cert = x509.load_pem_x509_certificate(fh.read()) + else: + self.logger.debug('creating CA cert') + ca_cert, ca_key = create_ca_cert(self.cache.ca_cert_path, self.cache.ca_key_path) + + cache_entries = self.cache.get('certs', {}) + services = args.services or self.all_services + for service in services: + self.logger.debug(f'creating cert for {service}') + service_host = keycloak_host if service == 'keycloak' else combined_host + + cert_path = os.path.join(self.cache.certs_dir, f'{service}.crt') + if os.path.isfile(cert_path) and not args.force: + self.fail(f'cert for {service} already exists; use --force to overwrite') + + cert_path, key_path = create_service_cert( + service, service_host, self.cache.certs_dir, ca_cert, ca_key, + ) + cache_entries[service] = {'cert': cert_path, 'key': key_path} + + self.cache['certs'] = cache_entries + self.logger.info('done.') + + +def create_ec_key(key_path): + key = ec.generate_private_key(curve=ec.SECP256R1()) + + with open(key_path, 'wb') as wfh: + wfh.write(key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + )) + + os.chmod(key_path, stat.S_IRUSR | stat.S_IWUSR) + + return key + +def create_ca_cert(cert_path, key_path): + key = create_ec_key(key_path) + + subject = issuer = x509.Name([ + x509.NameAttribute(NameOID.ORGANIZATION_NAME, 'Veraison'), + ]) + now = datetime.now(timezone.utc) + + cert = ( + x509.CertificateBuilder(public_key=key.public_key()) + .subject_name(subject) + .issuer_name(issuer) + .serial_number(x509.random_serial_number()) + .not_valid_before(now) + .not_valid_after(now + timedelta(days=3650)) + .add_extension( + x509.BasicConstraints(ca=True, path_length=None), + critical=True, + ) + .add_extension( + x509.KeyUsage( + digital_signature=True, + content_commitment=False, + key_encipherment=False, + data_encipherment=False, + key_agreement=False, + key_cert_sign=True, + crl_sign=True, + encipher_only=False, + decipher_only=False, + ), + critical=True, + ) + .add_extension( + x509.SubjectKeyIdentifier.from_public_key(key.public_key()), + critical=False, + ) + ).sign(key, hashes.SHA256()) + + with open(cert_path, 'wb') as wfh: + wfh.write(cert.public_bytes(serialization.Encoding.PEM)) + + return cert, key + + +def create_service_cert(service_name, service_host, dest_path, ca_cert, ca_key): + cert_path = os.path.join(dest_path, f'{service_name}.crt') + key_path = os.path.join(dest_path, f'{service_name}.key') + + key = create_ec_key(key_path) + + subject = issuer = x509.Name([ + x509.NameAttribute(NameOID.COMMON_NAME, service_host), + ]) + now = datetime.now(timezone.utc) + + cert = ( + x509.CertificateBuilder(public_key=key.public_key()) + .subject_name(subject) + .issuer_name(issuer) + .serial_number(x509.random_serial_number()) + .not_valid_before(now) + .not_valid_after(now + timedelta(days=3650)) + .add_extension( + x509.SubjectAlternativeName([ + x509.DNSName(service_host), + x509.DNSName('localhost'), + ]), + critical=False, + ) + .add_extension( + x509.BasicConstraints(ca=False, path_length=None), + critical=True, + ) + .add_extension( + x509.ExtendedKeyUsage([ + x509.ExtendedKeyUsageOID.CLIENT_AUTH, + x509.ExtendedKeyUsageOID.SERVER_AUTH, + ]), + critical=False, + ).add_extension( + x509.SubjectKeyIdentifier.from_public_key(key.public_key()), + critical=False, + ) + .add_extension( + x509.AuthorityKeyIdentifier.from_issuer_subject_key_identifier( + ca_cert.extensions.get_extension_for_class(x509.SubjectKeyIdentifier).value, + ), + critical=False, + ) + ).sign(ca_key, hashes.SHA256()) + + with open(cert_path, 'wb') as wfh: + wfh.write(cert.public_bytes(serialization.Encoding.PEM)) + + return cert_path, key_path + + class LogFormatter(logging.Formatter): fmt = f'{{}}%(asctime)s %(name)s %(levelname)s{COLOR_RESET}: %(message)s' level_formats = { logging.DEBUG: fmt.format(COLOR_DARK_GREY), - logging.INFO: fmt.format(COLOR_GREY), + logging.INFO: fmt.format(COLOR_MEDIUM_GREY), logging.WARNING: fmt.format(COLOR_YELLOW), logging.ERROR: fmt.format(COLOR_RED), logging.CRITICAL: fmt.format(COLOR_BOLD_RED), @@ -1099,12 +1531,21 @@ if __name__ == '__main__': cmd_map[alias] = cmd parser = argparse.ArgumentParser() - parser.add_argument('-d', '--deployment-name', default='veraison-deployment') - parser.add_argument('-f', '--force', action='store_true') - parser.add_argument('-W', '--wait-period', type=int, default=1) - parser.add_argument('-v', '--verbose', action='store_true') + parser.add_argument('-d', '--deployment-name', + help='the name for this deployment; this is used in a number ' + 'places, including AWS resources tags') + parser.add_argument('-f', '--force', action='store_true', + help='force overwrite of exiting resources') + parser.add_argument('-W', '--wait-period', type=int, default=1, + help='period (in seconds) to wait between polls to AWS for ' + 'long-running command progress') + parser.add_argument('-v', '--verbose', action='store_true', + help='show DEBUG level messages') + parser.add_argument('-q', '--quiet', action='store_true', + help='hide INFO level messages') parser.add_argument( '--cache-dir', default=xdg.BaseDirectory.save_data_path('veraison/aws'), + help='location that will be used for local deployment data', ) subparsers = parser.add_subparsers(dest='command', required=True) @@ -1113,6 +1554,21 @@ if __name__ == '__main__': command.register(subparsers) args = parser.parse_args() + + os.makedirs(args.cache_dir, exist_ok=True) + current_deployment_path = os.path.join(args.cache_dir, 'current_deployment') + if args.deployment_name: + with open(current_deployment_path, 'w') as wfh: + wfh.write(args.deployment_name) + else: + if os.path.isfile(current_deployment_path): + with open(current_deployment_path) as fh: + args.deployment_name = fh.read().strip() + else: + logging.critical('no current deployment exists; ' + 'please use -d/--deployment-name to specify') + sys.exit(1) + cmd = cmd_map[args.command] try: cmd.execute(args) diff --git a/deployments/aws/deployment.cfg b/deployments/aws/deployment.cfg index 5a5dd316..9663f57c 100644 --- a/deployments/aws/deployment.cfg +++ b/deployments/aws/deployment.cfg @@ -17,6 +17,7 @@ VTS_PORT=${VTS_PORT:-50051} PROVISIONING_PORT=${PROVISIONING_PORT:-8888} VERIFICATION_PORT=${VERIFICATION_PORT:-8080} MANAGEMENT_PORT=${MANAGEMENT_PORT:-8088} +KEYCLOAK_PORT=${MANAGEMENT_PORT:-11111} # The location of the Python venv that will be used to run the deployment # script. This venv must have appropriate dependencies installed (see @@ -39,3 +40,10 @@ VERAISON_AWS_SUBNET_ID=${VERAISON_AWS_SUBNET_ID:-} # Instances' security groups will be configures to allow connections from this # CIDR. VERAISON_AWS_ADMIN_CIDR=${VERAISON_AWS_ADMIN_CIDR:-217.140.96.0/20} + +# X.509 cert that will be used to sign serivice certificates. If not specified, +# a self-signed CA cert will be generated alongside service ones. +VERAISON_CA_CERT=${VERAISON_CA_CERT:-} + +# Private key associated with VERAISON_CA_CERT. +VERAISON_CA_CERT_KEY=${VERAISON_CA_CERT_KEY:-} diff --git a/deployments/aws/deployment.sh b/deployments/aws/deployment.sh index 16021087..0d56e315 100755 --- a/deployments/aws/deployment.sh +++ b/deployments/aws/deployment.sh @@ -51,12 +51,14 @@ function bootstrap() { cut -f2 -d= | tr -d \") case $distrib_id in - Arch) sudo pacman -Syy packer ssh;; + Arch) sudo pacman -Syy packer ssh openssl;; Ubuntu) - sudo apt --yes install curl + sudo apt update + sudo apt --yes install curl openssl + curl -fsSL https://apt.releases.hashicorp.com/gpg | sudo apt-key add - sudo apt-add-repository "deb [arch=amd64] https://apt.releases.hashicorp.com $(lsb_release -cs) main" - sudo apt update && sudo apt --yes install packer + sudo apt --yes install packer ;; *) echo -e "$_error: Boostrapping is currently only supported for Arch and Ubuntu." @@ -96,69 +98,34 @@ function bootstrap() { echo "$message" } -function create_image() { +function bringup() { + _check_installed openssl _check_installed packer - veraison create-image --vpc-id "${VERAISON_AWS_VPC_ID}" \ - --subnet-id "${VERAISON_AWS_SUBNET_ID}" -} - -function delete_image() { - veraison delete-image -} - -function create_key_pair() { - veraison create-key-pair -} - -function delete_key_pair() { - veraison delete-key-pair -} - -function create_deb() { - veraison create-deb -} - -function cache() { - veraison cache -} - -function create_stack() { - veraison create-stack --vpc-id "${VERAISON_AWS_VPC_ID}" \ + veraison configure --init \ + --vpc-id "${VERAISON_AWS_VPC_ID}" \ --subnet-id "${VERAISON_AWS_SUBNET_ID}" \ - --admin-cidr "${VERAISON_AWS_ADMIN_CIDR}" -} - -function delete_stack() { - veraison delete-stack -} - -function update_security_groups() { - veraison update-security-groups -} - -function update_certs() { - veraison update-certs -} - -function bringup() { - _check_installed packer + --admin-cidr "${VERAISON_AWS_ADMIN_CIDR}" \ + --region "${VERAISON_AWS_REGION}" veraison create-deb veraison create-key-pair - veraison create-image --vpc-id "${VERAISON_AWS_VPC_ID}" \ - --subnet-id "${VERAISON_AWS_SUBNET_ID}" - veraison create-stack --vpc-id "${VERAISON_AWS_VPC_ID}" \ - --subnet-id "${VERAISON_AWS_SUBNET_ID}" \ - --admin-cidr "${VERAISON_AWS_ADMIN_CIDR}" + veraison create-combined-image + veraison create-keycloak-image + veraison create-combined-stack + veraison update-security-groups + veraison create-certs --ca-cert "${VERAISON_CA_CERT}" \ + --ca-cert-key "${VERAISON_CA_CERT_KEY}" veraison update-certs } function teardown() { - veraison delete-stack - veraison delete-image + veraison delete-stack combined + veraison delete-image keycloak + veraison delete-image combined veraison delete-key-pair + veraison delete-certs veraison delete-deb } @@ -207,16 +174,6 @@ case $_command in bootstrap) bootstrap;; bringup) bringup;; teardown) teardown;; - create-image) create_image;; - delete-image) delete_image;; - create-key-pair | create-key) create_key_pair;; - delete-key-pair | delete-key) delete_key_pair;; - create-stack) create_stack;; - delete-stack) delete_stack;; - create-deb) create_deb;; - update-security-groups) update_security_groups;; - update-certs) update_certs;; - cache) cache;; *) echo -e "$_error: unexpected command: \"$_command\"";; esac # vim: set noet sts=8 sw=8: diff --git a/deployments/aws/misc/requirements.txt b/deployments/aws/misc/requirements.txt index 9032808c..0819375b 100644 --- a/deployments/aws/misc/requirements.txt +++ b/deployments/aws/misc/requirements.txt @@ -1,6 +1,7 @@ -ar==1.0.0 boto3==1.35.8 botocore==1.35.8 +cryptography==43.0.1 +envsubst==0.1.5 fabric==3.2.2 pyxdg==0.28 PyYAML==6.0.2 diff --git a/deployments/aws/templates/image-keycloak.pkr.hcl b/deployments/aws/templates/image-keycloak.pkr.hcl new file mode 100644 index 00000000..b12a831c --- /dev/null +++ b/deployments/aws/templates/image-keycloak.pkr.hcl @@ -0,0 +1,103 @@ +packer { + required_plugins { + amazon = { + version = ">= 1.2.8" + source = "github.com/hashicorp/amazon" + } + } +} + +variable "deployment_name" { + type = string +} + +variable "ami_name" { + type = string +} + +variable "vpc_id" { + type = string +} + +variable "region" { + type = string + default = "eu-west-1" +} + +variable "instance_type" { + type = string + default = "t2.micro" +} + +variable "subnet_id" { + type = string +} + +variable "keycloak_version" { + type = string + default = "25.0.5" +} + +variable "conf" { + type = string +} + +locals { + conf_dest = "/opt/keycloak/conf/keycloak.conf" +} + +source "amazon-ebs" "ubuntu" { + ami_name = "${var.ami_name}" + instance_type = "${var.instance_type}" + region = "${var.region}" + vpc_id = "${var.vpc_id}" + subnet_id = "${var.subnet_id}" + associate_public_ip_address = true + tags = { + veraison-deployment = "${var.deployment_name}" + } + source_ami_filter { + filters = { + name = "ubuntu/images/*ubuntu-jammy-22.04-amd64-server-*" + root-device-type = "ebs" + virtualization-type = "hvm" + architecture = "x86_64" + } + owners = ["099720109477"] # amazon + most_recent = true + } + ssh_username = "ubuntu" +} + +build { + name = "veraison-keycloak" + sources = [ + "source.amazon-ebs.ubuntu" + ] + + provisioner "file" { + source = "${var.conf}" + destination = "keycloak.conf" + } + + provisioner "shell" { + inline = [ + "sleep 1", + "sudo apt-get update", + "sudo apt-get update", # doing it twice as once doesn't seem to be enough .... + "sudo apt-get install -f --yes openjdk-21-jdk 2>&1", + + "sudo groupadd --system keycloak", + "sudo useradd --system --gid keycloak --no-create-home --shell /bin/false keycloak", + + "wget https://github.com/keycloak/keycloak/releases/download/${var.keycloak_version}/keycloak-${var.keycloak_version}.tar.gz", + "tar xf keycloak-${var.keycloak_version}.tar.gz", + "rm keycloak-${var.keycloak_version}.tar.gz", + "sudo mv keycloak-${var.keycloak_version} /opt/keycloak", + "sudo mv keycloak.conf /opt/keycloak/conf/keycloak.conf", + "sudo chown -R keycloak:keycloak /opt/keycloak" + ] + } +} + +# vim: set et sts=2 sw=2: diff --git a/deployments/aws/templates/keycloak.conf.template b/deployments/aws/templates/keycloak.conf.template new file mode 100644 index 00000000..cedb2c61 --- /dev/null +++ b/deployments/aws/templates/keycloak.conf.template @@ -0,0 +1,5 @@ +# See https://www.keycloak.org/server/all-config for all alvailable configuration. +http-enabled=false +https-port=${KEYCLOAK_PORT} +hostname-strict=false + diff --git a/deployments/aws/templates/stack-combined.yaml b/deployments/aws/templates/stack-combined.yaml index 0e625d55..a86d3208 100644 --- a/deployments/aws/templates/stack-combined.yaml +++ b/deployments/aws/templates/stack-combined.yaml @@ -16,7 +16,10 @@ Parameters: Description: | The name of an EC2 key pair that will be used to provide access to the instance. - InstanceImage: + CombinedImage: + Type: String + Description: ID of the AMI image to be used for the instance. + KeycloakImage: Type: String Description: ID of the AMI image to be used for the instance. AdminCidr: @@ -39,16 +42,19 @@ Parameters: Type: Number Description: TCP port on which the management service is listening Default: 8088 + KeycloakPort: + Type: Number + Description: TCP port on which the management service is listening + Default: 11111 ServiceInstanceType: Description: An EC2 instance type that will be used to run EC2 Instances Type: String Default: t2.micro - # TODO(setrofm): technicall, there is a set of AllowedValues that should be - # specified here (i.e. a valid type name), but since there is a ton of - # them, and right now I'm not sure which subset of those would even make - # sense for service instance, I'm leaving this unconstrained for now. + KeycloakInstanceType: + Description: An EC2 instance type that will be used to run EC2 Instances + Type: String + Default: t2.micro - Resources: VeraisonSecurityGroup: @@ -74,6 +80,10 @@ Resources: FromPort: !Ref ManagementPort ToPort: !Ref ManagementPort CidrIp: !Ref AdminCidr + - IpProtocol: tcp + FromPort: !Ref KeycloakPort + ToPort: !Ref KeycloakPort + CidrIp: !Ref AdminCidr Tags: - Key: veraison-deployment Value: !Ref DeploymentName @@ -83,13 +93,30 @@ Resources: Properties: KeyName: !Ref KeyName InstanceType: !Ref ServiceInstanceType - ImageId: !Ref InstanceImage + ImageId: !Ref CombinedImage SubnetId: !Ref SubnetId SecurityGroupIds: - !GetAtt VeraisonSecurityGroup.GroupId Tags: - Key: veraison-deployment Value: !Ref DeploymentName + - Key: deployment-instance-name + Value: combined + + KeycloakInstance: + Type: AWS::EC2::Instance + Properties: + KeyName: !Ref KeyName + InstanceType: !Ref ServiceInstanceType + ImageId: !Ref CombinedImage + SubnetId: !Ref SubnetId + SecurityGroupIds: + - !GetAtt VeraisonSecurityGroup.GroupId + Tags: + - Key: veraison-deployment + Value: !Ref DeploymentName + - Key: deployment-instance-name + Value: keycloak VeraisonIpAddress: Type: AWS::EC2::EIP @@ -100,3 +127,13 @@ Resources: Tags: - Key: veraison-deployment Value: !Ref DeploymentName + + KeycloakIpAddress: + Type: AWS::EC2::EIP + DependsOn: VeraisonInstance + Properties: + Domain: vpc + InstanceId: !Ref KeycloakInstance + Tags: + - Key: veraison-deployment + Value: !Ref DeploymentName diff --git a/deployments/aws/templates/stack-debug.yaml b/deployments/aws/templates/stack-debug.yaml new file mode 100644 index 00000000..0cf3dfad --- /dev/null +++ b/deployments/aws/templates/stack-debug.yaml @@ -0,0 +1,76 @@ +# Veraison stack +AWSTemplateFormatVersion: 2010-09-09 +Description: Veraison attestation verfication services + +Parameters: + # mandatory parameters (no defaults): + DeploymentName: + Type: String + Description: | + The name of this deployment. + VpcId: + Description: ID for the VPC into which Veraison will be deployed + Type: AWS::EC2::VPC::Id + KeyName: + Type: AWS::EC2::KeyPair::KeyName + Description: | + The name of an EC2 key pair that will be used to provide access to the + instance. + InstanceImage: + Type: String + Description: ID of the AMI image to be used for the instance. + AdminCidr: + Type: String + Description: CIDR to used to configure remote access + SubnetId: + Type: String + Description: ID of the subnet to be used for veraison deployment + + # optional parameters (have a default if are not specfied): + InstanceType: + Description: An EC2 instance type that will be used to run EC2 Instances + Type: String + Default: t2.micro + + +Resources: + + SecurityGroup: + Type: AWS::EC2::SecurityGroup + Properties: + VpcId: !Ref VpcId + GroupName: veraison-test + GroupDescription: Veraison services access + SecurityGroupIngress: + - IpProtocol: tcp + FromPort: 22 + ToPort: 22 + CidrIp: !Ref AdminCidr + Tags: + - Key: veraison-deployment + Value: !Ref DeploymentName + + Instance: + Type: AWS::EC2::Instance + Properties: + KeyName: !Ref KeyName + InstanceType: !Ref InstanceType + ImageId: !Ref InstanceImage + SubnetId: !Ref SubnetId + SecurityGroupIds: + - !GetAtt SecurityGroup.GroupId + Tags: + - Key: veraison-deployment + Value: !Ref DeploymentName + - Key: deployment-instance-name + Value: debug + + IpAddress: + Type: AWS::EC2::EIP + DependsOn: Instance + Properties: + Domain: vpc + InstanceId: !Ref Instance + Tags: + - Key: veraison-deployment + Value: !Ref DeploymentName diff --git a/deployments/debian/debian/postinst b/deployments/debian/debian/postinst index 83c5bba0..fdaa232a 100644 --- a/deployments/debian/debian/postinst +++ b/deployments/debian/debian/postinst @@ -22,5 +22,7 @@ if [ "$1" = "configure" ]; then chown -R "$VERAISON_USER":"$VERAISON_GROUP" /opt/veraison/certs chown -R "$VERAISON_USER":"$VERAISON_GROUP" /opt/veraison/stores + chmod 0500 /opt/veraison/certs/*.key + /opt/veraison/bin/veraison -s start-services fi diff --git a/end-to-end/end-to-end-aws b/end-to-end/end-to-end-aws index bd4557a7..a122582e 100755 --- a/end-to-end/end-to-end-aws +++ b/end-to-end/end-to-end-aws @@ -9,9 +9,8 @@ THIS_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) source "${THIS_DIR}/../deployments/aws/env/env.bash" -if [[ ! -d "$CONFIG_DIR" ]]; then - veraison --deployment-name "${VERAISON_AWS_DEPLOYMENT}" create-client-config --output-dir "$CONFIG_DIR" -fi +veraison --deployment-name "${VERAISON_AWS_DEPLOYMENT}" \ + create-client-config --output-dir "$CONFIG_DIR" function provision() { case $SCHEME in @@ -117,22 +116,22 @@ function _check_scheme() { } function _check_installed() { - local what=$1 + local what=$1 - if [[ "$(type -p "$what")" == "" ]]; then - echo -e "$_error: $what executable must be installed to use this command." - exit 1 - fi + if [[ "$(type -p "$what")" == "" ]]; then + echo -e "$_error: $what executable must be installed to use this command." + exit 1 + fi } _error='\e[0;31mERROR\e[0m' while getopts "hs:" opt; do - case "$opt" in - h) help; exit 0;; - s) SCHEME="$OPTARG";; - *) break;; - esac + case "$opt" in + h) help; exit 0;; + s) SCHEME="$OPTARG";; + *) break;; + esac done shift $((OPTIND-1)) @@ -150,4 +149,4 @@ case $command in verify) verify "$2";; *) echo "${_error}: unexpected command: \"$command\""; help;; esac -# vim: set et sts=4 sw=4 +# vim: set et sts=4 sw=4: