From f912ee1e91d12760ba950f82a1b69998e5bc3a4b Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Tue, 9 Dec 2025 12:34:01 +0100 Subject: [PATCH] feat: support Storage Box modules (#745) ##### SUMMARY Add support for the Storage Box resource with 2 new modules: - `storage_box` - `storage_box_info` --- plugins/module_utils/storage_box.py | 43 ++ plugins/modules/storage_box.py | 616 ++++++++++++++++++ plugins/modules/storage_box_info.py | 249 +++++++ tests/integration/targets/storage_box/aliases | 3 + .../storage_box/defaults/main/common.yml | 35 + .../storage_box/defaults/main/main.yml | 6 + .../targets/storage_box/meta/main.yml | 3 + .../targets/storage_box/tasks/cleanup.yml | 5 + .../targets/storage_box/tasks/main.yml | 31 + .../targets/storage_box/tasks/prepare.yml | 1 + .../targets/storage_box/tasks/test.yml | 242 +++++++ .../targets/storage_box_info/aliases | 3 + .../storage_box_info/defaults/main/common.yml | 35 + .../storage_box_info/defaults/main/main.yml | 6 + .../targets/storage_box_info/meta/main.yml | 3 + .../storage_box_info/tasks/cleanup.yml | 5 + .../targets/storage_box_info/tasks/main.yml | 31 + .../storage_box_info/tasks/prepare.yml | 15 + .../targets/storage_box_info/tasks/test.yml | 83 +++ 19 files changed, 1415 insertions(+) create mode 100644 plugins/module_utils/storage_box.py create mode 100644 plugins/modules/storage_box.py create mode 100644 plugins/modules/storage_box_info.py create mode 100644 tests/integration/targets/storage_box/aliases create mode 100644 tests/integration/targets/storage_box/defaults/main/common.yml create mode 100644 tests/integration/targets/storage_box/defaults/main/main.yml create mode 100644 tests/integration/targets/storage_box/meta/main.yml create mode 100644 tests/integration/targets/storage_box/tasks/cleanup.yml create mode 100644 tests/integration/targets/storage_box/tasks/main.yml create mode 100644 tests/integration/targets/storage_box/tasks/prepare.yml create mode 100644 tests/integration/targets/storage_box/tasks/test.yml create mode 100644 tests/integration/targets/storage_box_info/aliases create mode 100644 tests/integration/targets/storage_box_info/defaults/main/common.yml create mode 100644 tests/integration/targets/storage_box_info/defaults/main/main.yml create mode 100644 tests/integration/targets/storage_box_info/meta/main.yml create mode 100644 tests/integration/targets/storage_box_info/tasks/cleanup.yml create mode 100644 tests/integration/targets/storage_box_info/tasks/main.yml create mode 100644 tests/integration/targets/storage_box_info/tasks/prepare.yml create mode 100644 tests/integration/targets/storage_box_info/tasks/test.yml diff --git a/plugins/module_utils/storage_box.py b/plugins/module_utils/storage_box.py new file mode 100644 index 0000000..aa0b521 --- /dev/null +++ b/plugins/module_utils/storage_box.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from ..module_utils.vendor.hcloud.storage_boxes import ( + BoundStorageBox, +) + + +def prepare_result(o: BoundStorageBox): + return { + "id": o.id, + "name": o.name, + "storage_box_type": o.storage_box_type.name, + "location": o.location.name, + "labels": o.labels, + "delete_protection": o.protection["delete"], + "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, + "zfs_enabled": o.access_settings.zfs_enabled, + }, + "username": o.username, + "server": o.server, + "system": o.system, + "status": o.status, + "stats": { + "size": o.stats.size, + "size_data": o.stats.size_data, + "size_snapshots": o.stats.size_snapshots, + }, + "snapshot_plan": ( + None + if o.snapshot_plan is None + else { + "max_snapshots": o.snapshot_plan.max_snapshots, + "hour": o.snapshot_plan.hour, + "minute": o.snapshot_plan.minute, + "day_of_week": o.snapshot_plan.day_of_week, + "day_of_month": o.snapshot_plan.day_of_month, + } + ), + } diff --git a/plugins/modules/storage_box.py b/plugins/modules/storage_box.py new file mode 100644 index 0000000..4086a08 --- /dev/null +++ b/plugins/modules/storage_box.py @@ -0,0 +1,616 @@ +#!/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 + +short_description: Create and manage Storage Boxes in Hetzner. + +description: + - Create, update and delete Storage Boxes in Hetzner. + - See the L(Storage Boxes API documentation,https://docs.hetzner.cloud/reference/hetzner#storage-boxes) for more details. + +author: + - Jonas Lammler (@jooola) + +options: + id: + description: + - ID of the Storage Box to manage. + - Required if no Storage Box O(name) is given. + type: int + name: + description: + - Name of the Storage Box to manage. + - Required if no Storage Box O(id) is given. + - Required if the Storage Box does not exist. + type: str + storage_box_type: + description: + - Name or ID of the Storage Box Type for the Storage Box. + - Required if the Storage Box does not exist. + type: str + aliases: [type] + location: + description: + - Name or ID of the Location for the Storage Box. + - Required if the Storage Box does not exist. + type: str + password: + description: + - Password for the Storage Box. + - Required if the Storage Box does not exist. + type: str + labels: + description: + - User-defined labels (key-value pairs) for the Storage Box. + type: dict + ssh_keys: + description: + - SSH public keys in OpenSSH format to inject into the Storage Box. + type: list + elements: str + access_settings: + description: + - Access settings of the Storage Box. + 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 + zfs_enabled: + description: + - Whether the ZFS snapshot folder is visible. + type: bool + default: false + snapshot_plan: + description: + - Snapshot plan of the Storage Box. + - Use null to disabled the snapshot plan. + type: dict + suboptions: + max_snapshots: + description: + - Maximum amount of Snapshots that will be created by this Snapshot Plan. + - Older Snapshots will be deleted. + type: int + required: true + hour: + description: + - Hour when the Snapshot Plan is executed (UTC). + type: int + required: true + minute: + description: + - Minute when the Snapshot Plan is executed (UTC). + type: int + required: true + day_of_week: + description: + - Day of the week when the Snapshot Plan is executed. + - Starts at 1 for Monday til 7 for Sunday. + - Null means every day. + type: int + day_of_month: + description: + - Day of the month when the Snapshot Plan is executed. + - Null means every day. + type: int + delete_protection: + description: + - Protect the Storage Box from deletion. + type: bool + snapshot: + description: + - Snapshot ID or Name to rollback to. + - Only used when O(state=rollback_snapshot) + type: str + state: + description: + - State of the Storage Box. + - C(reset_password) and C(rollback_snapshot) are not idempotent. + default: present + choices: [absent, present, reset_password, rollback_snapshot] + type: str + +extends_documentation_fragment: + - hetzner.hcloud.hcloud +""" + +EXAMPLES = """ +- name: Create a Storage Box + hetzner.hcloud.storage_box: + name: my-storage-box + storage_box_type: bx11 + location: fsn1 + password: my-secret + labels: + env: prod + state: present + +- name: Create a Storage Box with access settings + hetzner.hcloud.storage_box: + name: my-storage-box + storage_box_type: bx11 + location: fsn1 + password: my-secret + access_settings: + reachable_externally: true + ssh_enabled: true + samba_enabled: false + webdav_enabled: false + zfs_enabled: false + state: present + +- name: Create a Storage Box with snapshot plan + hetzner.hcloud.storage_box: + name: my-storage-box + storage_box_type: bx11 + location: fsn1 + password: my-secret + snapshot_plan: + max_snapshots: 10 + hour: 3 + minute: 30 + state: present + +- name: Disable a Storage Box snapshot plan + hetzner.hcloud.storage_box: + name: my-storage-box + snapshot_plan: null + state: present + +- name: Reset a Storage Box password + hetzner.hcloud.storage_box: + name: my-storage-box + password: my-secret + state: reset_password + +- name: Rollback a Storage Box to a Snapshot + hetzner.hcloud.storage_box: + name: my-storage-box + snapshot: 2025-12-03T13-47-47 + state: rollback_snapshot + +- name: Delete a Storage Box + hetzner.hcloud.storage_box: + name: my-storage-box + state: absent +""" + +RETURN = """ +hcloud_storage_box: + description: Details about the Storage Box. + returned: always + type: dict + contains: + id: + description: ID of the Storage Box. + returned: always + type: int + sample: 1937415 + name: + description: Name of the Storage Box. + returned: always + type: str + sample: my-storage-box + storage_box_type: + description: Name of the Storage Box Type. + returned: always + type: str + sample: bx11 + location: + description: Name of the Location of the Storage Box. + returned: always + type: str + sample: fsn1 + labels: + description: User-defined labels (key-value pairs) of the Storage Box. + returned: always + type: dict + sample: + env: prod + delete_protection: + description: Protect the Storage Box from deletion. + returned: always + type: bool + sample: false + access_settings: + description: Access settings of the Storage Box. + returned: always + type: dict + contains: + reachable_externally: + description: Whether access from outside the Hetzner network is allowed. + returned: always + type: bool + sample: false + samba_enabled: + description: Whether the Samba subsystem is enabled. + returned: always + type: bool + sample: false + ssh_enabled: + description: Whether the SSH subsystem is enabled. + returned: always + type: bool + sample: true + webdav_enabled: + description: Whether the WebDAV subsystem is enabled. + returned: always + type: bool + sample: false + zfs_enabled: + description: Whether the ZFS snapshot folder is visible. + returned: always + type: bool + sample: false + snapshot_plan: + description: Snapshot plan of the Storage Box. + returned: when enabled + type: dict + contains: + max_snapshots: + description: Maximum amount of Snapshots that will be created by this Snapshot Plan. + returned: always + type: int + sample: 10 + hour: + description: Hour when the Snapshot Plan is executed (UTC). + returned: always + type: int + sample: 3 + minute: + description: Minute when the Snapshot Plan is executed (UTC). + returned: always + type: int + sample: 30 + day_of_week: + description: Day of the week when the Snapshot Plan is executed. Null means every day. + returned: always + type: int + sample: 1 + day_of_month: + description: Day of the month when the Snapshot Plan is executed. Null means every day. + returned: always + type: int + sample: 30 + username: + description: User name of the Storage Box. + returned: always + type: str + sample: u505337 + server: + description: FQDN of the Storage Box. + returned: always + type: str + sample: u505337.your-storagebox.de + system: + description: Host system of the Storage Box. + returned: always + type: str + sample: HEL1-BX136 + status: + description: Status of the Storage Box. + returned: always + type: str + sample: active + stats: + description: Statistics of the Storage Box. + returned: always + type: dict + contains: + size: + description: Current disk usage in bytes. + returned: always + type: int + sample: 10485760 + size_data: + description: Current disk usage for data in bytes. + returned: always + type: int + sample: 10485760 + size_snapshots: + description: Current disk usage for snapshots in bytes. + returned: always + type: int + sample: 10485760 +""" + +from ..module_utils import storage_box +from ..module_utils.client import client_resource_not_found +from ..module_utils.hcloud import AnsibleHCloud, AnsibleModule +from ..module_utils.vendor.hcloud import HCloudException +from ..module_utils.vendor.hcloud.locations import Location +from ..module_utils.vendor.hcloud.storage_box_types import StorageBoxType +from ..module_utils.vendor.hcloud.storage_boxes import ( + BoundStorageBox, + StorageBoxAccessSettings, + StorageBoxSnapshot, + StorageBoxSnapshotPlan, +) + + +class AnsibleStorageBox(AnsibleHCloud): + represent = "storage_box" + + storage_box: BoundStorageBox | None = None + + def _prepare_result(self): + if self.storage_box is not None: + return storage_box.prepare_result(self.storage_box) + return {} + + def _fetch(self): + if self.module.params.get("id") is not None: + self.storage_box = self.client.storage_boxes.get_by_id(self.module.params.get("id")) + else: + self.storage_box = self.client.storage_boxes.get_by_name(self.module.params.get("name")) + + def _create(self): + self.fail_on_invalid_params( + required=["name", "storage_box_type", "location", "password"], + ) + params = { + "name": self.module.params.get("name"), + "storage_box_type": StorageBoxType(self.module.params.get("storage_box_type")), + "location": Location(self.module.params.get("location")), + "password": self.module.params.get("password"), + } + + 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"] = StorageBoxAccessSettings.from_dict(value) + + if (value := self.module.params.get("ssh_keys")) is not None: + params["ssh_keys"] = value + + if not self.module.check_mode: + resp = self.client.storage_boxes.create(**params) + self.actions.append(resp.action) + + self.storage_box = resp.storage_box + + self._wait_actions() + + if (value := self.module.params.get("delete_protection")) is not None: + if not self.module.check_mode: + action = self.storage_box.change_protection(delete=value) + self.actions.append(action) + + if self.module.param_is_defined("snapshot_plan"): + if (value := self.module.params.get("snapshot_plan")) is not None: + if not self.module.check_mode: + action = self.storage_box.enable_snapshot_plan(StorageBoxSnapshotPlan.from_dict(value)) + self.actions.append(action) + + if not self.module.check_mode: + self._wait_actions() + self.storage_box.reload() + + self._mark_as_changed() + + def _update(self): + if (value := self.module.params.get("storage_box_type")) is not None: + if not self.storage_box.storage_box_type.has_id_or_name(value): + if not self.module.check_mode: + action = self.storage_box.change_type(StorageBoxType(value)) + self.actions.append(action) + self._mark_as_changed() + + if (value := self.module.params.get("access_settings")) is not None: + access_settings = StorageBoxAccessSettings.from_dict(value) + if self.storage_box.access_settings.to_payload() != access_settings.to_payload(): + if not self.module.check_mode: + action = self.storage_box.update_access_settings(access_settings) + self.actions.append(action) + self._mark_as_changed() + + if (value := self.module.params.get("delete_protection")) is not None: + if self.storage_box.protection["delete"] != value: + if not self.module.check_mode: + action = self.storage_box.change_protection(delete=value) + self.actions.append(action) + self._mark_as_changed() + + if self.module.param_is_defined("snapshot_plan"): + if (value := self.module.params.get("snapshot_plan")) is not None: + snapshot_plan = StorageBoxSnapshotPlan.from_dict(value) + if ( + self.storage_box.snapshot_plan is None + or self.storage_box.snapshot_plan.to_payload() != snapshot_plan.to_payload() + ): + if not self.module.check_mode: + action = self.storage_box.enable_snapshot_plan(snapshot_plan) + self.actions.append(action) + self._mark_as_changed() + else: + if self.storage_box.snapshot_plan is not None: + if not self.module.check_mode: + action = self.storage_box.disable_snapshot_plan() + self.actions.append(action) + self._mark_as_changed() + + params = {} + if (value := self.module.params.get("name")) is not None and value != self.storage_box.name: + self.fail_on_invalid_params(required=["id"]) + params["name"] = value + self._mark_as_changed() + + if (value := self.module.params.get("labels")) is not None and value != self.storage_box.labels: + params["labels"] = 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 = self.storage_box.update(**params) + + def _delete(self): + if not self.module.check_mode: + resp = self.storage_box.delete() + resp.action.wait_until_finished() + + self.storage_box = None + self._mark_as_changed() + + def present(self): + try: + self._fetch() + if self.storage_box 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 is None: + return + self._delete() + + except HCloudException as exception: + self.fail_json_hcloud(exception) + + def reset_password(self): + self.fail_on_invalid_params( + required=["password"], + ) + try: + self._fetch() + if self.storage_box is None: + raise client_resource_not_found( + "storage box", + self.module.params.get("id") or self.module.params.get("name"), + ) + if not self.module.check_mode: + action = self.storage_box.reset_password(self.module.params.get("password")) + self.actions.append(action) + self._wait_actions() + + self._mark_as_changed() + + except HCloudException as exception: + self.fail_json_hcloud(exception) + + def rollback_snapshot(self): + self.fail_on_invalid_params( + required=["snapshot"], + ) + try: + self._fetch() + if self.storage_box is None: + raise client_resource_not_found( + "storage box", + self.module.params.get("id") or self.module.params.get("name"), + ) + + if not self.module.check_mode: + action = self.storage_box.rollback_snapshot(StorageBoxSnapshot(self.module.params.get("snapshot"))) + self.actions.append(action) + self._wait_actions() + + self._mark_as_changed() + + except HCloudException as exception: + self.fail_json_hcloud(exception) + + @classmethod + def define_module(cls): + return AnsibleModule( + argument_spec=dict( + id={"type": "int"}, + name={"type": "str"}, + storage_box_type={"type": "str", "aliases": ["type"]}, + location={"type": "str"}, + password={"type": "str", "no_log": True}, + ssh_keys={"type": "list", "elements": "str", "no_log": False}, + 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}, + zfs_enabled={"type": "bool", "default": False}, + ), + }, + snapshot_plan={ + "type": "dict", + "options": dict( + max_snapshots={"type": "int", "required": True}, + hour={"type": "int", "required": True}, + minute={"type": "int", "required": True}, + day_of_week={"type": "int"}, + day_of_month={"type": "int"}, + ), + }, + delete_protection={"type": "bool"}, + snapshot={"type": "str"}, + state={ + "choices": ["absent", "present", "reset_password", "rollback_snapshot"], + "default": "present", + }, + **super().base_module_arguments(), + ), + required_one_of=[["id", "name"]], + supports_check_mode=True, + ) + + +def main(): + module = AnsibleStorageBox.define_module() + o = AnsibleStorageBox(module) + + match module.params.get("state"): + case "reset_password": + o.reset_password() + case "rollback_snapshot": + o.rollback_snapshot() + case "absent": + o.absent() + case _: + o.present() + + result = o.get_result() + + # Legacy return value naming pattern + result["hcloud_storage_box"] = result.pop("storage_box") + + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/storage_box_info.py b/plugins/modules/storage_box_info.py new file mode 100644 index 0000000..856bba7 --- /dev/null +++ b/plugins/modules/storage_box_info.py @@ -0,0 +1,249 @@ +#!/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_info + +short_description: Gather infos about Hetzner Storage Boxes. + +description: + - Gather infos about Hetzner Storage Boxes. + - See the L(Storage Boxes API documentation,https://docs.hetzner.cloud/reference/hetzner#storage-boxes) for more details. + +author: + - Jonas Lammler (@jooola) + +options: + id: + description: + - ID of the Storage Box to get. + - If the ID is invalid, the module will fail. + type: int + name: + description: + - Name of the Storage Box to get. + type: str + label_selector: + description: + - Label selector to filter the Storage Boxes to get. + type: str + +extends_documentation_fragment: + - hetzner.hcloud.hcloud +""" + +EXAMPLES = """ +- name: Gather all Storage Boxes + hetzner.hcloud.ssh_key_info: + register: output +- name: Print the gathered infos + debug: + var: output.hcloud_storage_box_info + +- name: Gather Storage Boxes by label + hetzner.hcloud.ssh_key_info: + label_selector: env=prod + register: output +- name: Print the gathered infos + debug: + var: output.hcloud_storage_box_info + +- name: Gather a Storage Box by name + hetzner.hcloud.ssh_key_info: + name: backups + register: output +- name: Print the gathered infos + debug: + var: output.hcloud_storage_box_info[0] + +- name: Gather a Storage Box by id + hetzner.hcloud.ssh_key_info: + name: 12345 + register: output +- name: Print the gathered infos + debug: + var: output.hcloud_storage_box_info[0] +""" + +RETURN = """ +hcloud_storage_box_info: + description: List of Storage Boxes. + returned: always + type: list + elements: dict + contains: + id: + description: ID of the Storage Box. + returned: always + type: int + sample: 1937415 + name: + description: Name of the Storage Box. + returned: always + type: str + sample: my-storage-box + storage_box_type: + description: Name of the Storage Box Type. + returned: always + type: str + sample: bx11 + location: + description: Name of the Location of the Storage Box. + returned: always + type: str + sample: fsn1 + labels: + description: User-defined labels (key-value pairs) of the Storage Box. + returned: always + type: dict + sample: + env: prod + delete_protection: + description: Protect the Storage Box from deletion. + returned: always + type: bool + sample: false + access_settings: + description: Access settings of the Storage Box. + returned: always + type: dict + contains: + reachable_externally: + description: Whether access from outside the Hetzner network is allowed. + returned: always + type: bool + sample: false + samba_enabled: + description: Whether the Samba subsystem is enabled. + returned: always + type: bool + sample: false + ssh_enabled: + description: Whether the SSH subsystem is enabled. + returned: always + type: bool + sample: true + webdav_enabled: + description: Whether the WebDAV subsystem is enabled. + returned: always + type: bool + sample: false + zfs_enabled: + description: Whether the ZFS snapshot folder is visible. + returned: always + type: bool + sample: false + username: + description: User name of the Storage Box. + returned: always + type: str + sample: u505337 + server: + description: FQDN of the Storage Box. + returned: always + type: str + sample: u505337.your-storagebox.de + system: + description: Host system of the Storage Box. + returned: always + type: str + sample: HEL1-BX136 + status: + description: Status of the Storage Box. + returned: always + type: str + sample: active + stats: + description: Statistics of the Storage Box. + returned: always + type: dict + contains: + size: + description: Current disk usage in bytes. + returned: always + type: int + sample: 10485760 + size_data: + description: Current disk usage for data in bytes. + returned: always + type: int + sample: 10485760 + size_snapshots: + description: Current disk usage for snapshots in bytes. + returned: always + type: int + sample: 10485760 +""" + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils import storage_box +from ..module_utils.hcloud import AnsibleHCloud +from ..module_utils.vendor.hcloud import HCloudException +from ..module_utils.vendor.hcloud.storage_boxes import ( + BoundStorageBox, +) + + +class AnsibleStorageBox(AnsibleHCloud): + represent = "storage_box" + + storage_box: list[BoundStorageBox] | None = None + + def _prepare_result(self): + result = [] + for o in self.storage_box or []: + if o is not None: + result.append(storage_box.prepare_result(o)) + + return result + + def fetch(self): + try: + if (id_ := self.module.params.get("id")) is not None: + self.storage_box = [self.client.storage_boxes.get_by_id(id_)] + elif (name := self.module.params.get("name")) is not None: + self.storage_box = [self.client.storage_boxes.get_by_name(name)] + else: + params = {} + if (label_selector := self.module.params.get("label_selector")) is not None: + params["label_selector"] = label_selector + self.storage_box = self.client.storage_boxes.get_all(**params) + + except HCloudException as exception: + self.fail_json_hcloud(exception) + + @classmethod + def define_module(cls): + return AnsibleModule( + argument_spec=dict( + id={"type": "int"}, + name={"type": "str"}, + label_selector={"type": "str"}, + **super().base_module_arguments(), + ), + supports_check_mode=True, + ) + + +def main(): + module = AnsibleStorageBox.define_module() + o = AnsibleStorageBox(module) + + o.fetch() + result = o.get_result() + + # Legacy return value naming pattern + result["hcloud_storage_box_info"] = result.pop("storage_box") + + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/tests/integration/targets/storage_box/aliases b/tests/integration/targets/storage_box/aliases new file mode 100644 index 0000000..18b1111 --- /dev/null +++ b/tests/integration/targets/storage_box/aliases @@ -0,0 +1,3 @@ +cloud/hcloud +gather_facts/no +azp/group2 diff --git a/tests/integration/targets/storage_box/defaults/main/common.yml b/tests/integration/targets/storage_box/defaults/main/common.yml new file mode 100644 index 0000000..015c3b5 --- /dev/null +++ b/tests/integration/targets/storage_box/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/defaults/main/main.yml b/tests/integration/targets/storage_box/defaults/main/main.yml new file mode 100644 index 0000000..69c5659 --- /dev/null +++ b/tests/integration/targets/storage_box/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/meta/main.yml b/tests/integration/targets/storage_box/meta/main.yml new file mode 100644 index 0000000..3a96ecb --- /dev/null +++ b/tests/integration/targets/storage_box/meta/main.yml @@ -0,0 +1,3 @@ +--- +dependencies: + - setup_ssh_keypair diff --git a/tests/integration/targets/storage_box/tasks/cleanup.yml b/tests/integration/targets/storage_box/tasks/cleanup.yml new file mode 100644 index 0000000..d0ab906 --- /dev/null +++ b/tests/integration/targets/storage_box/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/tasks/main.yml b/tests/integration/targets/storage_box/tasks/main.yml new file mode 100644 index 0000000..767fc46 --- /dev/null +++ b/tests/integration/targets/storage_box/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/tasks/prepare.yml b/tests/integration/targets/storage_box/tasks/prepare.yml new file mode 100644 index 0000000..ed97d53 --- /dev/null +++ b/tests/integration/targets/storage_box/tasks/prepare.yml @@ -0,0 +1 @@ +--- diff --git a/tests/integration/targets/storage_box/tasks/test.yml b/tests/integration/targets/storage_box/tasks/test.yml new file mode 100644 index 0000000..12e45b4 --- /dev/null +++ b/tests/integration/targets/storage_box/tasks/test.yml @@ -0,0 +1,242 @@ +--- +- name: Test missing required parameters + hetzner.hcloud.storage_box: + name: "{{ hcloud_storage_box_name }}" + state: present + ignore_errors: true + register: result +- name: Verify missing required parameters + ansible.builtin.assert: + that: + - result is failed + - 'result.msg == "missing required arguments: storage_box_type, location, password"' + +- name: Test create with check mode + 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 }}" + labels: + key: value + 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: + 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 + zfs_enabled: false + snapshot_plan: + max_snapshots: 10 + hour: 3 + minute: 30 + labels: + key: value + register: result +- name: Verify create + ansible.builtin.assert: + that: + - result is changed + - result.hcloud_storage_box.id is not none + - result.hcloud_storage_box.name == hcloud_storage_box_name + - result.hcloud_storage_box.storage_box_type == hcloud_storage_box_type_name + - result.hcloud_storage_box.location == hcloud_location_name + - result.hcloud_storage_box.labels.key == "value" + - result.hcloud_storage_box.delete_protection == false + - result.hcloud_storage_box.access_settings.reachable_externally == false + - result.hcloud_storage_box.access_settings.samba_enabled == false + - result.hcloud_storage_box.access_settings.ssh_enabled == true + - result.hcloud_storage_box.access_settings.webdav_enabled == false + - result.hcloud_storage_box.access_settings.zfs_enabled == false + - result.hcloud_storage_box.snapshot_plan.max_snapshots == 10 + - result.hcloud_storage_box.snapshot_plan.hour == 3 + - result.hcloud_storage_box.snapshot_plan.minute == 30 + - result.hcloud_storage_box.status == "active" + - result.hcloud_storage_box.server is not none + - result.hcloud_storage_box.system is not none + - result.hcloud_storage_box.username is not none + - result.hcloud_storage_box.stats.size >= 0 + - result.hcloud_storage_box.stats.size_data >= 0 + - result.hcloud_storage_box.stats.size_snapshots >= 0 + +- name: Test create idempotency + 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 + zfs_enabled: false + snapshot_plan: + max_snapshots: 10 + hour: 3 + minute: 30 + labels: + key: value + register: result +- name: Verify create idempotency + ansible.builtin.assert: + that: + - result is not changed + +- name: Test update name + hetzner.hcloud.storage_box: + id: "{{ result.hcloud_storage_box.id }}" + name: "{{ hcloud_storage_box_name }}-changed" # Update + register: result +- name: Verify update name + ansible.builtin.assert: + that: + - result is changed + - result.hcloud_storage_box.name == hcloud_storage_box_name + "-changed" + + # Ensure snapshot plan was not changed + - result.hcloud_storage_box.snapshot_plan.max_snapshots == 10 + - result.hcloud_storage_box.snapshot_plan.hour == 3 + - result.hcloud_storage_box.snapshot_plan.minute == 30 + +- name: Test update + hetzner.hcloud.storage_box: + id: "{{ result.hcloud_storage_box.id }}" + name: "{{ hcloud_storage_box_name }}" # Update + type: "{{ hcloud_storage_box_type_upgrade_name }}" # Update + location: "{{ hcloud_location_name }}" + ssh_keys: + - "{{ test_ssh_keypair.public_key }}" + access_settings: # Update + ssh_enabled: false + reachable_externally: true + snapshot_plan: + max_snapshots: 10 + hour: 4 # Update + minute: 30 + labels: + key: changed # Update + delete_protection: true # Update + register: result +- name: Verify update + ansible.builtin.assert: + that: + - result is changed + - result.hcloud_storage_box.id is not none + - result.hcloud_storage_box.name == hcloud_storage_box_name + - result.hcloud_storage_box.storage_box_type == hcloud_storage_box_type_upgrade_name + - result.hcloud_storage_box.location == hcloud_location_name + - result.hcloud_storage_box.labels.key == "changed" + - result.hcloud_storage_box.delete_protection == true + - result.hcloud_storage_box.access_settings.reachable_externally == true + - result.hcloud_storage_box.access_settings.samba_enabled == false + - result.hcloud_storage_box.access_settings.ssh_enabled == false + - result.hcloud_storage_box.access_settings.webdav_enabled == false + - result.hcloud_storage_box.access_settings.zfs_enabled == false + - result.hcloud_storage_box.snapshot_plan.max_snapshots == 10 + - result.hcloud_storage_box.snapshot_plan.hour == 4 + - result.hcloud_storage_box.snapshot_plan.minute == 30 + +- name: Test update idempotency + hetzner.hcloud.storage_box: + id: "{{ result.hcloud_storage_box.id }}" + name: "{{ hcloud_storage_box_name }}" # Update + type: "{{ hcloud_storage_box_type_upgrade_name }}" # Update + location: "{{ hcloud_location_name }}" + ssh_keys: + - "{{ test_ssh_keypair.public_key }}" + access_settings: # Update + ssh_enabled: false + reachable_externally: true + snapshot_plan: + max_snapshots: 10 + hour: 4 # Update + minute: 30 + labels: + key: changed # Update + delete_protection: true # Update + register: result +- name: Verify update idempotency + ansible.builtin.assert: + that: + - result is not changed + +- name: Test delete with delete protection + hetzner.hcloud.storage_box: + name: "{{ hcloud_storage_box_name }}" + state: absent + ignore_errors: true + register: result +- name: Verify delete with delete protection + ansible.builtin.assert: + that: + - result is failed + - result.failure.code == "protected" + +- name: Test update delete protection + hetzner.hcloud.storage_box: + name: "{{ hcloud_storage_box_name }}" + delete_protection: false + register: result +- name: Verify update delete protection + ansible.builtin.assert: + that: + - result is changed + - result.hcloud_storage_box.delete_protection == false + +- name: Test update snapshot plan + hetzner.hcloud.storage_box: + name: "{{ hcloud_storage_box_name }}" + snapshot_plan: null + register: result +- name: Verify update snapshot plan + ansible.builtin.assert: + that: + - result is changed + - result.hcloud_storage_box.snapshot_plan is none + +- name: Test reset password + hetzner.hcloud.storage_box: + name: "{{ hcloud_storage_box_name }}" + password: "{{ hcloud_storage_box_password }}" + state: reset_password + register: result +- name: Verify reset password + ansible.builtin.assert: + that: + - result is changed + +# TODO: take snapshot and rollback +# - name: Test rollback snapshot +# hetzner.hcloud.storage_box: +# name: "{{ hcloud_storage_box_name }}" +# snapshot: "TODO" +# state: rollback_snapshot +# register: result +# - name: Verify rollback snapshot +# ansible.builtin.assert: +# that: +# - result is changed + +- name: Test delete + hetzner.hcloud.storage_box: + name: "{{ hcloud_storage_box_name }}" + state: absent + register: result +- name: Verify delete + ansible.builtin.assert: + that: + - result is changed + - result.hcloud_storage_box is none diff --git a/tests/integration/targets/storage_box_info/aliases b/tests/integration/targets/storage_box_info/aliases new file mode 100644 index 0000000..18b1111 --- /dev/null +++ b/tests/integration/targets/storage_box_info/aliases @@ -0,0 +1,3 @@ +cloud/hcloud +gather_facts/no +azp/group2 diff --git a/tests/integration/targets/storage_box_info/defaults/main/common.yml b/tests/integration/targets/storage_box_info/defaults/main/common.yml new file mode 100644 index 0000000..015c3b5 --- /dev/null +++ b/tests/integration/targets/storage_box_info/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_info/defaults/main/main.yml b/tests/integration/targets/storage_box_info/defaults/main/main.yml new file mode 100644 index 0000000..69c5659 --- /dev/null +++ b/tests/integration/targets/storage_box_info/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_info/meta/main.yml b/tests/integration/targets/storage_box_info/meta/main.yml new file mode 100644 index 0000000..3a96ecb --- /dev/null +++ b/tests/integration/targets/storage_box_info/meta/main.yml @@ -0,0 +1,3 @@ +--- +dependencies: + - setup_ssh_keypair diff --git a/tests/integration/targets/storage_box_info/tasks/cleanup.yml b/tests/integration/targets/storage_box_info/tasks/cleanup.yml new file mode 100644 index 0000000..d0ab906 --- /dev/null +++ b/tests/integration/targets/storage_box_info/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_info/tasks/main.yml b/tests/integration/targets/storage_box_info/tasks/main.yml new file mode 100644 index 0000000..767fc46 --- /dev/null +++ b/tests/integration/targets/storage_box_info/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_info/tasks/prepare.yml b/tests/integration/targets/storage_box_info/tasks/prepare.yml new file mode 100644 index 0000000..ab381a9 --- /dev/null +++ b/tests/integration/targets/storage_box_info/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 + zfs_enabled: false + labels: + key: "{{ hcloud_ns }}" + register: test_storage_box diff --git a/tests/integration/targets/storage_box_info/tasks/test.yml b/tests/integration/targets/storage_box_info/tasks/test.yml new file mode 100644 index 0000000..e5971ef --- /dev/null +++ b/tests/integration/targets/storage_box_info/tasks/test.yml @@ -0,0 +1,83 @@ +# 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: Gather hcloud_storage_box_info + hetzner.hcloud.storage_box_info: + register: result +- name: Verify hcloud_storage_box_info + ansible.builtin.assert: + that: + - result.hcloud_storage_box_info | list | count >= 1 + +- name: Gather hcloud_storage_box_info in check mode + hetzner.hcloud.storage_box_info: + check_mode: true + register: result +- name: Verify hcloud_storage_box_info in check mode + ansible.builtin.assert: + that: + - result.hcloud_storage_box_info | list | count >= 1 + +- name: Gather hcloud_storage_box_info with label + hetzner.hcloud.storage_box_info: + label_selector: key={{ hcloud_ns }} + register: result +- name: Verify hcloud_storage_box_info + ansible.builtin.assert: + that: + - result.hcloud_storage_box_info | list | count == 1 + +- name: Gather hcloud_storage_box_info with correct id + hetzner.hcloud.storage_box_info: + id: "{{ test_storage_box.hcloud_storage_box.id }}" + register: result +- name: Verify hcloud_storage_box_info with correct id + ansible.builtin.assert: + that: + - result.hcloud_storage_box_info | list | count == 1 + - result.hcloud_storage_box_info[0].id == test_storage_box.hcloud_storage_box.id + - result.hcloud_storage_box_info[0].name == hcloud_storage_box_name + - result.hcloud_storage_box_info[0].storage_box_type == hcloud_storage_box_type_name + - result.hcloud_storage_box_info[0].location == hcloud_location_name + - result.hcloud_storage_box_info[0].labels.key == hcloud_ns + - result.hcloud_storage_box_info[0].delete_protection == false + - result.hcloud_storage_box_info[0].access_settings.reachable_externally == false + - result.hcloud_storage_box_info[0].access_settings.samba_enabled == false + - result.hcloud_storage_box_info[0].access_settings.ssh_enabled == true + - result.hcloud_storage_box_info[0].access_settings.webdav_enabled == false + - result.hcloud_storage_box_info[0].access_settings.zfs_enabled == false + - result.hcloud_storage_box_info[0].status == "active" + - result.hcloud_storage_box_info[0].server is not none + - result.hcloud_storage_box_info[0].system is not none + - result.hcloud_storage_box_info[0].username is not none + - result.hcloud_storage_box_info[0].stats.size >= 0 + - result.hcloud_storage_box_info[0].stats.size_data >= 0 + - result.hcloud_storage_box_info[0].stats.size_snapshots >= 0 + +- name: Gather hcloud_storage_box_info with wrong id + hetzner.hcloud.storage_box_info: + id: "{{ test_storage_box.hcloud_storage_box.id }}4321" + ignore_errors: true + register: result +- name: Verify hcloud_storage_box_info with wrong id + ansible.builtin.assert: + that: + - result is failed + +- name: Gather hcloud_storage_box_info with correct name + hetzner.hcloud.storage_box_info: + name: "{{ hcloud_storage_box_name }}" + register: result +- name: Verify hcloud_storage_box_info with correct name + ansible.builtin.assert: + that: + - result.hcloud_storage_box_info | list | count == 1 + +- name: Gather hcloud_storage_box_info with wrong name + hetzner.hcloud.storage_box_info: + name: "{{ hcloud_storage_box_name }}-invalid" + register: result +- name: Verify hcloud_storage_box_info with wrong name + ansible.builtin.assert: + that: + - result.hcloud_storage_box_info | list | count == 0