Repository: ansible-collections/community.aws

Issue:
Add a module to manage AWS Lightsail Static IPs
<!--- Verify first that your feature was not already discussed on GitHub -->
<!--- Complete *all* sections as described, this form is processed automatically -->

##### SUMMARY
Issue #174 requests adding the ability to manage firewall and static IP settings in a Lightsail VPS. I suggest that it may be beneficial to have a module that can manage static IPs for Lightsail (which are managed seperately from VMs).

##### ISSUE TYPE
- Feature Idea

##### COMPONENT NAME
community.aws.lightsail

##### ADDITIONAL INFORMATION
In Lightsail, static IP addresses are managed separately from VPSes. You create and manage static IPs, and can attach them to a VPS as per #174. A module that can mange these IPs would be a prerequisite for end-to-end configuration management for Lightsail.

I already have a basic version of this and am planning on submitting a PR in the next couple of weeks once it's cleaned up & tested

Relevant API Documentation:

* https://docs.aws.amazon.com/lightsail/2016-11-28/api-reference/API_AllocateStaticIp.html
* https://docs.aws.amazon.com/lightsail/2016-11-28/api-reference/API_GetStaticIps.html
* https://docs.aws.amazon.com/lightsail/2016-11-28/api-reference/API_ReleaseStaticIp.html

Possible Playbook notation:
```yaml
- name: Create a new Lightsail static IP
  community.aws.lightsail_static_ip:
    state: present
    name: static_ip_1
    aws_access_key: XXXXXXXX
```


Discussion:
Files identified in the description:
None

If these files are inaccurate, please update the `component name` section of the description or use the `!component` bot command.

[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->

Resolving patch:
diff --git a/plugins/modules/lightsail_static_ip.py b/plugins/modules/lightsail_static_ip.py
new file mode 100644
--- /dev/null
+++ b/plugins/modules/lightsail_static_ip.py
@@ -0,0 +1,148 @@
+#!/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
+
+
+DOCUMENTATION = '''
+---
+module: lightsail_static_ip
+version_added: 4.1.0
+short_description: Manage static IP addresses in AWS Lightsail
+description:
+  - Manage static IP addresses in AWS Lightsail.
+author:
+  - "Daniel Cotton (@danielcotton)"
+options:
+  state:
+    description:
+      - Describes the desired state.
+    default: present
+    choices: ['present', 'absent']
+    type: str
+  name:
+    description: Name of the static IP.
+    required: true
+    type: str
+extends_documentation_fragment:
+  - amazon.aws.aws
+  - amazon.aws.ec2
+'''
+
+
+EXAMPLES = '''
+- name: Provision a Lightsail static IP
+  community.aws.lightsail_static_ip:
+    state: present
+    name: my_static_ip
+  register: my_ip
+
+- name: Remove a static IP
+  community.aws.lightsail_static_ip:
+    state: absent
+    name: my_static_ip
+'''
+
+RETURN = '''
+static_ip:
+  description: static_ipinstance data
+  returned: always
+  type: dict
+  sample:
+    arn: "arn:aws:lightsail:ap-southeast-2:184297340509:StaticIp/d8f47672-c261-4443-a484-4a2ec983db9a"
+    created_at: "2021-02-28T00:04:05.202000+10:30"
+    ip_address: "192.0.2.5"
+    is_attached: false
+    location:
+        availability_zone: all
+        region_name: ap-southeast-2
+    name: "static_ip"
+    resource_type: StaticIp
+    support_code: "677585553206/192.0.2.5"
+'''
+
+try:
+    import botocore
+except ImportError:
+    # will be caught by AnsibleAWSModule
+    pass
+
+from ansible.module_utils.common.dict_transformations import camel_dict_to_snake_dict
+
+from ansible_collections.amazon.aws.plugins.module_utils.core import AnsibleAWSModule
+from ansible_collections.amazon.aws.plugins.module_utils.core import is_boto3_error_code
+
+
+def find_static_ip_info(module, client, static_ip_name, fail_if_not_found=False):
+
+    try:
+        res = client.get_static_ip(staticIpName=static_ip_name)
+    except is_boto3_error_code('NotFoundException') as e:
+        if fail_if_not_found:
+            module.fail_json_aws(e)
+        return None
+    except botocore.exceptions.ClientError as e:  # pylint: disable=duplicate-except
+        module.fail_json_aws(e)
+    return res['staticIp']
+
+
+def create_static_ip(module, client, static_ip_name):
+
+    inst = find_static_ip_info(module, client, static_ip_name)
+    if inst:
+        module.exit_json(changed=False, static_ip=camel_dict_to_snake_dict(inst))
+    else:
+        create_params = {'staticIpName': static_ip_name}
+
+        try:
+            client.allocate_static_ip(**create_params)
+        except botocore.exceptions.ClientError as e:
+            module.fail_json_aws(e)
+
+        inst = find_static_ip_info(module, client, static_ip_name, fail_if_not_found=True)
+
+        module.exit_json(changed=True, static_ip=camel_dict_to_snake_dict(inst))
+
+
+def delete_static_ip(module, client, static_ip_name):
+
+    inst = find_static_ip_info(module, client, static_ip_name)
+    if inst is None:
+        module.exit_json(changed=False, static_ip={})
+
+    changed = False
+    try:
+        client.release_static_ip(staticIpName=static_ip_name)
+        changed = True
+    except botocore.exceptions.ClientError as e:
+        module.fail_json_aws(e)
+
+    module.exit_json(changed=changed, static_ip=camel_dict_to_snake_dict(inst))
+
+
+def main():
+
+    argument_spec = dict(
+        name=dict(type='str', required=True),
+        state=dict(type='str', default='present', choices=['present', 'absent']),
+    )
+
+    module = AnsibleAWSModule(argument_spec=argument_spec)
+
+    client = module.client('lightsail')
+
+    name = module.params.get('name')
+    state = module.params.get('state')
+
+    if state == 'present':
+        create_static_ip(module, client, name)
+    elif state == 'absent':
+        delete_static_ip(module, client, name)
+
+
+if __name__ == '__main__':
+    main()


Test patch:
diff --git a/tests/integration/targets/lightsail_static_ip/aliases b/tests/integration/targets/lightsail_static_ip/aliases
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/lightsail_static_ip/aliases
@@ -0,0 +1 @@
+cloud/aws
diff --git a/tests/integration/targets/lightsail_static_ip/defaults/main.yml b/tests/integration/targets/lightsail_static_ip/defaults/main.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/lightsail_static_ip/defaults/main.yml
@@ -0,0 +1 @@
+static_ip_name: "{{ resource_prefix }}_static_ip"
diff --git a/tests/integration/targets/lightsail_static_ip/tasks/main.yml b/tests/integration/targets/lightsail_static_ip/tasks/main.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/lightsail_static_ip/tasks/main.yml
@@ -0,0 +1,96 @@
+---
+
+- module_defaults:
+    group/aws:
+      aws_access_key: '{{ aws_access_key | default(omit) }}'
+      aws_secret_key: '{{ aws_secret_key | default(omit) }}'
+      security_token: '{{ security_token | default(omit) }}'
+      region: '{{ aws_region | default(omit) }}'
+
+  block:
+
+    # ==== Tests ===================================================
+
+    - name: Create a new static IP
+      lightsail_static_ip:
+        name: "{{ static_ip_name }}"
+      register: result
+
+    - assert:
+        that:
+          - result.changed == True
+          - '"static_ip" in result'
+          - '"arn" in result.static_ip'
+          - '"created_at" in result.static_ip'
+          - '"ip_address" in result.static_ip'
+          - '"is_attached" in result.static_ip'
+          - '"location" in result.static_ip'
+          - '"name" in result.static_ip'
+          - '"resource_type" in result.static_ip'
+          - '"support_code" in result.static_ip'
+          - result.static_ip.arn.startswith("arn:")
+          - result.static_ip.name == static_ip_name
+          - result.static_ip.resource_type == 'StaticIp'
+          - result.static_ip.is_attached == false
+          - result.static_ip.ip_address | ansible.utils.ipaddr
+          - '"availability_zone" in result.static_ip.location'
+          - '"region_name" in result.static_ip.location'
+
+    - set_fact:
+        lightsail_ip_arn: '{{ result.static_ip.arn }}'
+        lightsail_ip_address: '{{ result.static_ip.ip_address }}'
+
+    - name: Make sure create is idempotent
+      lightsail_static_ip:
+        name: "{{ static_ip_name }}"
+      register: result
+
+    - assert:
+        that:
+          - result.changed == False
+          - '"static_ip" in result'
+          - '"arn" in result.static_ip'
+          - '"created_at" in result.static_ip'
+          - '"ip_address" in result.static_ip'
+          - '"is_attached" in result.static_ip'
+          - '"location" in result.static_ip'
+          - '"name" in result.static_ip'
+          - '"resource_type" in result.static_ip'
+          - '"support_code" in result.static_ip'
+          - result.static_ip.arn == lightsail_ip_arn
+          - result.static_ip.name == static_ip_name
+          - result.static_ip.resource_type == 'StaticIp'
+          - result.static_ip.is_attached == false
+          - result.static_ip.ip_address == lightsail_ip_address
+          - '"availability_zone" in result.static_ip.location'
+          - '"region_name" in result.static_ip.location'
+
+    - name: Delete the static IP
+      lightsail_static_ip:
+        name: "{{ static_ip_name }}"
+        state: absent
+      register: result
+
+    - assert:
+        that:
+          - result.changed == True
+
+    - name: Make sure deletion is idempotent
+      lightsail_static_ip:
+        name: "{{ static_ip_name }}"
+        state: absent
+      register: result
+
+    - assert:
+        that:
+          - result.changed == False
+
+  # ==== Cleanup ====================================================
+
+  always:
+
+    - name: Cleanup - delete static IP
+      lightsail_static_ip:
+        name: "{{ static_ip_name }}"
+        state: absent
+      ignore_errors: yes