diff --git a/docs/deploy-from-ansible.md b/docs/deploy-from-ansible.md index 01ecf21..5bcb87d 100644 --- a/docs/deploy-from-ansible.md +++ b/docs/deploy-from-ansible.md @@ -242,7 +242,25 @@ Possible options can be gathered via cli `aws lightsail get-regions` "lightsail:GetRegions", "lightsail:GetInstance", "lightsail:CreateInstances", - "lightsail:OpenInstancePublicPorts" + "lightsail:DisableAddOn", + "lightsail:PutInstancePublicPorts", + "lightsail:StartInstance", + "lightsail:GetStaticIp", + "lightsail:AllocateStaticIp", + "lightsail:AttachStaticIp" + ], + "Resource": [ + "*" + ] + }, + { + "Sid": "DeployCloudFormationStack", + "Effect": "Allow", + "Action": [ + "cloudformation:CreateStack", + "cloudformation:DescribeStacks", + "cloudformation:DescribeStackEvents", + "cloudformation:ListStackResources" ], "Resource": [ "*" diff --git a/library/lightsail.py b/library/lightsail.py deleted file mode 100644 index 99e49ac..0000000 --- a/library/lightsail.py +++ /dev/null @@ -1,551 +0,0 @@ -#!/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/files/stack.yaml b/roles/cloud-lightsail/files/stack.yaml new file mode 100644 index 0000000..8bb2135 --- /dev/null +++ b/roles/cloud-lightsail/files/stack.yaml @@ -0,0 +1,73 @@ +AWSTemplateFormatVersion: '2010-09-09' +Description: 'Algo VPN stack (LightSail)' +Parameters: + InstanceTypeParameter: + Type: String + Default: 'nano_2_0' + ImageIdParameter: + Type: String + Default: 'ubuntu_20_04' + WireGuardPort: + Type: String + Default: '51820' + SshPort: + Type: String + Default: '4160' + UserData: + Type: String + Default: 'true' +Resources: + Instance: + Type: AWS::Lightsail::Instance + Properties: + BlueprintId: + Ref: ImageIdParameter + BundleId: + Ref: InstanceTypeParameter + InstanceName: !Ref AWS::StackName + Networking: + Ports: + - AccessDirection: inbound + Cidrs: ['0.0.0.0/0'] + Ipv6Cidrs: ['::/0'] + CommonName: SSH + FromPort: !Ref SshPort + ToPort: !Ref SshPort + Protocol: tcp + - AccessDirection: inbound + Cidrs: ['0.0.0.0/0'] + Ipv6Cidrs: ['::/0'] + CommonName: WireGuard + FromPort: !Ref WireGuardPort + ToPort: !Ref WireGuardPort + Protocol: udp + - AccessDirection: inbound + Cidrs: ['0.0.0.0/0'] + Ipv6Cidrs: ['::/0'] + CommonName: IPSec-4500 + FromPort: 4500 + ToPort: 4500 + Protocol: udp + - AccessDirection: inbound + Cidrs: ['0.0.0.0/0'] + Ipv6Cidrs: ['::/0'] + CommonName: IPSec-500 + FromPort: 500 + ToPort: 500 + Protocol: udp + Tags: + - Key: Name + Value: !Ref AWS::StackName + UserData: !Ref UserData + + StaticIP: + Type: AWS::Lightsail::StaticIp + Properties: + AttachedTo: !Ref Instance + StaticIpName: !Join [ "-", [ !Ref AWS::StackName, "ip" ] ] + DependsOn: + - Instance + +Outputs: + IpAddress: + Value: !GetAtt [StaticIP, IpAddress] diff --git a/roles/cloud-lightsail/tasks/cloudformation.yml b/roles/cloud-lightsail/tasks/cloudformation.yml new file mode 100644 index 0000000..1e3a6a0 --- /dev/null +++ b/roles/cloud-lightsail/tasks/cloudformation.yml @@ -0,0 +1,19 @@ +--- +- name: Deploy the template + cloudformation: + aws_access_key: "{{ access_key }}" + aws_secret_key: "{{ secret_key }}" + stack_name: "{{ stack_name }}" + state: "present" + region: "{{ algo_region }}" + template: roles/cloud-lightsail/files/stack.yaml + template_parameters: + InstanceTypeParameter: "{{ cloud_providers.lightsail.size }}" + ImageIdParameter: "{{ cloud_providers.lightsail.image }}" + WireGuardPort: "{{ wireguard_port }}" + SshPort: "{{ ssh_port }}" + UserData: "{{ lookup('template', 'files/cloud-init/base.sh') }}" + tags: + Environment: Algo + Lightsail: true + register: stack diff --git a/roles/cloud-lightsail/tasks/main.yml b/roles/cloud-lightsail/tasks/main.yml index 0ee04b4..2d8d702 100644 --- a/roles/cloud-lightsail/tasks/main.yml +++ b/roles/cloud-lightsail/tasks/main.yml @@ -5,36 +5,11 @@ - name: Include prompts import_tasks: prompts.yml -- name: Create an instance - lightsail: - aws_access_key: "{{ access_key }}" - aws_secret_key: "{{ secret_key }}" - name: "{{ algo_server_name }}" - state: present - region: "{{ algo_region }}" - zone: "{{ algo_region }}a" - blueprint_id: "{{ cloud_providers.lightsail.image }}" - bundle_id: "{{ cloud_providers.lightsail.size }}" - wait_timeout: "300" - open_ports: - - from_port: "{{ ssh_port }}" - to_port: "{{ ssh_port }}" - protocol: tcp - - from_port: 4500 - to_port: 4500 - protocol: udp - - from_port: 500 - to_port: 500 - protocol: udp - - from_port: "{{ wireguard_port }}" - to_port: "{{ wireguard_port }}" - protocol: udp - user_data: | - {{ lookup('template', 'files/cloud-init/base.sh') }} - register: algo_instance +- name: Deploy the stack + import_tasks: cloudformation.yml - set_fact: - cloud_instance_ip: "{{ algo_instance['instance']['public_ip_address'] }}" + cloud_instance_ip: "{{ stack.stack_outputs.IpAddress }}" ansible_ssh_user: algo ansible_ssh_port: "{{ ssh_port }}" cloudinit: true diff --git a/roles/cloud-lightsail/tasks/prompts.yml b/roles/cloud-lightsail/tasks/prompts.yml index b1584f4..08605de 100644 --- a/roles/cloud-lightsail/tasks/prompts.yml +++ b/roles/cloud-lightsail/tasks/prompts.yml @@ -55,6 +55,7 @@ when: region is undefined - set_fact: + stack_name: "{{ algo_server_name | replace('.', '-') }}" algo_region: >- {% if region is defined %}{{ region }} {%- elif _algo_region.user_input %}{{ lightsail_regions[_algo_region.user_input | int -1 ]['name'] }}