diff --git a/plugins/module_utils/storage_box_subaccount.py b/plugins/module_utils/storage_box_subaccount.py new file mode 100644 index 0000000..b4175fd --- /dev/null +++ b/plugins/module_utils/storage_box_subaccount.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from ..module_utils.vendor.hcloud.storage_boxes import ( + BoundStorageBox, + BoundStorageBoxSubaccount, +) + +NAME_LABEL_KEY = "ansible-name" + + +def get_by_name(storage_box: BoundStorageBox, name: str): + if not name: + raise ValueError(f"invalid storage box subaccount name: '{name}'") + + result = storage_box.get_subaccount_list( + label_selector=f"{NAME_LABEL_KEY}={name}", + ) + if len(result.subaccounts) == 0: + return None + if len(result.subaccounts) == 1: + return result.subaccounts[0] + + raise ValueError(f"found multiple storage box subaccount with the same name: {name}") + + +def prepare_result(o: BoundStorageBoxSubaccount, name: str): + return { + "storage_box": o.storage_box.id, + "id": o.id, + "name": name, + "description": o.description, + "username": o.username, + "home_directory": o.home_directory, + "server": o.server, + "access_settings": { + "reachable_externally": o.access_settings.reachable_externally, + "samba_enabled": o.access_settings.samba_enabled, + "ssh_enabled": o.access_settings.ssh_enabled, + "webdav_enabled": o.access_settings.webdav_enabled, + "readonly": o.access_settings.readonly, + }, + "labels": o.labels, + "created": o.created.isoformat(), + } diff --git a/plugins/modules/storage_box_subaccount.py b/plugins/modules/storage_box_subaccount.py new file mode 100644 index 0000000..6813c53 --- /dev/null +++ b/plugins/modules/storage_box_subaccount.py @@ -0,0 +1,393 @@ +#!/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: storage_box_subaccount + +short_description: Create and manage Storage Box Subaccounts in Hetzner. + +description: + - Create, update and delete Storage Box Subaccounts in Hetzner. + - See the L(Storage Box Subaccounts API documentation,https://docs.hetzner.cloud/reference/hetzner#storage-box-subaccounts) for more details. + +author: + - Jonas Lammler (@jooola) + +options: + storage_box: + description: + - ID or Name of the parent Storage Box. + - Using the ID is preferred, to reduce the amount of API requests. + type: str + required: true + id: + description: + - ID of the Storage Box Subaccount to manage. + - Required if no Storage Box Subaccount O(name) is given. + type: int + name: + description: + - Name of the Storage Box Subaccount to manage. + - Required if no Storage Box Subaccount O(id) is given. + type: str + username: + description: + - Username of the Storage Box Subaccount to manage. + - Usernames are defined by the API and cannot be changed. + type: str + password: + description: + - Password for the Storage Box Subaccount. + - Required if the Storage Box Subaccount does not exist. + type: str + home_directory: + description: + - Home directory of the Storage Box Subaccount. + - Required if the Storage Box Subaccount does not exist. + type: str + access_settings: + description: + - Access settings of the Storage Box Subaccount. + type: dict + suboptions: + reachable_externally: + description: + - Whether access from outside the Hetzner network is allowed. + type: bool + default: false + samba_enabled: + description: + - Whether the Samba subsystem is enabled. + type: bool + default: false + ssh_enabled: + description: + - Whether the SSH subsystem is enabled. + type: bool + default: false + webdav_enabled: + description: + - Whether the WebDAV subsystem is enabled. + type: bool + default: false + readonly: + description: + - Whether the Subaccount is read-only. + type: bool + default: false + description: + description: + - Description of the Storage Box Subaccount. + type: str + labels: + description: + - User-defined labels (key-value pairs) for the Storage Box Subaccount. + type: dict + state: + description: + - State of the Storage Box Subaccount. + default: present + choices: [absent, present] + type: str + +extends_documentation_fragment: + - hetzner.hcloud.hcloud +""" + +EXAMPLES = """ +- name: Create a Storage Box Subaccount + hetzner.hcloud.storage_box_subaccount: + storage_box: my-storage-box + name: team1 + home_directory: backups/team1 + password: secret + access_settings: + reachable_externally: false + ssh_enabled: true + samba_enabled: false + webdav_enabled: false + readonly: false + labels: + env: prod + state: present + +- name: Delete a Storage Box Subaccount by name + hetzner.hcloud.storage_box_subaccount: + storage_box: my-storage-box + name: team1 + state: absent + +- name: Delete a Storage Box Subaccount by id + hetzner.hcloud.storage_box_subaccount: + storage_box: 497436 + id: 158045 + state: absent +""" + +RETURN = """ +hcloud_storage_box_subaccount: + description: Details about the Storage Box Subaccount. + returned: always + type: dict + contains: + storage_box: + description: ID of the parent Storage Box. + returned: always + type: int + sample: 514605 + id: + description: ID of the Storage Box Subaccount. + returned: always + type: int + sample: 158045 + name: + description: Name of the Storage Box Subaccount. + returned: always + type: str + sample: team1 + description: + description: Description of the Storage Box Subaccount. + returned: always + type: str + sample: backups from team1 + home_directory: + description: Home directory of the Storage Box Subaccount. + returned: always + type: str + sample: backups/team1 + username: + description: Username of the Storage Box Subaccount. + returned: always + type: str + sample: u514605-sub1 + server: + description: FQDN of the Storage Box Subaccount. + returned: always + type: str + sample: u514605-sub1.your-storagebox.de + labels: + description: User-defined labels (key-value pairs) of the Storage Box Subaccount. + returned: always + type: dict + sample: + env: prod + created: + description: Point in time when the Storage Box Subaccount was created (in RFC3339 format). + returned: always + type: str + sample: "2025-12-03T13:47:47Z" +""" + +from ..module_utils import storage_box, storage_box_subaccount +from ..module_utils.hcloud import AnsibleHCloud, AnsibleModule +from ..module_utils.storage_box_subaccount import NAME_LABEL_KEY +from ..module_utils.vendor.hcloud import HCloudException +from ..module_utils.vendor.hcloud.storage_boxes import ( + BoundStorageBox, + BoundStorageBoxSubaccount, + StorageBoxSubaccountAccessSettings, +) + + +class AnsibleStorageBoxSnapshot(AnsibleHCloud): + represent = "storage_box_subaccount" + + storage_box: BoundStorageBox | None = None + storage_box_subaccount: BoundStorageBoxSubaccount | None = None + storage_box_subaccount_name: str | None = None + + def _prepare_result(self): + if self.storage_box_subaccount is None: + return {} + return storage_box_subaccount.prepare_result(self.storage_box_subaccount, self.storage_box_subaccount_name) + + def _fetch(self): + self.storage_box = storage_box.get(self.client.storage_boxes, self.module.params.get("storage_box")) + + if (value := self.module.params.get("id")) is not None: + self.storage_box_subaccount = self.storage_box.get_subaccount_by_id(value) + elif (value := self.module.params.get("name")) is not None: + self.storage_box_subaccount = storage_box_subaccount.get_by_name(self.storage_box, value) + + # Workaround the missing name property + # Get the name of the resource from the labels + if self.storage_box_subaccount is not None: + self.storage_box_subaccount_name = self.storage_box_subaccount.labels.pop(NAME_LABEL_KEY) + + def _create(self): + self.fail_on_invalid_params( + required=["name", "home_directory", "password"], + ) + params = { + "home_directory": self.module.params.get("home_directory"), + "password": self.module.params.get("password"), + } + + if (value := self.module.params.get("description")) is not None: + params["description"] = value + + if (value := self.module.params.get("labels")) is not None: + params["labels"] = value + + if (value := self.module.params.get("access_settings")) is not None: + params["access_settings"] = StorageBoxSubaccountAccessSettings.from_dict(value) + + # Workaround the missing name property + # Save the name of the resource in the labels + if "labels" not in params: + params["labels"] = {} + params["labels"][NAME_LABEL_KEY] = self.module.params.get("name") + + if not self.module.check_mode: + resp = self.storage_box.create_subaccount(**params) + self.storage_box_subaccount = resp.subaccount + self.actions.append(resp.action) + + self._wait_actions() + self.storage_box_subaccount.reload() + self.storage_box_subaccount_name = self.storage_box_subaccount.labels.pop(NAME_LABEL_KEY) + + self._mark_as_changed() + + def _update(self): + if (value := self.module.params.get("home_directory")) is not None: + if self.storage_box_subaccount.home_directory != value: + if not self.module.check_mode: + action = self.storage_box_subaccount.change_home_directory(value) + self.actions.append(action) + self._mark_as_changed() + + if (value := self.module.params.get("access_settings")) is not None: + access_settings = StorageBoxSubaccountAccessSettings.from_dict(value) + if self.storage_box_subaccount.access_settings.to_payload() != access_settings.to_payload(): + if not self.module.check_mode: + action = self.storage_box_subaccount.update_access_settings(access_settings) + self.actions.append(action) + self._mark_as_changed() + + if not self.module.check_mode: + self._wait_actions() + + params = {} + if (value := self.module.params.get("description")) is not None: + if value != self.storage_box_subaccount.description: + params["description"] = value + self._mark_as_changed() + + if (value := self.module.params.get("labels")) is not None: + if value != self.storage_box_subaccount.labels: + params["labels"] = value + self._mark_as_changed() + + # Workaround the missing name property + # Preserve resource name in the labels, name update happen below + params["labels"][NAME_LABEL_KEY] = self.storage_box_subaccount_name + + # Workaround the missing name property + # Update resource name in the labels + if (value := self.module.params.get("name")) is not None: + if value != self.storage_box_subaccount_name: + self.fail_on_invalid_params(required=["id"]) + if "labels" in params: + params["labels"][NAME_LABEL_KEY] = value + else: + params["labels"] = self.storage_box_subaccount.labels + params["labels"][NAME_LABEL_KEY] = value + self._mark_as_changed() + + # Update only if params holds changes or the data must be refreshed (actions + # were triggered) + if params or self.actions: + if not self.module.check_mode: + self._wait_actions() + self.storage_box_subaccount = self.storage_box_subaccount.update(**params) + self.storage_box_subaccount_name = self.storage_box_subaccount.labels.pop(NAME_LABEL_KEY) + + def _delete(self): + if not self.module.check_mode: + resp = self.storage_box_subaccount.delete() + resp.action.wait_until_finished() + + self.storage_box_subaccount = None + self._mark_as_changed() + + def present(self): + try: + self._fetch() + if self.storage_box_subaccount is None: + self._create() + else: + self._update() + + except HCloudException as exception: + self.fail_json_hcloud(exception) + + def absent(self): + try: + self._fetch() + if self.storage_box_subaccount is None: + return + self._delete() + + except HCloudException as exception: + self.fail_json_hcloud(exception) + + @classmethod + def define_module(cls): + return AnsibleModule( + argument_spec=dict( + storage_box={"type": "str", "required": True}, + id={"type": "int"}, + name={"type": "str"}, + home_directory={"type": "str"}, + password={"type": "str", "no_log": True}, + description={"type": "str"}, + labels={"type": "dict"}, + access_settings={ + "type": "dict", + "options": dict( + reachable_externally={"type": "bool", "default": False}, + samba_enabled={"type": "bool", "default": False}, + ssh_enabled={"type": "bool", "default": False}, + webdav_enabled={"type": "bool", "default": False}, + readonly={"type": "bool", "default": False}, + ), + }, + state={ + "choices": ["absent", "present", "reset_password"], + "default": "present", + }, + **super().base_module_arguments(), + ), + required_one_of=[["id", "name"]], + supports_check_mode=True, + ) + + +def main(): + module = AnsibleStorageBoxSnapshot.define_module() + o = AnsibleStorageBoxSnapshot(module) + + match module.params.get("state"): + # TODO: reset password + case "absent": + o.absent() + case _: + o.present() + + result = o.get_result() + + # Legacy return value naming pattern + result["hcloud_storage_box_subaccount"] = result.pop("storage_box_subaccount") + + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/tests/integration/targets/storage_box_subaccount/aliases b/tests/integration/targets/storage_box_subaccount/aliases new file mode 100644 index 0000000..18b1111 --- /dev/null +++ b/tests/integration/targets/storage_box_subaccount/aliases @@ -0,0 +1,3 @@ +cloud/hcloud +gather_facts/no +azp/group2 diff --git a/tests/integration/targets/storage_box_subaccount/defaults/main/common.yml b/tests/integration/targets/storage_box_subaccount/defaults/main/common.yml new file mode 100644 index 0000000..015c3b5 --- /dev/null +++ b/tests/integration/targets/storage_box_subaccount/defaults/main/common.yml @@ -0,0 +1,35 @@ +# +# 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 + +hcloud_storage_box_type_name: bx11 +hcloud_storage_box_type_id: 1333 + +hcloud_storage_box_type_upgrade_name: bx21 +hcloud_storage_box_type_upgrade_id: 1334 diff --git a/tests/integration/targets/storage_box_subaccount/defaults/main/main.yml b/tests/integration/targets/storage_box_subaccount/defaults/main/main.yml new file mode 100644 index 0000000..69c5659 --- /dev/null +++ b/tests/integration/targets/storage_box_subaccount/defaults/main/main.yml @@ -0,0 +1,6 @@ +# Copyright: (c) 2019, Hetzner Cloud GmbH +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +--- +hcloud_storage_box_name: "{{ hcloud_ns }}" +hcloud_ssh_key_name: "{{ hcloud_ns }}" +hcloud_storage_box_password: 1-secret-PASSW0RD-=) diff --git a/tests/integration/targets/storage_box_subaccount/meta/main.yml b/tests/integration/targets/storage_box_subaccount/meta/main.yml new file mode 100644 index 0000000..3a96ecb --- /dev/null +++ b/tests/integration/targets/storage_box_subaccount/meta/main.yml @@ -0,0 +1,3 @@ +--- +dependencies: + - setup_ssh_keypair diff --git a/tests/integration/targets/storage_box_subaccount/tasks/cleanup.yml b/tests/integration/targets/storage_box_subaccount/tasks/cleanup.yml new file mode 100644 index 0000000..d0ab906 --- /dev/null +++ b/tests/integration/targets/storage_box_subaccount/tasks/cleanup.yml @@ -0,0 +1,5 @@ +--- +- name: Cleanup test_storage_box + hetzner.hcloud.storage_box: + name: "{{ hcloud_storage_box_name }}" + state: absent diff --git a/tests/integration/targets/storage_box_subaccount/tasks/main.yml b/tests/integration/targets/storage_box_subaccount/tasks/main.yml new file mode 100644 index 0000000..767fc46 --- /dev/null +++ b/tests/integration/targets/storage_box_subaccount/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/storage_box_subaccount/tasks/prepare.yml b/tests/integration/targets/storage_box_subaccount/tasks/prepare.yml new file mode 100644 index 0000000..5e009ac --- /dev/null +++ b/tests/integration/targets/storage_box_subaccount/tasks/prepare.yml @@ -0,0 +1,15 @@ +--- +- name: Create test_storage_box + hetzner.hcloud.storage_box: + name: "{{ hcloud_storage_box_name }}" + type: "{{ hcloud_storage_box_type_name }}" + location: "{{ hcloud_location_name }}" + password: "{{ hcloud_storage_box_password }}" + ssh_keys: + - "{{ test_ssh_keypair.public_key }}" + access_settings: + ssh_enabled: true + reachable_externally: false + labels: + key: "{{ hcloud_ns }}" + register: test_storage_box diff --git a/tests/integration/targets/storage_box_subaccount/tasks/test.yml b/tests/integration/targets/storage_box_subaccount/tasks/test.yml new file mode 100644 index 0000000..138464e --- /dev/null +++ b/tests/integration/targets/storage_box_subaccount/tasks/test.yml @@ -0,0 +1,178 @@ +--- +- name: Test missing required parameters # noqa: args[module] + hetzner.hcloud.storage_box_subaccount: + storage_box: "{{ hcloud_storage_box_name }}" + ignore_errors: true + register: result +- name: Verify missing required parameters + ansible.builtin.assert: + that: + - result is failed + - 'result.msg == "one of the following is required: id, name"' + +- name: Test create with check mode + hetzner.hcloud.storage_box_subaccount: + storage_box: "{{ hcloud_storage_box_name }}" + name: team1 + description: backups from team1 + home_directory: backups/team1 + password: "{{ hcloud_storage_box_password }}" + labels: + key: value + access_settings: + ssh_enabled: true + readonly: false + check_mode: true + register: result +- name: Verify create with check mode + ansible.builtin.assert: + that: + - result is changed + +- name: Test create + hetzner.hcloud.storage_box_subaccount: + storage_box: "{{ hcloud_storage_box_name }}" + name: team1 + description: backups from team1 + home_directory: backups/team1 + password: "{{ hcloud_storage_box_password }}" + labels: + key: value + access_settings: + ssh_enabled: true + readonly: false + register: result +- name: Verify create + ansible.builtin.assert: + that: + - result is changed + - result.hcloud_storage_box_subaccount.storage_box == test_storage_box.hcloud_storage_box.id + - result.hcloud_storage_box_subaccount.id is not none + - result.hcloud_storage_box_subaccount.name == "team1" + - result.hcloud_storage_box_subaccount.description == "backups from team1" + - result.hcloud_storage_box_subaccount.home_directory == "backups/team1" + - result.hcloud_storage_box_subaccount.username is not none + - result.hcloud_storage_box_subaccount.server is not none + - result.hcloud_storage_box_subaccount.labels.key == "value" + - result.hcloud_storage_box_subaccount.access_settings.reachable_externally == false + - result.hcloud_storage_box_subaccount.access_settings.samba_enabled == false + - result.hcloud_storage_box_subaccount.access_settings.ssh_enabled == true + - result.hcloud_storage_box_subaccount.access_settings.webdav_enabled == false + - result.hcloud_storage_box_subaccount.access_settings.readonly == false + - result.hcloud_storage_box_subaccount.created is not none + +- name: Test create idempotency + hetzner.hcloud.storage_box_subaccount: + storage_box: "{{ test_storage_box.hcloud_storage_box.id }}" + name: team1 + description: backups from team1 + home_directory: backups/team1 + password: "{{ hcloud_storage_box_password }}" + labels: + key: value + access_settings: + ssh_enabled: true + readonly: false + register: result +- name: Verify create idempotency + ansible.builtin.assert: + that: + - result is not changed + +- name: Test update + hetzner.hcloud.storage_box_subaccount: + storage_box: "{{ test_storage_box.hcloud_storage_box.id }}" + name: team1 + description: backups from team2 # Update + home_directory: backups/team2 # Update + password: "{{ hcloud_storage_box_password }}" + labels: + key: changed # Update + access_settings: + ssh_enabled: true + readonly: true # Update + register: result +- name: Verify update + ansible.builtin.assert: + that: + - result is changed + - result.hcloud_storage_box_subaccount.storage_box == test_storage_box.hcloud_storage_box.id + - result.hcloud_storage_box_subaccount.id is not none + - result.hcloud_storage_box_subaccount.name == "team1" + - result.hcloud_storage_box_subaccount.description == "backups from team2" + - result.hcloud_storage_box_subaccount.home_directory == "backups/team2" + - result.hcloud_storage_box_subaccount.username is not none + - result.hcloud_storage_box_subaccount.server is not none + - result.hcloud_storage_box_subaccount.labels.key == "changed" + - result.hcloud_storage_box_subaccount.access_settings.reachable_externally == false + - result.hcloud_storage_box_subaccount.access_settings.samba_enabled == false + - result.hcloud_storage_box_subaccount.access_settings.ssh_enabled == true + - result.hcloud_storage_box_subaccount.access_settings.webdav_enabled == false + - result.hcloud_storage_box_subaccount.access_settings.readonly == true + - result.hcloud_storage_box_subaccount.created is not none + +- name: Test update idempotency + hetzner.hcloud.storage_box_subaccount: + storage_box: "{{ test_storage_box.hcloud_storage_box.id }}" + name: team1 + description: backups from team2 # Update + home_directory: backups/team2 # Update + password: "{{ hcloud_storage_box_password }}" + labels: + key: changed # Update + access_settings: + ssh_enabled: true + readonly: true # Update + register: result +- name: Verify update idempotency + ansible.builtin.assert: + that: + - result is not changed + +- name: Test update name + hetzner.hcloud.storage_box_subaccount: + storage_box: "{{ test_storage_box.hcloud_storage_box.id }}" + id: "{{ result.hcloud_storage_box_subaccount.id }}" + name: team2 + register: result +- name: Verify update + ansible.builtin.assert: + that: + - result is changed + - result.hcloud_storage_box_subaccount.storage_box == test_storage_box.hcloud_storage_box.id + - result.hcloud_storage_box_subaccount.id is not none + - result.hcloud_storage_box_subaccount.name == "team2" # Update + - result.hcloud_storage_box_subaccount.description == "backups from team2" + - result.hcloud_storage_box_subaccount.home_directory == "backups/team2" + - result.hcloud_storage_box_subaccount.username is not none + - result.hcloud_storage_box_subaccount.server is not none + - result.hcloud_storage_box_subaccount.labels.key == "changed" + - result.hcloud_storage_box_subaccount.access_settings.reachable_externally == false + - result.hcloud_storage_box_subaccount.access_settings.samba_enabled == false + - result.hcloud_storage_box_subaccount.access_settings.ssh_enabled == true + - result.hcloud_storage_box_subaccount.access_settings.webdav_enabled == false + - result.hcloud_storage_box_subaccount.access_settings.readonly == true + - result.hcloud_storage_box_subaccount.created is not none + +- name: Test delete + hetzner.hcloud.storage_box_subaccount: + storage_box: "{{ test_storage_box.hcloud_storage_box.id }}" + name: team2 + state: absent + register: result +- name: Verify delete + ansible.builtin.assert: + that: + - result is changed + - result.hcloud_storage_box_subaccount is none + +- name: Test delete idempotency + hetzner.hcloud.storage_box_subaccount: + storage_box: "{{ test_storage_box.hcloud_storage_box.id }}" + name: team2 + state: absent + register: result +- name: Verify delete idempotency + ansible.builtin.assert: + that: + - result is not changed