From 66aaef7be4b145364115723f88ce5725f1bc4499 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Wed, 5 Nov 2025 16:15:08 +0100 Subject: [PATCH] feat: attach server or load balancer to specific subnet (#726) ##### SUMMARY Attach the server or load balancer to the specific subnet ip_range. ##### ISSUE TYPE - Feature Pull Request ##### COMPONENT NAME - server_network - load_balancer_network --- .../fragments/attach-to-network-ip-range.yml | 3 + plugins/modules/load_balancer_network.py | 98 ++++++++++++++++--- plugins/modules/server_network.py | 69 ++++++++++--- .../load_balancer_network/tasks/prepare.yml | 12 ++- .../load_balancer_network/tasks/test.yml | 56 +++++++++++ .../targets/server_network/tasks/prepare.yml | 12 ++- .../targets/server_network/tasks/test.yml | 56 +++++++++++ 7 files changed, 274 insertions(+), 32 deletions(-) create mode 100644 changelogs/fragments/attach-to-network-ip-range.yml diff --git a/changelogs/fragments/attach-to-network-ip-range.yml b/changelogs/fragments/attach-to-network-ip-range.yml new file mode 100644 index 0000000..d395472 --- /dev/null +++ b/changelogs/fragments/attach-to-network-ip-range.yml @@ -0,0 +1,3 @@ +minor_changes: + - load_balancer_network - Add ``ip_range`` argument to attach a load balancer to a specific subnet. + - server_network - Add ``ip_range`` argument to attach a load balancer to a specific subnet. diff --git a/plugins/modules/load_balancer_network.py b/plugins/modules/load_balancer_network.py index b6ec486..a5304bb 100644 --- a/plugins/modules/load_balancer_network.py +++ b/plugins/modules/load_balancer_network.py @@ -30,6 +30,10 @@ options: - Name or ID of the Hetzner Cloud Load Balancer. type: str required: true + ip_range: + description: + - IP range in CIDR block notation of the subnet to attach to. + type: str ip: description: - The IP the Load Balancer should have. @@ -48,21 +52,28 @@ extends_documentation_fragment: EXAMPLES = """ - name: Create a basic Load Balancer network hetzner.hcloud.load_balancer_network: - network: my-network load_balancer: my-LoadBalancer + network: my-network + state: present + +- name: Create a Load Balancer network and specify the subnet + hetzner.hcloud.load_balancer_network: + load_balancer: my-LoadBalancer + network: my-network + ip_range: 10.1.0.0/24 state: present - name: Create a Load Balancer network and specify the ip address hetzner.hcloud.load_balancer_network: - network: my-network load_balancer: my-LoadBalancer + network: my-network ip: 10.0.0.1 state: present - name: Ensure the Load Balancer network is absent (remove if needed) hetzner.hcloud.load_balancer_network: - network: my-network load_balancer: my-LoadBalancer + network: my-network state: absent """ @@ -89,6 +100,9 @@ hcloud_load_balancer_network: sample: 10.0.0.8 """ +from ipaddress import ip_address, ip_network +from time import sleep + from ansible.module_utils.basic import AnsibleModule from ..module_utils.hcloud import AnsibleHCloud @@ -127,13 +141,18 @@ class AnsibleHCloudLoadBalancerNetwork(AnsibleHCloud): self.fail_json_hcloud(exception) def _get_load_balancer_network(self): + self.hcloud_load_balancer_network = None for private_net in self.hcloud_load_balancer.private_net: if private_net.network.id == self.hcloud_network.id: self.hcloud_load_balancer_network = private_net - def _create_load_balancer_network(self): - params = {"network": self.hcloud_network} + def _attach(self): + params = { + "network": self.hcloud_network, + } + if self.module.params.get("ip_range") is not None: + params["ip_range"] = self.module.params.get("ip_range") if self.module.params.get("ip") is not None: params["ip"] = self.module.params.get("ip") @@ -141,39 +160,90 @@ class AnsibleHCloudLoadBalancerNetwork(AnsibleHCloud): try: action = self.hcloud_load_balancer.attach_to_network(**params) action.wait_until_finished() + + # Workaround to handle flakiness from the API + self._wait_for_attachment(True) except HCloudException as exception: self.fail_json_hcloud(exception) self._mark_as_changed() + + def _detach(self): + if not self.module.check_mode: + try: + action = self.hcloud_load_balancer.detach_from_network(self.hcloud_load_balancer_network.network) + action.wait_until_finished() + + # Workaround to handle flakiness from the API + self._wait_for_attachment(False) + except HCloudException as exception: + self.fail_json_hcloud(exception) + + self._mark_as_changed() + + def _create_load_balancer_network(self): + self._attach() + self._get_load_balancer_and_network() self._get_load_balancer_network() + def _update_load_balancer_network(self): + ip_range = self.module.params.get("ip_range") + if ip_range is not None: + ip_range_network = ip_network(ip_range) + if ip_range_network not in [ip_network(o.ip_range) for o in self.hcloud_network.subnets]: + # Validate before "detach" instead of relying on the "attach" API + # validation, leaving the resource in a half applied state. + self.module.fail_json(msg=f"ip_range '{ip_range}' was not found in the network subnets") + + if ip_address(self.hcloud_load_balancer_network.ip) not in ip_range_network: + self._detach() + self._attach() + + # No further updates needed, exit + self._get_load_balancer_and_network() + self._get_load_balancer_network() + return + def present_load_balancer_network(self): self._get_load_balancer_and_network() self._get_load_balancer_network() if self.hcloud_load_balancer_network is None: self._create_load_balancer_network() + else: + self._update_load_balancer_network() def delete_load_balancer_network(self): self._get_load_balancer_and_network() self._get_load_balancer_network() if self.hcloud_load_balancer_network is not None and self.hcloud_load_balancer is not None: - if not self.module.check_mode: - try: - action = self.hcloud_load_balancer.detach_from_network(self.hcloud_load_balancer_network.network) - action.wait_until_finished() - self._mark_as_changed() - except HCloudException as exception: - self.fail_json_hcloud(exception) - + self._detach() self.hcloud_load_balancer_network = None + # Workaround to handle flakiness from the API + def _wait_for_attachment(self, present: bool): + def done(x: PrivateNet | None): + if present: + return x is not None + return x is None + + # pylint: disable=disallowed-name + for _ in range(10): + self.hcloud_load_balancer.reload() + self._get_load_balancer_network() + + if done(self.hcloud_load_balancer_network): + break + + sleep(2) + @classmethod def define_module(cls): return AnsibleModule( argument_spec=dict( - network={"type": "str", "required": True}, load_balancer={"type": "str", "required": True}, + network={"type": "str", "required": True}, + ip_range={"type": "str"}, ip={"type": "str"}, state={ "choices": ["absent", "present"], diff --git a/plugins/modules/server_network.py b/plugins/modules/server_network.py index bc6dec4..58cf7e5 100644 --- a/plugins/modules/server_network.py +++ b/plugins/modules/server_network.py @@ -20,16 +20,20 @@ author: - Lukas Kaemmerling (@lkaemmerling) options: - network: - description: - - Name or ID of the Hetzner Cloud Networks. - type: str - required: true server: description: - Name or ID of the Hetzner Cloud server. type: str required: true + network: + description: + - Name or ID of the Hetzner Cloud Networks. + type: str + required: true + ip_range: + description: + - IP range in CIDR block notation of the subnet to attach to. + type: str ip: description: - The IP the server should have. @@ -64,6 +68,13 @@ EXAMPLES = """ ip: 10.0.0.1 state: present +- name: Create a server network and specify the subnet + hetzner.hcloud.server_network: + network: my-network + server: my-server + ip_range: 10.1.0.0/24 + state: present + - name: Create a server network and add alias ips hetzner.hcloud.server_network: network: my-network @@ -110,6 +121,8 @@ hcloud_server_network: sample: [10.1.0.1, ...] """ +from ipaddress import ip_address, ip_network + from ansible.module_utils.basic import AnsibleModule from ..module_utils.hcloud import AnsibleHCloud @@ -152,11 +165,13 @@ class AnsibleHCloudServerNetwork(AnsibleHCloud): if private_net.network.id == self.hcloud_network.id: self.hcloud_server_network = private_net - def _create_server_network(self): + def _attach(self): params = { "network": self.hcloud_network, } + if self.module.params.get("ip_range") is not None: + params["ip_range"] = self.module.params.get("ip_range") if self.module.params.get("ip") is not None: params["ip"] = self.module.params.get("ip") if self.module.params.get("alias_ips") is not None: @@ -170,10 +185,40 @@ class AnsibleHCloudServerNetwork(AnsibleHCloud): self.fail_json_hcloud(exception) self._mark_as_changed() + + def _detach(self): + if not self.module.check_mode: + try: + action = self.hcloud_server.detach_from_network(self.hcloud_network) + action.wait_until_finished() + except HCloudException as exception: + self.fail_json_hcloud(exception) + + self._mark_as_changed() + + def _create_server_network(self): + self._attach() self._get_server_and_network() self._get_server_network() def _update_server_network(self): + ip_range = self.module.params.get("ip_range") + if ip_range is not None: + ip_range_network = ip_network(ip_range) + if ip_range_network not in [ip_network(o.ip_range) for o in self.hcloud_network.subnets]: + # Validate before "detach" instead of relying on the "attach" API + # validation, leaving the resource in a half applied state. + self.module.fail_json(msg=f"ip_range '{ip_range}' was not found in the network subnets") + + if ip_address(self.hcloud_server_network.ip) not in ip_range_network: + self._detach() + self._attach() + + # No further updates needed, exit + self._get_server_and_network() + self._get_server_network() + return + params = { "network": self.hcloud_network, } @@ -189,6 +234,7 @@ class AnsibleHCloudServerNetwork(AnsibleHCloud): self.fail_json_hcloud(exception) self._mark_as_changed() + self._get_server_and_network() self._get_server_network() @@ -204,21 +250,16 @@ class AnsibleHCloudServerNetwork(AnsibleHCloud): self._get_server_and_network() self._get_server_network() if self.hcloud_server_network is not None and self.hcloud_server is not None: - if not self.module.check_mode: - try: - action = self.hcloud_server.detach_from_network(self.hcloud_server_network.network) - action.wait_until_finished() - except HCloudException as exception: - self.fail_json_hcloud(exception) - self._mark_as_changed() + self._detach() self.hcloud_server_network = None @classmethod def define_module(cls): return AnsibleModule( argument_spec=dict( - network={"type": "str", "required": True}, server={"type": "str", "required": True}, + network={"type": "str", "required": True}, + ip_range={"type": "str"}, ip={"type": "str"}, alias_ips={"type": "list", "elements": "str"}, state={ diff --git a/tests/integration/targets/load_balancer_network/tasks/prepare.yml b/tests/integration/targets/load_balancer_network/tasks/prepare.yml index e1d0df3..0d85fbb 100644 --- a/tests/integration/targets/load_balancer_network/tasks/prepare.yml +++ b/tests/integration/targets/load_balancer_network/tasks/prepare.yml @@ -5,13 +5,21 @@ ip_range: 10.0.0.0/16 register: test_network -- name: Create test_subnetwork +- name: Create test_subnetwork1 hetzner.hcloud.subnetwork: network: "{{ hcloud_network_name }}" network_zone: "{{ hcloud_network_zone_name }}" type: cloud ip_range: 10.0.1.0/24 - register: test_subnetwork + register: test_subnetwork1 + +- name: Create test_subnetwork2 + hetzner.hcloud.subnetwork: + network: "{{ hcloud_network_name }}" + network_zone: "{{ hcloud_network_zone_name }}" + type: cloud + ip_range: 10.0.2.0/24 + register: test_subnetwork2 - name: Create test_load_balancer hetzner.hcloud.load_balancer: diff --git a/tests/integration/targets/load_balancer_network/tasks/test.yml b/tests/integration/targets/load_balancer_network/tasks/test.yml index 127a7ba..b36ca6a 100644 --- a/tests/integration/targets/load_balancer_network/tasks/test.yml +++ b/tests/integration/targets/load_balancer_network/tasks/test.yml @@ -85,6 +85,62 @@ that: - result is changed +- name: Test create with ip_range + hetzner.hcloud.load_balancer_network: + load_balancer: "{{ hcloud_load_balancer_name }}" + network: "{{ hcloud_network_name }}" + ip_range: 10.0.1.0/24 + state: present + register: result +- name: Verify create with ip_range + ansible.builtin.assert: + that: + - result is changed + - result.hcloud_load_balancer_network.load_balancer == hcloud_load_balancer_name + - result.hcloud_load_balancer_network.network == hcloud_load_balancer_name + - > + "10.0.1.0/24" | ansible.utils.network_in_usable(result.hcloud_load_balancer_network.ip) + +- name: Test update with ip_range + hetzner.hcloud.load_balancer_network: + load_balancer: "{{ hcloud_load_balancer_name }}" + network: "{{ hcloud_network_name }}" + ip_range: 10.0.2.0/24 + state: present + register: result +- name: Verify update with ip_range + ansible.builtin.assert: + that: + - result is changed + - result.hcloud_load_balancer_network.load_balancer == hcloud_load_balancer_name + - result.hcloud_load_balancer_network.network == hcloud_load_balancer_name + - > + "10.0.2.0/24" | ansible.utils.network_in_usable(result.hcloud_load_balancer_network.ip) + +- name: Test update with ip_range idempotency + hetzner.hcloud.load_balancer_network: + load_balancer: "{{ hcloud_load_balancer_name }}" + network: "{{ hcloud_network_name }}" + ip_range: 10.0.2.0/24 + state: present + register: result +- name: Verify update with ip_range idempotency + ansible.builtin.assert: + that: + - result is not changed + +- name: Test delete with ip_range + hetzner.hcloud.load_balancer_network: + load_balancer: "{{ hcloud_load_balancer_name }}" + network: "{{ hcloud_network_name }}" + ip_range: 10.0.2.0/24 + state: absent + register: result +- name: Verify delete with ip_range + ansible.builtin.assert: + that: + - result is changed + - name: Test create with ip hetzner.hcloud.load_balancer_network: load_balancer: "{{ hcloud_load_balancer_name }}" diff --git a/tests/integration/targets/server_network/tasks/prepare.yml b/tests/integration/targets/server_network/tasks/prepare.yml index b78af98..9de946b 100644 --- a/tests/integration/targets/server_network/tasks/prepare.yml +++ b/tests/integration/targets/server_network/tasks/prepare.yml @@ -7,13 +7,21 @@ key: value register: test_network -- name: Create test_subnetwork +- name: Create test_subnetwork1 hetzner.hcloud.subnetwork: network: "{{ hcloud_network_name }}" type: server network_zone: "{{ hcloud_network_zone_name }}" ip_range: 10.0.1.0/24 - register: test_subnetwork + register: test_subnetwork1 + +- name: Create test_subnetwork2 + hetzner.hcloud.subnetwork: + network: "{{ hcloud_network_name }}" + type: server + network_zone: "{{ hcloud_network_zone_name }}" + ip_range: 10.0.2.0/24 + register: test_subnetwork2 - name: Create test_server hetzner.hcloud.server: diff --git a/tests/integration/targets/server_network/tasks/test.yml b/tests/integration/targets/server_network/tasks/test.yml index b0ba809..ccc5334 100644 --- a/tests/integration/targets/server_network/tasks/test.yml +++ b/tests/integration/targets/server_network/tasks/test.yml @@ -85,6 +85,62 @@ that: - result is changed +- name: Test create with ip_range + hetzner.hcloud.server_network: + network: "{{ hcloud_network_name }}" + server: "{{ hcloud_server_name }}" + ip_range: "10.0.1.0/24" + state: present + register: result +- name: Verify create with ip_range + ansible.builtin.assert: + that: + - result is changed + - result.hcloud_server_network.network == hcloud_network_name + - result.hcloud_server_network.server == hcloud_server_name + - > + "10.0.1.0/24" | ansible.utils.network_in_usable(result.hcloud_server_network.ip) + +- name: Test update with ip_range + hetzner.hcloud.server_network: + network: "{{ hcloud_network_name }}" + server: "{{ hcloud_server_name }}" + ip_range: "10.0.2.0/24" + state: present + register: result +- name: Verify update with ip_range + ansible.builtin.assert: + that: + - result is changed + - result.hcloud_server_network.network == hcloud_network_name + - result.hcloud_server_network.server == hcloud_server_name + - > + "10.0.2.0/24" | ansible.utils.network_in_usable(result.hcloud_server_network.ip) + +- name: Test update with ip_range idempotency + hetzner.hcloud.server_network: + network: "{{ hcloud_network_name }}" + server: "{{ hcloud_server_name }}" + ip_range: "10.0.2.0/24" + state: present + register: result +- name: Verify update with ip_range idempotency + ansible.builtin.assert: + that: + - result is not changed + +- name: Test delete with ip_range + hetzner.hcloud.server_network: + network: "{{ hcloud_network_name }}" + server: "{{ hcloud_server_name }}" + ip_range: "10.0.2.0/24" + state: absent + register: result +- name: Verify delete with ip_range + ansible.builtin.assert: + that: + - result is changed + - name: Test create with alias ips hetzner.hcloud.server_network: network: "{{ hcloud_network_name }}"