diff --git a/algo b/algo index 392464e1..117bbfa3 100755 --- a/algo +++ b/algo @@ -287,7 +287,64 @@ Enter the number of your desired region: esac ROLES="ec2 vpn cloud" - EXTRA_VARS="aws_access_key=$aws_access_key aws_secret_key=$aws_secret_key aws_server_name=$aws_server_name ssh_public_key=$ssh_public_key region=$region" + EXTRA_VARS="aws_access_key=$aws_access_key aws_secret_key=$aws_secret_key aws_server_name=$aws_server_name ssh_public_key=$ssh_public_key algo_region=$region" +} + +lightsail () { +read -p " +Enter your aws_access_key (http://docs.aws.amazon.com/general/latest/gr/managing-aws-access-keys.html) +Note: Make sure to use an IAM user with an acceptable policy attached (see https://github.com/trailofbits/algo/blob/master/docs/deploy-from-ansible.md). +$ADDITIONAL_PROMPT +[AKIA...]: " -rs aws_access_key + +read -p " + +Enter your aws_secret_key (http://docs.aws.amazon.com/general/latest/gr/managing-aws-access-keys.html) +$ADDITIONAL_PROMPT +[ABCD...]: " -rs aws_secret_key + +read -p " + +Name the vpn server: +[algo.local]: " -r algo_server_name + algo_server_name=${algo_server_name:-algo.local} + + read -p " + + What region should the server be located in? + 1. us-east-1 US East (N. Virginia) + 2. us-east-2 US East (Ohio) + 3. us-west-1 US West (N. California) + 4. us-west-2 US West (Oregon) + 5. ap-south-1 Asia Pacific (Mumbai) + 6. ap-northeast-2 Asia Pacific (Seoul) + 7. ap-southeast-1 Asia Pacific (Singapore) + 8. ap-southeast-2 Asia Pacific (Sydney) + 9. ap-northeast-1 Asia Pacific (Tokyo) + 10. eu-central-1 EU (Frankfurt) + 11. eu-west-1 EU (Ireland) + 12. eu-west-2 EU (London) +Enter the number of your desired region: +[1]: " -r algo_region +algo_region=${algo_region:-1} + + case "$algo_region" in + 1) region="us-east-1" ;; + 2) region="us-east-2" ;; + 3) region="us-west-1" ;; + 4) region="us-west-2" ;; + 5) region="ap-south-1" ;; + 6) region="ap-northeast-2" ;; + 7) region="ap-southeast-1" ;; + 8) region="ap-southeast-2" ;; + 9) region="ap-northeast-1" ;; + 10) region="eu-central-1" ;; + 11) region="eu-west-1" ;; + 12) region="eu-west-2";; + esac + + ROLES="lightsail vpn cloud" + EXTRA_VARS="aws_access_key=$aws_access_key aws_secret_key=$aws_secret_key algo_server_name=$algo_server_name region=$region" } gce () { @@ -433,10 +490,11 @@ algo_provisioning () { echo -n " What provider would you like to use? 1. DigitalOcean - 2. Amazon EC2 - 3. Microsoft Azure - 4. Google Compute Engine - 5. Install to existing Ubuntu 16.04 server + 2. Amazon Lightsail + 3. Amazon EC2 + 4. Microsoft Azure + 5. Google Compute Engine + 6. Install to existing Ubuntu 16.04 server Enter the number of your desired provider : " @@ -445,10 +503,11 @@ Enter the number of your desired provider case "$N" in 1) digitalocean; ;; - 2) ec2; ;; - 3) azure; ;; - 4) gce; ;; - 5) non_cloud; ;; + 2) lightsail; ;; + 3) ec2; ;; + 4) azure; ;; + 5) gce; ;; + 6) non_cloud; ;; *) exit 1 ;; esac diff --git a/config.cfg b/config.cfg index 40382e61..f42cfd75 100644 --- a/config.cfg +++ b/config.cfg @@ -86,6 +86,9 @@ cloud_providers: gce: size: f1-micro image: ubuntu-1604 # ubuntu-1604 / ubuntu-1704 + lightsail: + size: nano_1_0 + image: ubuntu_16_04 local: fail_hint: diff --git a/deploy.yml b/deploy.yml index 9869d866..a1cbec4f 100644 --- a/deploy.yml +++ b/deploy.yml @@ -26,6 +26,7 @@ - { role: cloud-ec2, tags: ['ec2'] } - { role: cloud-gce, tags: ['gce'] } - { role: cloud-azure, tags: ['azure'] } + - { role: cloud-lightsail, tags: ['lightsail'] } - { role: local, tags: ['local'] } post_tasks: @@ -52,7 +53,7 @@ - block: - name: Common pre-tasks include_tasks: playbooks/common.yml - tags: [ 'digitalocean', 'ec2', 'gce', 'azure', 'local', 'pre' ] + tags: [ 'digitalocean', 'ec2', 'gce', 'azure', 'lightsail', 'local', 'pre' ] rescue: - debug: var=fail_hint tags: always diff --git a/library/lightsail.py b/library/lightsail.py new file mode 100644 index 00000000..99e49ac7 --- /dev/null +++ b/library/lightsail.py @@ -0,0 +1,551 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + +DOCUMENTATION = ''' +--- +module: lightsail +short_description: Create or delete a virtual machine instance in AWS Lightsail +description: + - Creates or instances in AWS Lightsail and optionally wait for it to be 'running'. +version_added: "2.4" +author: "Nick Ball (@nickball)" +options: + state: + description: + - Indicate desired state of the target. + default: present + choices: ['present', 'absent', 'running', 'restarted', 'stopped'] + name: + description: + - Name of the instance + required: true + default : null + zone: + description: + - AWS availability zone in which to launch the instance. Required when state='present' + required: false + default: null + blueprint_id: + description: + - ID of the instance blueprint image. Required when state='present' + required: false + default: null + bundle_id: + description: + - Bundle of specification info for the instance. Required when state='present' + required: false + default: null + user_data: + description: + - Launch script that can configure the instance with additional data + required: false + default: null + key_pair_name: + description: + - Name of the key pair to use with the instance + required: false + default: null + wait: + description: + - Wait for the instance to be in state 'running' before returning. If wait is "no" an ip_address may not be returned + default: "yes" + choices: [ "yes", "no" ] + wait_timeout: + description: + - How long before wait gives up, in seconds. + default: 300 + open_ports: + description: + - Adds public ports to an Amazon Lightsail instance. + default: null + suboptions: + from_port: + description: Begin of the range + required: true + default: null + to_port: + description: End of the range + required: true + default: null + protocol: + description: Accepted traffic protocol. + required: true + choices: + - udp + - tcp + - all + default: null +requirements: + - "python >= 2.6" + - boto3 + +extends_documentation_fragment: + - aws + - ec2 +''' + + +EXAMPLES = ''' +# Create a new Lightsail instance, register the instance details +- lightsail: + state: present + name: myinstance + region: us-east-1 + zone: us-east-1a + blueprint_id: ubuntu_16_04 + bundle_id: nano_1_0 + key_pair_name: id_rsa + user_data: " echo 'hello world' > /home/ubuntu/test.txt" + wait_timeout: 500 + open_ports: + - from_port: 4500 + to_port: 4500 + protocol: udp + - from_port: 500 + to_port: 500 + protocol: udp + register: my_instance + +- debug: + msg: "Name is {{ my_instance.instance.name }}" + +- debug: + msg: "IP is {{ my_instance.instance.publicIpAddress }}" + +# Delete an instance if present +- lightsail: + state: absent + region: us-east-1 + name: myinstance + +''' + +RETURN = ''' +changed: + description: if a snapshot has been modified/created + returned: always + type: bool + sample: + changed: true +instance: + description: instance data + returned: always + type: dict + sample: + arn: "arn:aws:lightsail:us-east-1:448830907657:Instance/1fef0175-d6c8-480e-84fa-214f969cda87" + blueprint_id: "ubuntu_16_04" + blueprint_name: "Ubuntu" + bundle_id: "nano_1_0" + created_at: "2017-03-27T08:38:59.714000-04:00" + hardware: + cpu_count: 1 + ram_size_in_gb: 0.5 + is_static_ip: false + location: + availability_zone: "us-east-1a" + region_name: "us-east-1" + name: "my_instance" + networking: + monthly_transfer: + gb_per_month_allocated: 1024 + ports: + - access_direction: "inbound" + access_from: "Anywhere (0.0.0.0/0)" + access_type: "public" + common_name: "" + from_port: 80 + protocol: tcp + to_port: 80 + - access_direction: "inbound" + access_from: "Anywhere (0.0.0.0/0)" + access_type: "public" + common_name: "" + from_port: 22 + protocol: tcp + to_port: 22 + private_ip_address: "172.26.8.14" + public_ip_address: "34.207.152.202" + resource_type: "Instance" + ssh_key_name: "keypair" + state: + code: 16 + name: running + support_code: "588307843083/i-0997c97831ee21e33" + username: "ubuntu" +''' + +import time +import traceback + +try: + import botocore + HAS_BOTOCORE = True +except ImportError: + HAS_BOTOCORE = False + +try: + import boto3 +except ImportError: + # will be caught by imported HAS_BOTO3 + pass + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.ec2 import (ec2_argument_spec, get_aws_connection_info, boto3_conn, + HAS_BOTO3, camel_dict_to_snake_dict) + + +def create_instance(module, client, instance_name): + """ + Create an instance + + module: Ansible module object + client: authenticated lightsail connection object + instance_name: name of instance to delete + + Returns a dictionary of instance information + about the new instance. + + """ + + changed = False + + # Check if instance already exists + inst = None + try: + inst = _find_instance_info(client, instance_name) + except botocore.exceptions.ClientError as e: + if e.response['Error']['Code'] != 'NotFoundException': + module.fail_json(msg='Error finding instance {0}, error: {1}'.format(instance_name, e)) + + zone = module.params.get('zone') + blueprint_id = module.params.get('blueprint_id') + bundle_id = module.params.get('bundle_id') + user_data = module.params.get('user_data') + user_data = '' if user_data is None else user_data + + wait = module.params.get('wait') + wait_timeout = int(module.params.get('wait_timeout')) + wait_max = time.time() + wait_timeout + + if module.params.get('key_pair_name'): + key_pair_name = module.params.get('key_pair_name') + else: + key_pair_name = '' + + if module.params.get('open_ports'): + open_ports = module.params.get('open_ports') + else: + open_ports = '[]' + + resp = None + if inst is None: + try: + resp = client.create_instances( + instanceNames=[ + instance_name + ], + availabilityZone=zone, + blueprintId=blueprint_id, + bundleId=bundle_id, + userData=user_data, + keyPairName=key_pair_name, + ) + resp = resp['operations'][0] + except botocore.exceptions.ClientError as e: + module.fail_json(msg='Unable to create instance {0}, error: {1}'.format(instance_name, e)) + + inst = _find_instance_info(client, instance_name) + + # Wait for instance to become running + if wait: + while (wait_max > time.time()) and (inst is not None and inst['state']['name'] != "running"): + try: + time.sleep(2) + inst = _find_instance_info(client, instance_name) + except botocore.exceptions.ClientError as e: + if e.response['ResponseMetadata']['HTTPStatusCode'] == "403": + module.fail_json(msg="Failed to start/stop instance {0}. Check that you have permissions to perform the operation".format(instance_name), + exception=traceback.format_exc()) + elif e.response['Error']['Code'] == "RequestExpired": + module.fail_json(msg="RequestExpired: Failed to start instance {0}.".format(instance_name), exception=traceback.format_exc()) + time.sleep(1) + + # Timed out + if wait and not changed and wait_max <= time.time(): + module.fail_json(msg="Wait for instance start timeout at %s" % time.asctime()) + + # Attempt to open ports + if open_ports: + if inst is not None: + try: + for o in open_ports: + resp = client.open_instance_public_ports( + instanceName=instance_name, + portInfo={ + 'fromPort': o['from_port'], + 'toPort': o['to_port'], + 'protocol': o['protocol'] + } + ) + except botocore.exceptions.ClientError as e: + module.fail_json(msg='Error opening ports for instance {0}, error: {1}'.format(instance_name, e)) + + changed = True + + return (changed, inst) + + +def delete_instance(module, client, instance_name): + """ + Terminates an instance + + module: Ansible module object + client: authenticated lightsail connection object + instance_name: name of instance to delete + + Returns a dictionary of instance information + about the instance deleted (pre-deletion). + + If the instance to be deleted is running + "changed" will be set to False. + + """ + + # It looks like deleting removes the instance immediately, nothing to wait for + wait = module.params.get('wait') + wait_timeout = int(module.params.get('wait_timeout')) + wait_max = time.time() + wait_timeout + + changed = False + + inst = None + try: + inst = _find_instance_info(client, instance_name) + except botocore.exceptions.ClientError as e: + if e.response['Error']['Code'] != 'NotFoundException': + module.fail_json(msg='Error finding instance {0}, error: {1}'.format(instance_name, e)) + + # Wait for instance to exit transition state before deleting + if wait: + while wait_max > time.time() and inst is not None and inst['state']['name'] in ('pending', 'stopping'): + try: + time.sleep(5) + inst = _find_instance_info(client, instance_name) + except botocore.exceptions.ClientError as e: + if e.response['ResponseMetadata']['HTTPStatusCode'] == "403": + module.fail_json(msg="Failed to delete instance {0}. Check that you have permissions to perform the operation.".format(instance_name), + exception=traceback.format_exc()) + elif e.response['Error']['Code'] == "RequestExpired": + module.fail_json(msg="RequestExpired: Failed to delete instance {0}.".format(instance_name), exception=traceback.format_exc()) + # sleep and retry + time.sleep(10) + + # Attempt to delete + if inst is not None: + while not changed and ((wait and wait_max > time.time()) or (not wait)): + try: + client.delete_instance(instanceName=instance_name) + changed = True + except botocore.exceptions.ClientError as e: + module.fail_json(msg='Error deleting instance {0}, error: {1}'.format(instance_name, e)) + + # Timed out + if wait and not changed and wait_max <= time.time(): + module.fail_json(msg="wait for instance delete timeout at %s" % time.asctime()) + + return (changed, inst) + + +def restart_instance(module, client, instance_name): + """ + Reboot an existing instance + + module: Ansible module object + client: authenticated lightsail connection object + instance_name: name of instance to reboot + + Returns a dictionary of instance information + about the restarted instance + + If the instance was not able to reboot, + "changed" will be set to False. + + Wait will not apply here as this is an OS-level operation + """ + wait = module.params.get('wait') + wait_timeout = int(module.params.get('wait_timeout')) + wait_max = time.time() + wait_timeout + + changed = False + + inst = None + try: + inst = _find_instance_info(client, instance_name) + except botocore.exceptions.ClientError as e: + if e.response['Error']['Code'] != 'NotFoundException': + module.fail_json(msg='Error finding instance {0}, error: {1}'.format(instance_name, e)) + + # Wait for instance to exit transition state before state change + if wait: + while wait_max > time.time() and inst is not None and inst['state']['name'] in ('pending', 'stopping'): + try: + time.sleep(5) + inst = _find_instance_info(client, instance_name) + except botocore.exceptions.ClientError as e: + if e.response['ResponseMetadata']['HTTPStatusCode'] == "403": + module.fail_json(msg="Failed to restart instance {0}. Check that you have permissions to perform the operation.".format(instance_name), + exception=traceback.format_exc()) + elif e.response['Error']['Code'] == "RequestExpired": + module.fail_json(msg="RequestExpired: Failed to restart instance {0}.".format(instance_name), exception=traceback.format_exc()) + time.sleep(3) + + # send reboot + if inst is not None: + try: + client.reboot_instance(instanceName=instance_name) + except botocore.exceptions.ClientError as e: + if e.response['Error']['Code'] != 'NotFoundException': + module.fail_json(msg='Unable to reboot instance {0}, error: {1}'.format(instance_name, e)) + changed = True + + return (changed, inst) + + +def startstop_instance(module, client, instance_name, state): + """ + Starts or stops an existing instance + + module: Ansible module object + client: authenticated lightsail connection object + instance_name: name of instance to start/stop + state: Target state ("running" or "stopped") + + Returns a dictionary of instance information + about the instance started/stopped + + If the instance was not able to state change, + "changed" will be set to False. + + """ + wait = module.params.get('wait') + wait_timeout = int(module.params.get('wait_timeout')) + wait_max = time.time() + wait_timeout + + changed = False + + inst = None + try: + inst = _find_instance_info(client, instance_name) + except botocore.exceptions.ClientError as e: + if e.response['Error']['Code'] != 'NotFoundException': + module.fail_json(msg='Error finding instance {0}, error: {1}'.format(instance_name, e)) + + # Wait for instance to exit transition state before state change + if wait: + while wait_max > time.time() and inst is not None and inst['state']['name'] in ('pending', 'stopping'): + try: + time.sleep(5) + inst = _find_instance_info(client, instance_name) + except botocore.exceptions.ClientError as e: + if e.response['ResponseMetadata']['HTTPStatusCode'] == "403": + module.fail_json(msg="Failed to start/stop instance {0}. Check that you have permissions to perform the operation".format(instance_name), + exception=traceback.format_exc()) + elif e.response['Error']['Code'] == "RequestExpired": + module.fail_json(msg="RequestExpired: Failed to start/stop instance {0}.".format(instance_name), exception=traceback.format_exc()) + time.sleep(1) + + # Try state change + if inst is not None and inst['state']['name'] != state: + try: + if state == 'running': + client.start_instance(instanceName=instance_name) + else: + client.stop_instance(instanceName=instance_name) + except botocore.exceptions.ClientError as e: + module.fail_json(msg='Unable to change state for instance {0}, error: {1}'.format(instance_name, e)) + changed = True + # Grab current instance info + inst = _find_instance_info(client, instance_name) + + return (changed, inst) + + +def core(module): + region, ec2_url, aws_connect_kwargs = get_aws_connection_info(module, boto3=True) + if not region: + module.fail_json(msg='region must be specified') + + client = None + try: + client = boto3_conn(module, conn_type='client', resource='lightsail', + region=region, endpoint=ec2_url, **aws_connect_kwargs) + except (botocore.exceptions.ClientError, botocore.exceptions.ValidationError) as e: + module.fail_json(msg='Failed while connecting to the lightsail service: %s' % e, exception=traceback.format_exc()) + + changed = False + state = module.params['state'] + name = module.params['name'] + + if state == 'absent': + changed, instance_dict = delete_instance(module, client, name) + elif state in ('running', 'stopped'): + changed, instance_dict = startstop_instance(module, client, name, state) + elif state == 'restarted': + changed, instance_dict = restart_instance(module, client, name) + elif state == 'present': + changed, instance_dict = create_instance(module, client, name) + + module.exit_json(changed=changed, instance=camel_dict_to_snake_dict(instance_dict)) + + +def _find_instance_info(client, instance_name): + ''' handle exceptions where this function is called ''' + inst = None + try: + inst = client.get_instance(instanceName=instance_name) + except botocore.exceptions.ClientError as e: + raise + return inst['instance'] + + +def main(): + argument_spec = ec2_argument_spec() + argument_spec.update(dict( + name=dict(type='str', required=True), + state=dict(type='str', default='present', choices=['present', 'absent', 'stopped', 'running', 'restarted']), + zone=dict(type='str'), + blueprint_id=dict(type='str'), + bundle_id=dict(type='str'), + key_pair_name=dict(type='str'), + user_data=dict(type='str'), + wait=dict(type='bool', default=True), + wait_timeout=dict(default=300), + open_ports=dict(type='list') + )) + + module = AnsibleModule(argument_spec=argument_spec) + + if not HAS_BOTO3: + module.fail_json(msg='Python module "boto3" is missing, please install it') + + if not HAS_BOTOCORE: + module.fail_json(msg='Python module "botocore" is missing, please install it') + + try: + core(module) + except (botocore.exceptions.ClientError, Exception) as e: + module.fail_json(msg=str(e), exception=traceback.format_exc()) + + +if __name__ == '__main__': + main() diff --git a/roles/cloud-lightsail/tasks/main.yml b/roles/cloud-lightsail/tasks/main.yml new file mode 100644 index 00000000..ce28ceb4 --- /dev/null +++ b/roles/cloud-lightsail/tasks/main.yml @@ -0,0 +1,52 @@ +- block: + - set_fact: + access_key: "{{ aws_access_key | default(lookup('env','AWS_ACCESS_KEY_ID'), true) }}" + secret_key: "{{ aws_secret_key | default(lookup('env','AWS_SECRET_ACCESS_KEY'), true) }}" + region: "{{ algo_region | default(lookup('env','AWS_DEFAULT_REGION'), true) }}" + + - name: Create an instance + lightsail: + aws_access_key: "{{ access_key }}" + aws_secret_key: "{{ secret_key }}" + name: "{{ algo_server_name }}" + state: present + region: "{{ region }}" + zone: "{{ region }}a" + blueprint_id: "{{ cloud_providers.lightsail.image }}" + bundle_id: "{{ cloud_providers.lightsail.size }}" + wait_timeout: 300 + open_ports: + - from_port: 4500 + to_port: 4500 + protocol: udp + - from_port: 500 + to_port: 500 + protocol: udp + user_data: | + #!/bin/bash + mkdir -p /home/ubuntu/.ssh/ + echo "{{ lookup('file', '{{ SSH_keys.public }}') }}" >> /home/ubuntu/.ssh/authorized_keys + chown -R ubuntu: /home/ubuntu/.ssh/ + chmod 0700 /home/ubuntu/.ssh/ + chmod 0600 /home/ubuntu/.ssh/* + test + register: algo_instance + + - set_fact: + cloud_instance_ip: "{{ algo_instance['instance']['public_ip_address'] }}" + + - name: Add new instance to host group + add_host: + hostname: "{{ cloud_instance_ip }}" + groupname: vpn-host + ansible_ssh_user: ubuntu + ansible_python_interpreter: "/usr/bin/python2.7" + ansible_ssh_private_key_file: "{{ SSH_keys.private }}" + cloud_provider: lightsail + ipv6_support: no + + rescue: + - debug: var=fail_hint + tags: always + - fail: + tags: always