diff --git a/changelogs/fragments/add-volume-attachment-module.yml b/changelogs/fragments/add-volume-attachment-module.yml new file mode 100644 index 0000000..149869c --- /dev/null +++ b/changelogs/fragments/add-volume-attachment-module.yml @@ -0,0 +1,2 @@ +minor_changes: + - volume_attachment - Add new `volume_attachment` module to manage Volumes attachment. diff --git a/plugins/modules/volume_attachment.py b/plugins/modules/volume_attachment.py new file mode 100644 index 0000000..3e8dddb --- /dev/null +++ b/plugins/modules/volume_attachment.py @@ -0,0 +1,203 @@ +#!/usr/bin/python + +# Copyright: (c) 2025, Hetzner Cloud GmbH +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + + +from __future__ import annotations + +DOCUMENTATION = """ +--- +module: volume_attachment + +short_description: Manage the attachment of Hetzner Cloud Volumes + + +description: + - Attach and detach Volumes from Hetzner Cloud Servers. + +author: + - Amirhossein Shaerpour (@shaerpour) + +options: + volume: + description: + - Name or ID of the Hetzner Cloud Volume to attach/detach. + type: str + required: true + server: + description: + - Name or ID of the Hetzner Cloud Server to attach the Volume to. + - Required if O(state=present). + type: str + automount: + description: + - Automatically mount the Volume in the Server. + type: bool + state: + description: + - State of the Volume. + type: str + default: present + choices: [ present, absent ] + +extends_documentation_fragment: + - hetzner.hcloud.hcloud +""" + +EXAMPLES = """ +- name: Attach my-volume to my-server + hetzner.hcloud.volume_attachment: + volume: my-volume + server: my-server + +- name: Detach my-volume from my-server + hetzner.hcloud.volume_attachment: + volume: my-volume + state: absent + +- name: Attach my-volume using id to my-server with automount enabled + hetzner.hcloud.volume_attachment: + volume: 123456 + server: my-server + automount: true + state: present +""" + +RETURN = """ +hcloud_volume_attachment: + description: The relationship between a Server and a Volume + returned: always + type: complex + contains: + volume: + description: Name of the Volume + type: str + returned: always + sample: my-volume + server: + description: Name of the attached Server + type: str + returned: always + sample: my-server +""" + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.hcloud import AnsibleHCloud +from ..module_utils.vendor.hcloud import HCloudException +from ..module_utils.vendor.hcloud.servers import BoundServer +from ..module_utils.vendor.hcloud.volumes import BoundVolume + + +class AnsibleHCloudVolumeAttachment(AnsibleHCloud): + represent = "hcloud_volume_attachment" + + # We must the hcloud_volume_attachment name instead of hcloud_volume, because + # AnsibleHCloud.get_result does funny things. + hcloud_volume_attachment: BoundVolume | None = None + hcloud_server: BoundServer | None = None + + def _prepare_result(self): + return { + "volume": self.hcloud_volume_attachment.name, + "server": ( + self.hcloud_volume_attachment.server.name if self.hcloud_volume_attachment.server is not None else None + ), + } + + def _get_volume(self): + try: + self.hcloud_volume_attachment = self._client_get_by_name_or_id( + "volumes", + self.module.params.get("volume"), + ) + except HCloudException as exception: + self.fail_json_hcloud(exception) + + def _get_server(self): + try: + self.hcloud_server = self._client_get_by_name_or_id( + "servers", + self.module.params.get("server"), + ) + except HCloudException as exception: + self.fail_json_hcloud(exception) + + def attach_volume(self): + self.module.fail_on_missing_params(required_params=["server"]) + + try: + self._get_volume() + self._get_server() + + if self.hcloud_volume_attachment.server is not None: + if self.hcloud_volume_attachment.server.id == self.hcloud_server.id: + return + + if not self.module.check_mode: + action = self.hcloud_volume_attachment.detach() + action.wait_until_finished() + + self.hcloud_volume_attachment.server = None + self._mark_as_changed() + + if not self.module.check_mode: + action = self.hcloud_volume_attachment.attach( + server=self.hcloud_server, + automount=self.module.params.get("automount"), + ) + action.wait_until_finished() + + self.hcloud_volume_attachment.server = self.hcloud_server + self._mark_as_changed() + + except HCloudException as exception: + self.fail_json_hcloud(exception) + + def detach_volume(self): + try: + self._get_volume() + + if self.hcloud_volume_attachment.server is not None: + if not self.module.check_mode: + action = self.hcloud_volume_attachment.detach() + action.wait_until_finished() + + self.hcloud_volume_attachment.server = None + self._mark_as_changed() + except HCloudException as exception: + self.fail_json_hcloud(exception) + + @classmethod + def define_module(cls): + return AnsibleModule( + argument_spec=dict( + volume={"type": "str", "required": True}, + server={"type": "str"}, + automount={"type": "bool"}, + state={ + "choices": ["present", "absent"], + "default": "present", + }, + **super().base_module_arguments(), + ), + supports_check_mode=True, + ) + + +def main(): + module = AnsibleHCloudVolumeAttachment.define_module() + + hcloud = AnsibleHCloudVolumeAttachment(module) + state = module.params["state"] + if state == "present": + hcloud.attach_volume() + elif state == "absent": + hcloud.detach_volume() + + module.exit_json(**hcloud.get_result()) + + +if __name__ == "__main__": + main() diff --git a/tests/integration/targets/volume_attachment/aliases b/tests/integration/targets/volume_attachment/aliases new file mode 100644 index 0000000..18b1111 --- /dev/null +++ b/tests/integration/targets/volume_attachment/aliases @@ -0,0 +1,3 @@ +cloud/hcloud +gather_facts/no +azp/group2 diff --git a/tests/integration/targets/volume_attachment/defaults/main/common.yml b/tests/integration/targets/volume_attachment/defaults/main/common.yml new file mode 100644 index 0000000..0b15142 --- /dev/null +++ b/tests/integration/targets/volume_attachment/defaults/main/common.yml @@ -0,0 +1,29 @@ +# +# DO NOT EDIT THIS FILE! Please edit the files in tests/integration/common instead. +# +--- +# Azure Pipelines will configure this value to something similar to +# "azp-84824-1-hetzner-2-13-test-2-13-hcloud-3-9-1-default-i" +hcloud_prefix: "tests" + +# Used to namespace resources created by concurrent test pipelines/targets +hcloud_run_ns: "{{ hcloud_prefix | md5 }}" +hcloud_role_ns: "{{ role_name | split('_') | map('batch', 2) | map('first') | flatten() | join() }}" +hcloud_ns: "ansible-{{ hcloud_run_ns }}-{{ hcloud_role_ns }}" + +# Used to easily update the server types and images across all our tests. +hcloud_server_type_name: cax11 +hcloud_server_type_id: 45 + +hcloud_server_type_upgrade_name: cax21 +hcloud_server_type_upgrade_id: 93 + +hcloud_image_name: debian-12 +hcloud_image_id: 114690389 # architecture=arm + +hcloud_location_name: hel1 +hcloud_location_id: 3 +hcloud_datacenter_name: hel1-dc2 +hcloud_datacenter_id: 3 + +hcloud_network_zone_name: eu-central diff --git a/tests/integration/targets/volume_attachment/defaults/main/main.yml b/tests/integration/targets/volume_attachment/defaults/main/main.yml new file mode 100644 index 0000000..bff41f6 --- /dev/null +++ b/tests/integration/targets/volume_attachment/defaults/main/main.yml @@ -0,0 +1,5 @@ +# Copyright: (c) 2025, Hetzner Cloud GmbH +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +--- +hcloud_volume_name: "{{ hcloud_ns }}" +hcloud_server_name: "{{ hcloud_ns }}" diff --git a/tests/integration/targets/volume_attachment/tasks/cleanup.yml b/tests/integration/targets/volume_attachment/tasks/cleanup.yml new file mode 100644 index 0000000..69717f4 --- /dev/null +++ b/tests/integration/targets/volume_attachment/tasks/cleanup.yml @@ -0,0 +1,16 @@ +--- +- name: Cleanup test_server + hetzner.hcloud.server: + name: "{{ hcloud_server_name }}" + state: absent + +- name: Cleanup test_server2 + hetzner.hcloud.server: + name: "{{ hcloud_server_name }}2" + state: absent + register: test_server2 + +- name: Cleanup test_volume + hetzner.hcloud.volume: + name: "{{ hcloud_volume_name }}" + state: absent diff --git a/tests/integration/targets/volume_attachment/tasks/main.yml b/tests/integration/targets/volume_attachment/tasks/main.yml new file mode 100644 index 0000000..767fc46 --- /dev/null +++ b/tests/integration/targets/volume_attachment/tasks/main.yml @@ -0,0 +1,31 @@ +# +# DO NOT EDIT THIS FILE! Please edit the files in tests/integration/common instead. +# +--- +- name: Check if cleanup.yml exists + ansible.builtin.stat: + path: "{{ role_path }}/tasks/cleanup.yml" + register: cleanup_file + +- name: Check if prepare.yml exists + ansible.builtin.stat: + path: "{{ role_path }}/tasks/prepare.yml" + register: prepare_file + +- name: Include cleanup tasks + ansible.builtin.include_tasks: "{{ role_path }}/tasks/cleanup.yml" + when: cleanup_file.stat.exists + +- name: Include prepare tasks + ansible.builtin.include_tasks: "{{ role_path }}/tasks/prepare.yml" + when: prepare_file.stat.exists + +- name: Run tests + block: + - name: Include test tasks + ansible.builtin.include_tasks: "{{ role_path }}/tasks/test.yml" + + always: + - name: Include cleanup tasks + ansible.builtin.include_tasks: "{{ role_path }}/tasks/cleanup.yml" + when: cleanup_file.stat.exists diff --git a/tests/integration/targets/volume_attachment/tasks/prepare.yml b/tests/integration/targets/volume_attachment/tasks/prepare.yml new file mode 100644 index 0000000..913bfab --- /dev/null +++ b/tests/integration/targets/volume_attachment/tasks/prepare.yml @@ -0,0 +1,25 @@ +--- +- name: Create test_server + hetzner.hcloud.server: + name: "{{ hcloud_server_name }}" + server_type: "{{ hcloud_server_type_name }}" + image: "{{ hcloud_image_name }}" + location: "{{ hcloud_location_name }}" + state: created + register: test_server + +- name: Create test_server2 + hetzner.hcloud.server: + name: "{{ hcloud_server_name }}2" + server_type: "{{ hcloud_server_type_name }}" + image: "{{ hcloud_image_name }}" + location: "{{ hcloud_location_name }}" + state: created + register: test_server2 + +- name: Create test_volume + hetzner.hcloud.volume: + name: "{{ hcloud_volume_name }}" + size: 10 + location: "{{ hcloud_location_name }}" + register: test_volume diff --git a/tests/integration/targets/volume_attachment/tasks/test.yml b/tests/integration/targets/volume_attachment/tasks/test.yml new file mode 100644 index 0000000..d6fbadd --- /dev/null +++ b/tests/integration/targets/volume_attachment/tasks/test.yml @@ -0,0 +1,130 @@ +# Copyright: (c) 2025, Hetzner Cloud GmbH +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +--- +- name: Test missing required parameters # noqa: args[module] + hetzner.hcloud.volume_attachment: + server: "{{ hcloud_server_name }}" + register: result + ignore_errors: true +- name: Verify missing required parameters + ansible.builtin.assert: + that: + - result is failed + - 'result.msg == "missing required arguments: volume"' + +- name: Test missing required parameters # noqa: args[module] + hetzner.hcloud.volume_attachment: + volume: "{{ hcloud_volume_name }}" + register: result + ignore_errors: true +- name: Verify missing required parameters + ansible.builtin.assert: + that: + - result is failed + - 'result.msg == "missing required arguments: server"' + +- name: Test attach with check mode + hetzner.hcloud.volume_attachment: + volume: "{{ hcloud_volume_name }}" + server: "{{ hcloud_server_name }}" + register: result + check_mode: true +- name: Verify attach with check mode + ansible.builtin.assert: + that: + - result is changed + - result.hcloud_volume_attachment.volume == hcloud_volume_name + - result.hcloud_volume_attachment.server == hcloud_server_name + +- name: Test attach volume not found + hetzner.hcloud.volume_attachment: + volume: "invalid" + server: "{{ hcloud_server_name }}" + register: result + ignore_errors: true +- name: Verify attach volume not found + ansible.builtin.assert: + that: + - result is failed + - 'result.msg == "resource (volume) does not exist: invalid"' + +- name: Test attach server not found + hetzner.hcloud.volume_attachment: + volume: "{{ hcloud_volume_name }}" + server: "invalid" + register: result + ignore_errors: true +- name: Verify attach server not found + ansible.builtin.assert: + that: + - result is failed + - 'result.msg == "resource (server) does not exist: invalid"' + +- name: Test attach + hetzner.hcloud.volume_attachment: + volume: "{{ hcloud_volume_name }}" + server: "{{ hcloud_server_name }}" + register: result +- name: Verify attach + ansible.builtin.assert: + that: + - result is changed + - result.hcloud_volume_attachment.volume == hcloud_volume_name + - result.hcloud_volume_attachment.server == hcloud_server_name + +- name: Test attach idempotency + hetzner.hcloud.volume_attachment: + volume: "{{ hcloud_volume_name }}" + server: "{{ hcloud_server_name }}" + register: result +- name: Verify attach idempotency + ansible.builtin.assert: + that: + - result is not changed + +- name: Test attach to another server + hetzner.hcloud.volume_attachment: + volume: "{{ hcloud_volume_name }}" + server: "{{ hcloud_server_name }}2" + register: result +- name: Verify attach + ansible.builtin.assert: + that: + - result is changed + - result.hcloud_volume_attachment.volume == hcloud_volume_name + - result.hcloud_volume_attachment.server == hcloud_server_name + '2' + +- name: Test detach with check mode + hetzner.hcloud.volume_attachment: + volume: "{{ hcloud_volume_name }}" + state: "absent" + check_mode: true + register: result +- name: Verify detach with check mode + ansible.builtin.assert: + that: + - result is changed + - result.hcloud_volume_attachment.volume == hcloud_volume_name + - result.hcloud_volume_attachment.server is none + +- name: Test detach + hetzner.hcloud.volume_attachment: + volume: "{{ hcloud_volume_name }}" + state: "absent" + register: result +- name: Verify detach + ansible.builtin.assert: + that: + - result is changed + - result.hcloud_volume_attachment.volume == hcloud_volume_name + - result.hcloud_volume_attachment.server is none + +- name: Test detach idempotency + hetzner.hcloud.volume_attachment: + volume: "{{ hcloud_volume_name }}" + state: "absent" + register: result +- name: Verify detach idempotency + ansible.builtin.assert: + that: + - result is not changed