1
0
Fork 0
mirror of https://github.com/ansible-collections/hetzner.hcloud.git synced 2026-02-04 08:01:49 +00:00

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
This commit is contained in:
Jonas L. 2025-11-05 16:15:08 +01:00 committed by GitHub
parent 2b183fb486
commit 66aaef7be4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 274 additions and 32 deletions

View file

@ -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.

View file

@ -30,6 +30,10 @@ options:
- Name or ID of the Hetzner Cloud Load Balancer. - Name or ID of the Hetzner Cloud Load Balancer.
type: str type: str
required: true required: true
ip_range:
description:
- IP range in CIDR block notation of the subnet to attach to.
type: str
ip: ip:
description: description:
- The IP the Load Balancer should have. - The IP the Load Balancer should have.
@ -48,21 +52,28 @@ extends_documentation_fragment:
EXAMPLES = """ EXAMPLES = """
- name: Create a basic Load Balancer network - name: Create a basic Load Balancer network
hetzner.hcloud.load_balancer_network: hetzner.hcloud.load_balancer_network:
network: my-network
load_balancer: my-LoadBalancer 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 state: present
- name: Create a Load Balancer network and specify the ip address - name: Create a Load Balancer network and specify the ip address
hetzner.hcloud.load_balancer_network: hetzner.hcloud.load_balancer_network:
network: my-network
load_balancer: my-LoadBalancer load_balancer: my-LoadBalancer
network: my-network
ip: 10.0.0.1 ip: 10.0.0.1
state: present state: present
- name: Ensure the Load Balancer network is absent (remove if needed) - name: Ensure the Load Balancer network is absent (remove if needed)
hetzner.hcloud.load_balancer_network: hetzner.hcloud.load_balancer_network:
network: my-network
load_balancer: my-LoadBalancer load_balancer: my-LoadBalancer
network: my-network
state: absent state: absent
""" """
@ -89,6 +100,9 @@ hcloud_load_balancer_network:
sample: 10.0.0.8 sample: 10.0.0.8
""" """
from ipaddress import ip_address, ip_network
from time import sleep
from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.basic import AnsibleModule
from ..module_utils.hcloud import AnsibleHCloud from ..module_utils.hcloud import AnsibleHCloud
@ -127,13 +141,18 @@ class AnsibleHCloudLoadBalancerNetwork(AnsibleHCloud):
self.fail_json_hcloud(exception) self.fail_json_hcloud(exception)
def _get_load_balancer_network(self): def _get_load_balancer_network(self):
self.hcloud_load_balancer_network = None
for private_net in self.hcloud_load_balancer.private_net: for private_net in self.hcloud_load_balancer.private_net:
if private_net.network.id == self.hcloud_network.id: if private_net.network.id == self.hcloud_network.id:
self.hcloud_load_balancer_network = private_net self.hcloud_load_balancer_network = private_net
def _create_load_balancer_network(self): def _attach(self):
params = {"network": self.hcloud_network} 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: if self.module.params.get("ip") is not None:
params["ip"] = self.module.params.get("ip") params["ip"] = self.module.params.get("ip")
@ -141,39 +160,90 @@ class AnsibleHCloudLoadBalancerNetwork(AnsibleHCloud):
try: try:
action = self.hcloud_load_balancer.attach_to_network(**params) action = self.hcloud_load_balancer.attach_to_network(**params)
action.wait_until_finished() action.wait_until_finished()
# Workaround to handle flakiness from the API
self._wait_for_attachment(True)
except HCloudException as exception: except HCloudException as exception:
self.fail_json_hcloud(exception) self.fail_json_hcloud(exception)
self._mark_as_changed() 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_and_network()
self._get_load_balancer_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): def present_load_balancer_network(self):
self._get_load_balancer_and_network() self._get_load_balancer_and_network()
self._get_load_balancer_network() self._get_load_balancer_network()
if self.hcloud_load_balancer_network is None: if self.hcloud_load_balancer_network is None:
self._create_load_balancer_network() self._create_load_balancer_network()
else:
self._update_load_balancer_network()
def delete_load_balancer_network(self): def delete_load_balancer_network(self):
self._get_load_balancer_and_network() self._get_load_balancer_and_network()
self._get_load_balancer_network() self._get_load_balancer_network()
if self.hcloud_load_balancer_network is not None and self.hcloud_load_balancer is not None: if self.hcloud_load_balancer_network is not None and self.hcloud_load_balancer is not None:
if not self.module.check_mode: self._detach()
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.hcloud_load_balancer_network = None 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 @classmethod
def define_module(cls): def define_module(cls):
return AnsibleModule( return AnsibleModule(
argument_spec=dict( argument_spec=dict(
network={"type": "str", "required": True},
load_balancer={"type": "str", "required": True}, load_balancer={"type": "str", "required": True},
network={"type": "str", "required": True},
ip_range={"type": "str"},
ip={"type": "str"}, ip={"type": "str"},
state={ state={
"choices": ["absent", "present"], "choices": ["absent", "present"],

View file

@ -20,16 +20,20 @@ author:
- Lukas Kaemmerling (@lkaemmerling) - Lukas Kaemmerling (@lkaemmerling)
options: options:
network:
description:
- Name or ID of the Hetzner Cloud Networks.
type: str
required: true
server: server:
description: description:
- Name or ID of the Hetzner Cloud server. - Name or ID of the Hetzner Cloud server.
type: str type: str
required: true 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: ip:
description: description:
- The IP the server should have. - The IP the server should have.
@ -64,6 +68,13 @@ EXAMPLES = """
ip: 10.0.0.1 ip: 10.0.0.1
state: present 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 - name: Create a server network and add alias ips
hetzner.hcloud.server_network: hetzner.hcloud.server_network:
network: my-network network: my-network
@ -110,6 +121,8 @@ hcloud_server_network:
sample: [10.1.0.1, ...] sample: [10.1.0.1, ...]
""" """
from ipaddress import ip_address, ip_network
from ansible.module_utils.basic import AnsibleModule from ansible.module_utils.basic import AnsibleModule
from ..module_utils.hcloud import AnsibleHCloud from ..module_utils.hcloud import AnsibleHCloud
@ -152,11 +165,13 @@ class AnsibleHCloudServerNetwork(AnsibleHCloud):
if private_net.network.id == self.hcloud_network.id: if private_net.network.id == self.hcloud_network.id:
self.hcloud_server_network = private_net self.hcloud_server_network = private_net
def _create_server_network(self): def _attach(self):
params = { params = {
"network": self.hcloud_network, "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: if self.module.params.get("ip") is not None:
params["ip"] = self.module.params.get("ip") params["ip"] = self.module.params.get("ip")
if self.module.params.get("alias_ips") is not None: if self.module.params.get("alias_ips") is not None:
@ -170,10 +185,40 @@ class AnsibleHCloudServerNetwork(AnsibleHCloud):
self.fail_json_hcloud(exception) self.fail_json_hcloud(exception)
self._mark_as_changed() 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_and_network()
self._get_server_network() self._get_server_network()
def _update_server_network(self): 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 = { params = {
"network": self.hcloud_network, "network": self.hcloud_network,
} }
@ -189,6 +234,7 @@ class AnsibleHCloudServerNetwork(AnsibleHCloud):
self.fail_json_hcloud(exception) self.fail_json_hcloud(exception)
self._mark_as_changed() self._mark_as_changed()
self._get_server_and_network() self._get_server_and_network()
self._get_server_network() self._get_server_network()
@ -204,21 +250,16 @@ class AnsibleHCloudServerNetwork(AnsibleHCloud):
self._get_server_and_network() self._get_server_and_network()
self._get_server_network() self._get_server_network()
if self.hcloud_server_network is not None and self.hcloud_server is not None: if self.hcloud_server_network is not None and self.hcloud_server is not None:
if not self.module.check_mode: self._detach()
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.hcloud_server_network = None self.hcloud_server_network = None
@classmethod @classmethod
def define_module(cls): def define_module(cls):
return AnsibleModule( return AnsibleModule(
argument_spec=dict( argument_spec=dict(
network={"type": "str", "required": True},
server={"type": "str", "required": True}, server={"type": "str", "required": True},
network={"type": "str", "required": True},
ip_range={"type": "str"},
ip={"type": "str"}, ip={"type": "str"},
alias_ips={"type": "list", "elements": "str"}, alias_ips={"type": "list", "elements": "str"},
state={ state={

View file

@ -5,13 +5,21 @@
ip_range: 10.0.0.0/16 ip_range: 10.0.0.0/16
register: test_network register: test_network
- name: Create test_subnetwork - name: Create test_subnetwork1
hetzner.hcloud.subnetwork: hetzner.hcloud.subnetwork:
network: "{{ hcloud_network_name }}" network: "{{ hcloud_network_name }}"
network_zone: "{{ hcloud_network_zone_name }}" network_zone: "{{ hcloud_network_zone_name }}"
type: cloud type: cloud
ip_range: 10.0.1.0/24 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 - name: Create test_load_balancer
hetzner.hcloud.load_balancer: hetzner.hcloud.load_balancer:

View file

@ -85,6 +85,62 @@
that: that:
- result is changed - 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 - name: Test create with ip
hetzner.hcloud.load_balancer_network: hetzner.hcloud.load_balancer_network:
load_balancer: "{{ hcloud_load_balancer_name }}" load_balancer: "{{ hcloud_load_balancer_name }}"

View file

@ -7,13 +7,21 @@
key: value key: value
register: test_network register: test_network
- name: Create test_subnetwork - name: Create test_subnetwork1
hetzner.hcloud.subnetwork: hetzner.hcloud.subnetwork:
network: "{{ hcloud_network_name }}" network: "{{ hcloud_network_name }}"
type: server type: server
network_zone: "{{ hcloud_network_zone_name }}" network_zone: "{{ hcloud_network_zone_name }}"
ip_range: 10.0.1.0/24 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 - name: Create test_server
hetzner.hcloud.server: hetzner.hcloud.server:

View file

@ -85,6 +85,62 @@
that: that:
- result is changed - 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 - name: Test create with alias ips
hetzner.hcloud.server_network: hetzner.hcloud.server_network:
network: "{{ hcloud_network_name }}" network: "{{ hcloud_network_name }}"