Repository: ansible-collections/community.aws

Issue:
lightsail - Add firewall settings to lightsail module
<!--- Verify first that your feature was not already discussed on GitHub -->
<!--- Complete *all* sections as described, this form is processed automatically -->

##### SUMMARY
<!--- Describe the new feature/improvement briefly below -->
Extend AWS lightsail module to include additional parameters to configure instance network settings. Including firewall and Static IP binding.

##### ISSUE TYPE
- Feature Idea

##### COMPONENT NAME
<!--- Write the short name of the module, plugin, task or feature below, use your best guess if unsure -->
[community.aws.lightsail]( https://github.com/ansible-collections/community.aws/blob/main/docs/community.aws.lightsail_module.rst)

##### ADDITIONAL INFORMATION
<!--- Describe how the feature would be used, why it is needed and what it would solve -->
The additional parameters would allow the lightsail instance's firewall desired state to be set. Currently this requires post modification by either aws cli or gui. In addition binding to an existing lightsail static IP would be helpful. 

Suggested yaml format
<!--- Paste example playbooks or commands between quotes below -->
```yaml

- name: Create a new Lightsail instance, register the instance details
    lightsail:
      state: present
      name: new_instance
      region: ap-southeast-2
      firewall:
        #  application, protocol, port, from
        - ['SSH', 'TCP', 22, '10.10.10.10/24']
        - ['HTTPS', 'TCP', 443, '10.10.10.10/24']
      staticip: static_ip1

```

<!--- HINT: You can also paste gist.github.com links for larger files -->



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 --->
@bentaylr Would the following PR fix your issue? https://github.com/ansible-collections/community.aws/pull/259
Thanks @gravesm, that will definitely help with configuring the static IP. Though I'd still need to set firewall settings via aws cli, encouraging to see progress.
Thanks @danielcotton



@bentaylr It's worth noting that my PR will create static IPs in Lightsail, but you won't be able to attach an instance to them yet.

I'm hoping to eventually add functionality for:

- Attaching static IPs
- Setting firewall rules
- Setting metric alarms

Unfortunately I'm still relatively new to writing Ansible modules, and free time is at a premium these days.
Static IP support was added in #259 

Resolving patch:
diff --git a/plugins/modules/lightsail.py b/plugins/modules/lightsail.py
--- a/plugins/modules/lightsail.py
+++ b/plugins/modules/lightsail.py
@@ -47,6 +47,38 @@
       - Launch script that can configure the instance with additional data.
     type: str
     default: ''
+  public_ports:
+    description:
+      - A list of dictionaries to describe the ports to open for the specified instance.
+    type: list
+    elements: dict
+    suboptions:
+      from_port:
+        description: The first port in a range of open ports on the instance.
+        type: int
+        required: true
+      to_port:
+        description: The last port in a range of open ports on the instance.
+        type: int
+        required: true
+      protocol:
+        description: The IP protocol name accepted for the defined range of open ports.
+        type: str
+        choices: ['tcp', 'all', 'udp', 'icmp']
+        required: true
+      cidrs:
+        description:
+          - The IPv4 address, or range of IPv4 addresses (in CIDR notation) that are allowed to connect to the instance through the ports, and the protocol.
+          - One of I(cidrs) or I(ipv6_cidrs) must be specified.
+        type: list
+        elements: str
+      ipv6_cidrs:
+        description:
+          - The IPv6 address, or range of IPv6 addresses (in CIDR notation) that are allowed to connect to the instance through the ports, and the protocol.
+          - One of I(cidrs) or I(ipv6_cidrs) must be specified.
+        type: list
+        elements: str
+    version_added: 6.0.0
   key_pair_name:
     description:
       - Name of the key pair to use with the instance.
@@ -83,6 +115,12 @@
     bundle_id: nano_1_0
     key_pair_name: id_rsa
     user_data: " echo 'hello world' > /home/ubuntu/test.txt"
+    public_ports:
+      - from_port: 22
+        to_port: 22
+        protocol: "tcp"
+        cidrs: ["0.0.0.0/0"]
+        ipv6_cidrs: ["::/0"]
   register: my_instance
 
 - name: Delete an instance
@@ -155,6 +193,7 @@
     pass
 
 from ansible.module_utils.common.dict_transformations import camel_dict_to_snake_dict
+from ansible.module_utils.common.dict_transformations import snake_dict_to_camel_dict
 
 from ansible_collections.amazon.aws.plugins.module_utils.botocore import is_boto3_error_code
 
@@ -194,17 +233,28 @@ def wait_for_instance_state(module, client, instance_name, states):
                              ' {1}'.format(instance_name, states))
 
 
-def create_instance(module, client, instance_name):
+def update_public_ports(module, client, instance_name):
+    try:
+        client.put_instance_public_ports(
+            portInfos=snake_dict_to_camel_dict(module.params.get("public_ports")),
+            instanceName=instance_name,
+        )
+    except botocore.exceptions.ClientError as e:
+        module.fail_json_aws(e)
+
+
+def create_or_update_instance(module, client, instance_name):
 
     inst = find_instance_info(module, client, instance_name)
-    if inst:
-        module.exit_json(changed=False, instance=camel_dict_to_snake_dict(inst))
-    else:
-        create_params = {'instanceNames': [instance_name],
-                         'availabilityZone': module.params.get('zone'),
-                         'blueprintId': module.params.get('blueprint_id'),
-                         'bundleId': module.params.get('bundle_id'),
-                         'userData': module.params.get('user_data')}
+
+    if not inst:
+        create_params = {
+            "instanceNames": [instance_name],
+            "availabilityZone": module.params.get("zone"),
+            "blueprintId": module.params.get("blueprint_id"),
+            "bundleId": module.params.get("bundle_id"),
+            "userData": module.params.get("user_data"),
+        }
 
         key_pair_name = module.params.get('key_pair_name')
         if key_pair_name:
@@ -219,9 +269,15 @@ def create_instance(module, client, instance_name):
         if wait:
             desired_states = ['running']
             wait_for_instance_state(module, client, instance_name, desired_states)
-        inst = find_instance_info(module, client, instance_name, fail_if_not_found=True)
 
-        module.exit_json(changed=True, instance=camel_dict_to_snake_dict(inst))
+    if module.params.get("public_ports") is not None:
+        update_public_ports(module, client, instance_name)
+    after_update_inst = find_instance_info(module, client, instance_name, fail_if_not_found=True)
+
+    module.exit_json(
+        changed=after_update_inst != inst,
+        instance=camel_dict_to_snake_dict(after_update_inst),
+    )
 
 
 def delete_instance(module, client, instance_name):
@@ -302,16 +358,29 @@ def start_or_stop_instance(module, client, instance_name, state):
 def main():
 
     argument_spec = dict(
-        name=dict(type='str', required=True),
-        state=dict(type='str', default='present', choices=['present', 'absent', 'stopped', 'running', 'restarted',
-                                                           'rebooted']),
-        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', default=''),
-        wait=dict(type='bool', default=True),
-        wait_timeout=dict(default=300, type='int'),
+        name=dict(type="str", required=True),
+        state=dict(
+            type="str", default="present", choices=["present", "absent", "stopped", "running", "restarted", "rebooted"]
+        ),
+        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", default=""),
+        wait=dict(type="bool", default=True),
+        wait_timeout=dict(default=300, type="int"),
+        public_ports=dict(
+            type="list",
+            elements="dict",
+            options=dict(
+                from_port=dict(type="int", required=True),
+                to_port=dict(type="int", required=True),
+                protocol=dict(type="str", choices=["tcp", "all", "udp", "icmp"], required=True),
+                cidrs=dict(type="list", elements="str"),
+                ipv6_cidrs=dict(type="list", elements="str"),
+            ),
+            required_one_of=[("cidrs", "ipv6_cidrs")],
+        ),
     )
 
     module = AnsibleAWSModule(argument_spec=argument_spec,
@@ -323,7 +392,7 @@ def main():
     state = module.params.get('state')
 
     if state == 'present':
-        create_instance(module, client, name)
+        create_or_update_instance(module, client, name)
     elif state == 'absent':
         delete_instance(module, client, name)
     elif state in ('running', 'stopped'):
diff --git a/plugins/modules/lightsail_snapshot.py b/plugins/modules/lightsail_snapshot.py
new file mode 100644
--- /dev/null
+++ b/plugins/modules/lightsail_snapshot.py
@@ -0,0 +1,205 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+
+# Copyright: Contributors to the Ansible Project
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+DOCUMENTATION = r"""
+---
+module: lightsail_snapshot
+version_added: "6.0.0"
+short_description: Creates snapshots of AWS Lightsail instances
+description:
+    - Creates snapshots of AWS Lightsail instances.
+author:
+    - "Nuno Saavedra (@Nfsaavedra)"
+options:
+  state:
+    description:
+      - Indicate desired state of the target.
+    default: present
+    choices: ['present', 'absent']
+    type: str
+  snapshot_name:
+    description: Name of the new instance snapshot.
+    required: true
+    type: str
+  instance_name:
+    description:
+      - Name of the instance to create the snapshot.
+      - Required when I(state=present).
+    type: str
+  wait:
+    description:
+      - Wait for the instance snapshot to be created before returning.
+    type: bool
+    default: true
+  wait_timeout:
+    description:
+      - How long before I(wait) gives up, in seconds.
+    default: 300
+    type: int
+
+extends_documentation_fragment:
+- amazon.aws.common.modules
+- amazon.aws.region.modules
+- amazon.aws.boto3
+"""
+
+EXAMPLES = r"""
+- name: Create AWS Lightsail snapshot
+  lightsail_snapshot:
+    region: us-east-1
+    snapshot_name: "my_instance_snapshot"
+    instance_name: "my_instance"
+
+- name: Delete AWS Lightsail snapshot
+  lightsail_snapshot:
+    region: us-east-1
+    snapshot_name: "my_instance_snapshot"
+    state: absent
+"""
+
+RETURN = r"""
+changed:
+  description: if a snapshot has been modified/created
+  returned: always
+  type: bool
+  sample:
+    changed: true
+snapshot:
+  description: instance snapshot data
+  type: dict
+  returned: always
+  sample:
+    arn: "arn:aws:lightsail:us-east-1:070807442430:InstanceSnapshot/54b0f785-7132-443d-9e32-95a6825636a4"
+    created_at: "2023-02-23T18:46:11.183000+00:00"
+    from_attached_disks: []
+    from_blueprint_id: "amazon_linux_2"
+    from_bundle_id: "nano_2_0"
+    from_instance_arn: "arn:aws:lightsail:us-east-1:070807442430:Instance/5ca1e7ca-a994-4e19-bb82-deb9d79e9ca3"
+    from_instance_name: "my_instance"
+    is_from_auto_snapshot: false
+    location:
+      availability_zone: "all"
+      region_name: "us-east-1"
+    name: "my_instance_snapshot"
+    resource_type: "InstanceSnapshot"
+    size_in_gb: 20
+    state: "available"
+    support_code: "351201681302/ami-06b48e5589f1e248b"
+    tags: []
+"""
+
+import time
+
+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 is_boto3_error_code
+
+from ansible_collections.community.aws.plugins.module_utils.modules import AnsibleCommunityAWSModule as AnsibleAWSModule
+
+
+def find_instance_snapshot_info(module, client, instance_snapshot_name, fail_if_not_found=False):
+    try:
+        res = client.get_instance_snapshot(instanceSnapshotName=instance_snapshot_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["instanceSnapshot"]
+
+
+def wait_for_instance_snapshot(module, client, instance_snapshot_name):
+    wait_timeout = module.params.get("wait_timeout")
+    wait_max = time.time() + wait_timeout
+    snapshot = find_instance_snapshot_info(module, client, instance_snapshot_name)
+
+    while wait_max > time.time():
+        snapshot = find_instance_snapshot_info(module, client, instance_snapshot_name)
+        current_state = snapshot["state"]
+        if current_state != "pending":
+            break
+        time.sleep(5)
+    else:
+        module.fail_json(msg=f'Timed out waiting for instance snapshot "{instance_snapshot_name}" to be created.')
+
+    return snapshot
+
+
+def create_snapshot(module, client):
+    snapshot = find_instance_snapshot_info(module, client, module.params.get("snapshot_name"))
+    new_instance = snapshot is None
+
+    if module.check_mode or not new_instance:
+        snapshot = snapshot if snapshot is not None else {}
+        module.exit_json(
+            changed=new_instance,
+            instance_snapshot=camel_dict_to_snake_dict(snapshot),
+        )
+
+    try:
+        snapshot = client.create_instance_snapshot(
+            instanceSnapshotName=module.params.get("snapshot_name"),
+            instanceName=module.params.get("instance_name"),
+        )
+    except botocore.exceptions.ClientError as e:
+        module.fail_json_aws(e)
+
+    if module.params.get("wait"):
+        snapshot = wait_for_instance_snapshot(module, client, module.params.get("snapshot_name"))
+
+    module.exit_json(
+        changed=new_instance,
+        instance_snapshot=camel_dict_to_snake_dict(snapshot),
+    )
+
+
+def delete_snapshot(module, client):
+    snapshot = find_instance_snapshot_info(module, client, module.params.get("snapshot_name"))
+    if module.check_mode or snapshot is None:
+        changed = not (snapshot is None)
+        instance = snapshot if changed else {}
+        module.exit_json(changed=changed, instance=instance)
+
+    try:
+        client.delete_instance_snapshot(instanceSnapshotName=module.params.get("snapshot_name"))
+    except botocore.exceptions.ClientError as e:
+        module.fail_json_aws(e)
+
+    module.exit_json(changed=True, instance=camel_dict_to_snake_dict(snapshot))
+
+
+def main():
+    argument_spec = dict(
+        state=dict(type="str", default="present", choices=["present", "absent"]),
+        snapshot_name=dict(type="str", required=True),
+        instance_name=dict(type="str"),
+        wait=dict(type="bool", default=True),
+        wait_timeout=dict(default=300, type="int"),
+    )
+    required_if = [
+        ["state", "present", ("instance_name",)],
+    ]
+
+    module = AnsibleAWSModule(argument_spec=argument_spec, required_if=required_if, supports_check_mode=True)
+    client = module.client("lightsail")
+
+    state = module.params.get("state")
+
+    if state == "present":
+        create_snapshot(module, client)
+    elif state == "absent":
+        delete_snapshot(module, client)
+
+
+if __name__ == "__main__":
+    main()


Test patch:
diff --git a/tests/integration/targets/lightsail/tasks/main.yml b/tests/integration/targets/lightsail/tasks/main.yml
--- a/tests/integration/targets/lightsail/tasks/main.yml
+++ b/tests/integration/targets/lightsail/tasks/main.yml
@@ -17,6 +17,12 @@
         zone: "{{ zone }}"
         blueprint_id: amazon_linux_2
         bundle_id: nano_2_0
+        public_ports:
+          - from_port: 50
+            to_port: 50
+            protocol: "tcp"
+            cidrs: ["0.0.0.0/0"]
+            ipv6_cidrs: ["::/0"]
         wait: yes
       register: result
 
@@ -25,6 +31,20 @@
           - result.changed == True
           - "'instance' in result and result.instance.name == instance_name"
           - "result.instance.state.name == 'running'"
+          - "result.instance.networking.ports[0].from_port == 50"
+          - "{{ result.instance.networking.ports|length }} == 1"
+
+    - name: Check if it does not delete public ports config when no value is provided
+      lightsail:
+        name: "{{ instance_name }}"
+        zone: "{{ zone }}"
+        blueprint_id: amazon_linux
+        bundle_id: nano_2_0
+      register: result
+
+    - assert:
+        that:
+          - result.changed == False
 
     - name: Make sure create is idempotent
       lightsail:
@@ -32,6 +52,12 @@
         zone: "{{ zone }}"
         blueprint_id: amazon_linux_2
         bundle_id: nano_2_0
+        public_ports:
+          - from_port: 50
+            to_port: 50
+            protocol: "tcp"
+            cidrs: ["0.0.0.0/0"]
+            ipv6_cidrs: ["::/0"]
       register: result
 
     - assert:
diff --git a/tests/integration/targets/lightsail_snapshot/aliases b/tests/integration/targets/lightsail_snapshot/aliases
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/lightsail_snapshot/aliases
@@ -0,0 +1 @@
+cloud/aws
diff --git a/tests/integration/targets/lightsail_snapshot/defaults/main.yml b/tests/integration/targets/lightsail_snapshot/defaults/main.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/lightsail_snapshot/defaults/main.yml
@@ -0,0 +1,3 @@
+instance_name: "{{ resource_prefix }}_instance"
+snapshot_name: "{{ resource_prefix }}_instance_snapshot"
+zone: "{{ aws_region }}a"
diff --git a/tests/integration/targets/lightsail_snapshot/meta/main.yml b/tests/integration/targets/lightsail_snapshot/meta/main.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/lightsail_snapshot/meta/main.yml
@@ -0,0 +1 @@
+dependencies: []
diff --git a/tests/integration/targets/lightsail_snapshot/tasks/main.yml b/tests/integration/targets/lightsail_snapshot/tasks/main.yml
new file mode 100644
--- /dev/null
+++ b/tests/integration/targets/lightsail_snapshot/tasks/main.yml
@@ -0,0 +1,85 @@
+---
+
+- 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 instance
+      lightsail:
+        name: "{{ instance_name }}"
+        zone: "{{ zone }}"
+        blueprint_id: amazon_linux_2
+        bundle_id: nano_2_0
+        wait: yes
+
+    - name: Create a new snapshot
+      lightsail_snapshot:
+        snapshot_name: "{{ snapshot_name }}"
+        instance_name: "{{ instance_name }}"
+        region: "{{ aws_region }}"
+        wait: yes
+      register: result
+    
+    - assert:
+        that:
+          - result.changed == True
+          - "'instance_snapshot' in result and result.instance_snapshot.name == '{{ snapshot_name }}'"
+          - "result.instance_snapshot.state == 'available'"
+
+    - name: Make sure instance snapshot creation is idempotent
+      lightsail_snapshot:
+        snapshot_name: "{{ snapshot_name }}"
+        instance_name: "{{ instance_name }}"
+        region: "{{ aws_region }}"
+        wait: yes
+      register: result
+
+    - assert:
+        that:
+          - result.changed == False
+
+    - name: Delete the instance snapshot
+      lightsail_snapshot:
+        snapshot_name: "{{ snapshot_name }}"
+        region: "{{ aws_region }}"
+        state: absent
+      register: result
+
+    - assert:
+        that:
+          - result.changed == True
+
+    - name: Make sure instance snapshot deletion is idempotent
+      lightsail_snapshot:
+        snapshot_name: "{{ snapshot_name }}"
+        region: "{{ aws_region }}"
+        state: absent
+      register: result
+
+    - assert:
+        that:
+          - result.changed == False
+
+  # ==== Cleanup ====================================================
+
+  always:
+
+    - name: Cleanup - delete instance snapshot
+      lightsail_snapshot:
+        snapshot_name: "{{ snapshot_name }}"
+        region: "{{ aws_region }}"
+        state: absent
+      ignore_errors: yes
+
+    - name: Cleanup - delete instance
+      lightsail:
+        name: "{{ instance_name }}"
+        state: absent
+      ignore_errors: yes