diff --git a/changelogs/fragments/hcloud_placement_group.yml b/changelogs/fragments/hcloud_placement_group.yml new file mode 100644 index 0000000..7d49484 --- /dev/null +++ b/changelogs/fragments/hcloud_placement_group.yml @@ -0,0 +1,2 @@ +major_changes: + - Introduction of placement groups diff --git a/plugins/modules/hcloud_placement_group.py b/plugins/modules/hcloud_placement_group.py new file mode 100644 index 0000000..a31ab04 --- /dev/null +++ b/plugins/modules/hcloud_placement_group.py @@ -0,0 +1,230 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2020, Hetzner Cloud GmbH +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = """ +--- +module: hcloud_placement_group + +short_description: Create and manage placement groups on the Hetzner Cloud. + + +description: + - Create, update and manage placement groups on the Hetzner Cloud. + +author: + - Adrian Huber (@Adi146) + +options: + id: + description: + - The ID of the Hetzner Cloud placement group to manage. + - Only required if no placement group I(name) is given + type: int + name: + description: + - The Name of the Hetzner Cloud placement group to manage. + - Only required if no placement group I(id) is given, or a placement group does not exists. + type: str + labels: + description: + - User-defined labels (key-value pairs) + type: dict + type: + description: + - The Type of the Hetzner Cloud placement group. + type: str + state: + description: + - State of the placement group. + default: present + choices: [ absent, present ] + type: str + +requirements: + - hcloud-python >= 1.15.0 + +extends_documentation_fragment: +- hetzner.hcloud.hcloud +""" + +EXAMPLES = """ +- name: Create a basic placement group + hcloud_placement_group: + name: my-placement-group + state: present + type: spread + +- name: Create a placement group with labels + hcloud_placement_group: + name: my-placement-group + type: spread + labels: + key: value + mylabel: 123 + state: present + +- name: Ensure the placement group is absent (remove if needed) + hcloud_placement_group: + name: my-placement-group + state: absent +""" + +RETURN = """ +hcloud_placement_group: + description: The placement group instance + returned: Always + type: complex + contains: + id: + description: Numeric identifier of the placement group + returned: always + type: int + sample: 1937415 + name: + description: Name of the placement group + returned: always + type: str + sample: my placement group + labels: + description: User-defined labels (key-value pairs) + returned: always + type: dict + type: + description: Type of the placement group + returned: always + type: str + sample: spread + servers: + description: Server IDs of the placement group + returned: always + type: list + elements: int + sample: + - 4711 + - 4712 +""" + +from ansible_collections.hetzner.hcloud.plugins.module_utils.hcloud import Hcloud +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils._text import to_native + + +class AnsibleHcloudPlacementGroup(Hcloud): + def __init__(self, module): + Hcloud.__init__(self, module, "hcloud_placement_group") + self.hcloud_placement_group = None + + def _prepare_result(self): + return { + "id": to_native(self.hcloud_placement_group.id), + "name": to_native(self.hcloud_placement_group.name), + "labels": self.hcloud_placement_group.labels, + "type": to_native(self.hcloud_placement_group.type), + "servers": self.hcloud_placement_group.servers, + } + + def _get_placement_group(self): + try: + if self.module.params.get("id") is not None: + self.hcloud_placement_group = self.client.placement_groups.get_by_id( + self.module.params.get("id") + ) + elif self.module.params.get("name") is not None: + self.hcloud_placement_group = self.client.placement_groups.get_by_name( + self.module.params.get("name") + ) + except Exception as e: + self.module.fail_json(msg=e.message) + + def _create_placement_group(self): + self.module.fail_on_missing_params( + required_params=["name"] + ) + params = { + "name": self.module.params.get("name"), + "type": self.module.params.get("type"), + "labels": self.module.params.get("labels"), + } + if not self.module.check_mode: + try: + self.client.placement_groups.create(**params) + except Exception as e: + self.module.fail_json(msg=e.message, **params) + self._mark_as_changed() + self._get_placement_group() + + def _update_placement_group(self): + name = self.module.params.get("name") + if name is not None and self.hcloud_placement_group.name != name: + self.module.fail_on_missing_params( + required_params=["id"] + ) + if not self.module.check_mode: + self.hcloud_placement_group.update(name=name) + self._mark_as_changed() + + labels = self.module.params.get("labels") + if labels is not None and self.hcloud_placement_group.labels != labels: + if not self.module.check_mode: + self.hcloud_placement_group.update(labels=labels) + self._mark_as_changed() + + self._get_placement_group() + + def present_placement_group(self): + self._get_placement_group() + if self.hcloud_placement_group is None: + self._create_placement_group() + else: + self._update_placement_group() + + def delete_placement_group(self): + self._get_placement_group() + if self.hcloud_placement_group is not None: + if not self.module.check_mode: + self.client.placement_groups.delete(self.hcloud_placement_group) + self._mark_as_changed() + self.hcloud_placement_group = None + + @staticmethod + def define_module(): + return AnsibleModule( + argument_spec=dict( + id={"type": "int"}, + name={"type": "str"}, + labels={"type": "dict"}, + type={"type": "str"}, + state={ + "choices": ["absent", "present"], + "default": "present", + }, + **Hcloud.base_module_arguments() + ), + required_one_of=[['id', 'name']], + required_if=[['state', 'present', ['name']]], + supports_check_mode=True, + ) + + +def main(): + module = AnsibleHcloudPlacementGroup.define_module() + + hcloud = AnsibleHcloudPlacementGroup(module) + state = module.params.get("state") + if state == "absent": + hcloud.delete_placement_group() + elif state == "present": + hcloud.present_placement_group() + + module.exit_json(**hcloud.get_result()) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/hcloud_server.py b/plugins/modules/hcloud_server.py index 62943fb..9238afe 100644 --- a/plugins/modules/hcloud_server.py +++ b/plugins/modules/hcloud_server.py @@ -81,10 +81,17 @@ options: default: no force_upgrade: description: + - Deprecated - Force the upgrade of the server. - Power off the server if it is running on upgrade. type: bool default: no + force: + description: + - Force the update of the server. + - May power off the server if update. + type: bool + default: no allow_deprecated_image: description: - Allows the creation of servers with deprecated images. @@ -113,6 +120,10 @@ options: - Protect the Server for rebuild. - Needs to be the same as I(delete_protection). type: bool + placement_group: + description: + - Placement Group of the server. + type: str state: description: - State of the server. @@ -180,6 +191,19 @@ EXAMPLES = """ name: my-server image: ubuntu-18.04 state: rebuild + +- name: Add server to placement group + hcloud_server: + name: my-server + placement_group: my-placement-group + force: True + state: present + +- name: Remove server from placement group + hcloud_server: + name: my-server + placement_group: null + state: present """ RETURN = """ @@ -223,6 +247,12 @@ hcloud_server: returned: always type: str sample: fsn1 + placement_group: + description: Placement Group of the server + type: str + returned: always + sample: 4711 + version_added: "1.5.0" datacenter: description: Name of the datacenter of the server returned: always @@ -283,6 +313,7 @@ class AnsibleHcloudServer(Hcloud): def _prepare_result(self): image = None if self.hcloud_server.image is None else to_native(self.hcloud_server.image.name) + placement_group = None if self.hcloud_server.placement_group is None else to_native(self.hcloud_server.placement_group.name) return { "id": to_native(self.hcloud_server.id), "name": to_native(self.hcloud_server.name), @@ -292,6 +323,7 @@ class AnsibleHcloudServer(Hcloud): "server_type": to_native(self.hcloud_server.server_type.name), "datacenter": to_native(self.hcloud_server.datacenter.name), "location": to_native(self.hcloud_server.datacenter.location.name), + "placement_group": placement_group, "rescue_enabled": self.hcloud_server.rescue_enabled, "backup_window": to_native(self.hcloud_server.backup_window), "labels": self.hcloud_server.labels, @@ -323,7 +355,8 @@ class AnsibleHcloudServer(Hcloud): "server_type": self._get_server_type(), "user_data": self.module.params.get("user_data"), "labels": self.module.params.get("labels"), - "image": self._get_image() + "image": self._get_image(), + "placement_group": self._get_placement_group(), } if self.module.params.get("ssh_keys") is not None: @@ -424,8 +457,27 @@ class AnsibleHcloudServer(Hcloud): return server_type + def _get_placement_group(self): + if self.module.params.get("placement_group") is None: + return None + + placement_group = self.client.placement_groups.get_by_name( + self.module.params.get("placement_group") + ) + if placement_group is None: + try: + placement_group = self.client.placement_groups.get_by_id(self.module.params.get("placement_group")) + except Exception: + self.module.fail_json(msg="placement_group %s was not found" % self.module.params.get("placement_group")) + + return placement_group + def _update_server(self): + self.module.warn("force_upgrade is deprecated, use force instead") + try: + previous_server_status = self.hcloud_server.status + rescue_mode = self.module.params.get("rescue_mode") if rescue_mode and self.hcloud_server.rescue_enabled is False: if not self.module.check_mode: @@ -482,19 +534,26 @@ class AnsibleHcloudServer(Hcloud): for a in actions: a.wait_until_finished() + if "placement_group" in self.module.params: + if self.module.params["placement_group"] is None and self.hcloud_server.placement_group is not None: + if not self.module.check_mode: + self.hcloud_server.remove_from_placement_group().wait_until_finished() + self._mark_as_changed() + else: + placement_group = self._get_placement_group() + if ( + placement_group is not None and + (self.hcloud_server.placement_group is None or self.hcloud_server.placement_group.id != placement_group.id) + ): + self.stop_server_if_forced() + if not self.module.check_mode: + self.hcloud_server.add_to_placement_group(placement_group) + self._mark_as_changed() + server_type = self.module.params.get("server_type") if server_type is not None and self.hcloud_server.server_type.name != server_type: - previous_server_status = self.hcloud_server.status - state = self.module.params.get("state") - if previous_server_status == Server.STATUS_RUNNING: - if not self.module.check_mode: - if self.module.params.get("force_upgrade") or state == "stopped": - self.stop_server() # Only stopped server can be upgraded - else: - self.module.warn( - "You can not upgrade a running instance %s. You need to stop the instance or use force_upgrade=yes." - % self.hcloud_server.name - ) + self.stop_server_if_forced() + timeout = 100 if self.module.params.get("upgrade_disk"): timeout = ( @@ -505,11 +564,17 @@ class AnsibleHcloudServer(Hcloud): server_type=self._get_server_type(), upgrade_disk=self.module.params.get("upgrade_disk"), ).wait_until_finished(timeout) - if state == "present" and previous_server_status == Server.STATUS_RUNNING or state == "started": - self.start_server() - self._mark_as_changed() + if ( + not self.module.check_mode and + ( + self.module.params.get("state") == "present" and previous_server_status == Server.STATUS_RUNNING or + self.module.params.get("state") == "started" + ) + ): + self.start_server() + delete_protection = self.module.params.get("delete_protection") rebuild_protection = self.module.params.get("rebuild_protection") if (delete_protection is not None and rebuild_protection is not None) and ( @@ -557,6 +622,24 @@ class AnsibleHcloudServer(Hcloud): except Exception as e: self.module.fail_json(msg=e.message) + def stop_server_if_forced(self): + previous_server_status = self.hcloud_server.status + if previous_server_status == Server.STATUS_RUNNING and not self.module.check_mode: + if ( + self.module.params.get("force_upgrade") or + self.module.params.get("force") or + self.module.params.get("state") == "stopped" + ): + self.stop_server() # Only stopped server can be upgraded + return previous_server_status + else: + self.module.warn( + "You can not upgrade a running instance %s. You need to stop the instance or use force=yes." + % self.hcloud_server.name + ) + + return None + def rebuild_server(self): self.module.fail_on_missing_params( required_params=["image"] @@ -606,11 +689,13 @@ class AnsibleHcloudServer(Hcloud): labels={"type": "dict"}, backups={"type": "bool"}, upgrade_disk={"type": "bool", "default": False}, + force={"type": "bool", "default": False}, force_upgrade={"type": "bool", "default": False}, allow_deprecated_image={"type": "bool", "default": False}, rescue_mode={"type": "str"}, delete_protection={"type": "bool"}, rebuild_protection={"type": "bool"}, + placement_group={"type": "str"}, state={ "choices": ["absent", "present", "restarted", "started", "stopped", "rebuild"], "default": "present", diff --git a/plugins/modules/hcloud_server_info.py b/plugins/modules/hcloud_server_info.py index 80fb61d..f156496 100644 --- a/plugins/modules/hcloud_server_info.py +++ b/plugins/modules/hcloud_server_info.py @@ -92,6 +92,12 @@ hcloud_server_info: returned: always type: str sample: fsn1 + placement_group: + description: Placement Group of the server + type: str + returned: always + sample: 4711 + version_added: "1.5.0" datacenter: description: Name of the datacenter of the server returned: always @@ -146,6 +152,7 @@ class AnsibleHcloudServerInfo(Hcloud): for server in self.hcloud_server_info: if server is not None: image = None if server.image is None else to_native(server.image.name) + placement_group = None if server.placement_group is None else to_native(server.placement_group.name) tmp.append({ "id": to_native(server.id), "name": to_native(server.name), @@ -155,6 +162,7 @@ class AnsibleHcloudServerInfo(Hcloud): "server_type": to_native(server.server_type.name), "datacenter": to_native(server.datacenter.name), "location": to_native(server.datacenter.location.name), + "placement_group": placement_group, "rescue_enabled": server.rescue_enabled, "backup_window": to_native(server.backup_window), "labels": server.labels, diff --git a/tests/integration/targets/hcloud_placement_group/aliases b/tests/integration/targets/hcloud_placement_group/aliases new file mode 100644 index 0000000..55ec821 --- /dev/null +++ b/tests/integration/targets/hcloud_placement_group/aliases @@ -0,0 +1,2 @@ +cloud/hcloud +shippable/hcloud/group2 diff --git a/tests/integration/targets/hcloud_placement_group/defaults/main.yml b/tests/integration/targets/hcloud_placement_group/defaults/main.yml new file mode 100644 index 0000000..7557ffb --- /dev/null +++ b/tests/integration/targets/hcloud_placement_group/defaults/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_prefix: "tests" +hcloud_placement_group_name: "{{hcloud_prefix}}-i" +hcloud_server_name: "{{hcloud_prefix}}-i" diff --git a/tests/integration/targets/hcloud_placement_group/meta/main.yml b/tests/integration/targets/hcloud_placement_group/meta/main.yml new file mode 100644 index 0000000..407c901 --- /dev/null +++ b/tests/integration/targets/hcloud_placement_group/meta/main.yml @@ -0,0 +1,3 @@ +collections: + - community.general.ipfilter + - hetzner.cloud diff --git a/tests/integration/targets/hcloud_placement_group/tasks/main.yml b/tests/integration/targets/hcloud_placement_group/tasks/main.yml new file mode 100644 index 0000000..df47b1b --- /dev/null +++ b/tests/integration/targets/hcloud_placement_group/tasks/main.yml @@ -0,0 +1,169 @@ +# Copyright: (c) 2020, Hetzner Cloud GmbH +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +--- +- name: setup placement group to be absent + hcloud_placement_group: + name: "{{ hcloud_placement_group_name }}" + state: absent + +- name: setup server to be absent + hcloud_server: + name: "{{ hcloud_server_name }}" + state: absent + +- name: test missing required parameters on create placement group + hcloud_placement_group: + register: result + ignore_errors: yes +- name: verify fail test missing required parameters on create placement group + assert: + that: + - result is failed + - 'result.msg == "one of the following is required: id, name"' + +- name: test create placement group with check mode + hcloud_placement_group: + name: "{{ hcloud_placement_group_name }}" + type: spread + register: result + check_mode: yes +- name: test create placement group with check mode + assert: + that: + - result is changed + +- name: test create placement group + hcloud_placement_group: + name: "{{ hcloud_placement_group_name }}" + type: spread + labels: + key: value + my-label: label + register: placement_group +- name: verify create placement group + assert: + that: + - placement_group is changed + - placement_group.hcloud_placement_group.name == "{{ hcloud_placement_group_name }}" + - placement_group.hcloud_placement_group.type == "spread" + - placement_group.hcloud_placement_group.servers | list | count == 0 + +- name: test create placement group idempotence + hcloud_placement_group: + name: "{{ hcloud_placement_group_name }}" + type: spread + labels: + key: value + my-label: label + register: result +- name: verify create placement group idempotence + assert: + that: + - result is not changed + +- name: test create server with placement group + hcloud_server: + name: "{{ hcloud_server_name }}" + server_type: cpx11 + placement_group: "{{ hcloud_placement_group_name }}" + image: "ubuntu-20.04" + ssh_keys: + - ci@ansible.hetzner.cloud + state: present + register: server +- name: verify create server with placement group + assert: + that: + - server is changed + - server.hcloud_server.placement_group == "{{ hcloud_placement_group_name }}" + +- name: test remove server from placement group + hcloud_server: + name: "{{ hcloud_server_name }}" + placement_group: null + state: present + register: result +- name: verify remove server from placement group + assert: + that: + - result is changed + - result.hcloud_server.placement_group == None + +- name: test add server to placement group + hcloud_server: + name: "{{ hcloud_server_name }}" + placement_group: "{{ hcloud_server_name }}" + force: True + state: present + register: result +- name: verify add server to placement group + assert: + that: + - result is changed + - result.hcloud_server.placement_group == "{{ hcloud_placement_group_name }}" + - result.hcloud_server.status == "running" + +- name: test add server to placement group idempotence + hcloud_server: + name: "{{ hcloud_server_name }}" + placement_group: "{{ hcloud_server_name }}" + force: True + state: present + register: result +- name: verify add server to placement group idempotence + assert: + that: + - result is not changed + - result.hcloud_server.placement_group == "{{ hcloud_placement_group_name }}" + - result.hcloud_server.status == "running" + +- name: test update placement group with check mode + hcloud_placement_group: + id: "{{ placement_group.hcloud_placement_group.id }}" + name: "changed-{{ hcloud_placement_group_name }}" + register: result + check_mode: yes +- name: verify update placement group with check mode + assert: + that: + - result is changed + +- name: test update placement group + hcloud_placement_group: + id: "{{ placement_group.hcloud_placement_group.id }}" + name: "changed-{{ hcloud_placement_group_name }}" + labels: + key: value + register: result +- name: verify update placement group + assert: + that: + - result is changed + - result.hcloud_placement_group.name == "changed-{{ hcloud_placement_group_name }}" + +- name: test update placement group idempotence + hcloud_placement_group: + id: "{{ placement_group.hcloud_placement_group.id }}" + name: "changed-{{ hcloud_placement_group_name }}" + labels: + key: value + register: result +- name: verify update placement group idempotence + assert: + that: + - result is not changed + +- name: absent server + hcloud_server: + id: "{{ server.hcloud_server.id }}" + state: absent + +- name: absent placement group + hcloud_placement_group: + id: "{{ placement_group.hcloud_placement_group.id }}" + state: absent + register: result +- name: verify absent placement group + assert: + that: + - result is success diff --git a/tests/integration/targets/hcloud_server/tasks/main.yml b/tests/integration/targets/hcloud_server/tasks/main.yml index 8534c0a..d72cf6b 100644 --- a/tests/integration/targets/hcloud_server/tasks/main.yml +++ b/tests/integration/targets/hcloud_server/tasks/main.yml @@ -49,22 +49,6 @@ - result is failed - 'result.msg == "Image my-not-existing-image-20.04 was not found"' -# Temporary test case to test deprecated images. This test will fail when the ubuntu-16.04 image was removed -# feel free to remove this test then. -- name: test create server with deprecated image - hcloud_server: - name: "{{ hcloud_server_name }}" - server_type: cx11 - image: ubuntu-16.04 - state: present - register: result - ignore_errors: yes -- name: verify fail test create server deprecated image - assert: - that: - - result is failed - - 'result.msg == "You try to use a deprecated image. The image ubuntu-16.04 will continue to be available until 2021-06-22. If you want to use this image use allow_deprecated_image=yes."' - - name: test create server with check mode hcloud_server: name: "{{ hcloud_server_name }}"