From 322b25a1aeb5437c5f960471996f9fb77ce2e625 Mon Sep 17 00:00:00 2001 From: Jonas Lammler Date: Mon, 28 Sep 2026 15:09:32 +0200 Subject: [PATCH 1/5] feat: allow creating load balancers with primary ips --- hcloud/load_balancers/__init__.py | 2 + hcloud/load_balancers/client.py | 6 +++ hcloud/load_balancers/domain.py | 60 +++++++++++++++++++++++- tests/unit/load_balancers/test_client.py | 14 +++++- 4 files changed, 79 insertions(+), 3 deletions(-) diff --git a/hcloud/load_balancers/__init__.py b/hcloud/load_balancers/__init__.py index 9b07d026..c68b2bbc 100644 --- a/hcloud/load_balancers/__init__.py +++ b/hcloud/load_balancers/__init__.py @@ -12,6 +12,7 @@ IPv6Network, LoadBalancer, LoadBalancerAlgorithm, + LoadBalancerCreatePublicNetwork, LoadBalancerHealtCheckHttp, LoadBalancerHealthCheck, LoadBalancerHealthCheckHttp, @@ -36,6 +37,7 @@ "LoadBalancer", "LoadBalancerProtection", "LoadBalancerAlgorithm", + "LoadBalancerCreatePublicNetwork", "LoadBalancerHealtCheckHttp", "LoadBalancerHealthCheckHttp", "LoadBalancerHealthCheck", diff --git a/hcloud/load_balancers/client.py b/hcloud/load_balancers/client.py index e04b9130..8d841f7e 100644 --- a/hcloud/load_balancers/client.py +++ b/hcloud/load_balancers/client.py @@ -27,6 +27,7 @@ IPv6Network, LoadBalancer, LoadBalancerAlgorithm, + LoadBalancerCreatePublicNetwork, LoadBalancerHealthCheck, LoadBalancerHealthCheckHttp, LoadBalancerService, @@ -514,6 +515,7 @@ def create( location: Location | BoundLocation | None = None, network_zone: str | None = None, public_interface: bool | None = None, + public_net: LoadBalancerCreatePublicNetwork | None = None, network: Network | BoundNetwork | None = None, ) -> CreateLoadBalancerResponse: """Creates a Load Balancer . @@ -536,6 +538,8 @@ def create( The targets the Load Balancer is currently serving :param public_interface: bool Enable or disable the public interface of the Load Balancer + :param public_net: + Public network configuration for the Load Balancer. :param network: Network Adds the Load Balancer to a Network :return: :class:`CreateLoadBalancerResponse ` @@ -548,6 +552,8 @@ def create( data["network"] = network.id if public_interface is not None: data["public_interface"] = public_interface + if public_net is not None: + data["public_net"] = public_net.to_payload() if labels is not None: data["labels"] = labels if algorithm is not None: diff --git a/hcloud/load_balancers/domain.py b/hcloud/load_balancers/domain.py index bf329b92..905049c6 100644 --- a/hcloud/load_balancers/domain.py +++ b/hcloud/load_balancers/domain.py @@ -12,6 +12,7 @@ from ..locations import BoundLocation from ..metrics import Metrics from ..networks import BoundNetwork, Network + from ..primary_ips import BoundPrimaryIP, PrimaryIP from ..servers import BoundServer from .client import BoundLoadBalancer @@ -578,6 +579,41 @@ def __init__( self.enabled = enabled +class LoadBalancerCreatePublicNetwork(BaseDomain): + """Load Balancer Create Public Network Domain + + :param ipv4: ID of an existing Primary IP of type `ipv4` to assign to the Load Balancer. + :param ipv6: ID of an existing Primary IP of type `ipv6` to assign to the Load Balancer. + """ + + __api_properties__ = ( + "ipv4", + "ipv6", + ) + __slots__ = __api_properties__ + + def __init__( + self, + ipv4: PrimaryIP | BoundPrimaryIP | None = None, + ipv6: PrimaryIP | BoundPrimaryIP | None = None, + ): + self.ipv4 = ipv4 + self.ipv6 = ipv6 + + def to_payload(self) -> dict[str, Any]: + """ + Generates the request payload from this domain object. + """ + payload: dict[str, Any] = {} + + if self.ipv4 is not None: + payload["ipv4"] = self.ipv4.id + if self.ipv6 is not None: + payload["ipv6"] = self.ipv6.id + + return payload + + class IPv4Address(BaseDomain): """IPv4 Address Domain @@ -585,15 +621,25 @@ class IPv4Address(BaseDomain): The IPv4 Address """ - __api_properties__ = ("ip", "dns_ptr") + __api_properties__ = ( + "primary_ip", + "ip", + "blocked", + "dns_ptr", + ) __slots__ = __api_properties__ def __init__( self, ip: str, dns_ptr: str, + *, + primary_ip: PrimaryIP | BoundPrimaryIP | None = None, + blocked: bool | None = None, ): + self.primary_ip = primary_ip self.ip = ip + self.blocked = blocked self.dns_ptr = dns_ptr @@ -604,15 +650,25 @@ class IPv6Network(BaseDomain): The IPv6 Network as CIDR Notation """ - __api_properties__ = ("ip", "dns_ptr") + __api_properties__ = ( + "primary_ip", + "ip", + "blocked", + "dns_ptr", + ) __slots__ = __api_properties__ def __init__( self, ip: str, dns_ptr: str, + *, + primary_ip: PrimaryIP | BoundPrimaryIP | None = None, + blocked: bool | None = None, ): + self.primary_ip = primary_ip self.ip = ip + self.blocked = blocked self.dns_ptr = dns_ptr diff --git a/tests/unit/load_balancers/test_client.py b/tests/unit/load_balancers/test_client.py index ea5dbfa7..26100728 100644 --- a/tests/unit/load_balancers/test_client.py +++ b/tests/unit/load_balancers/test_client.py @@ -10,6 +10,7 @@ BoundLoadBalancer, LoadBalancer, LoadBalancerAlgorithm, + LoadBalancerCreatePublicNetwork, LoadBalancerHealthCheck, LoadBalancersClient, LoadBalancerService, @@ -20,6 +21,7 @@ ) from hcloud.locations import Location from hcloud.networks import Network +from hcloud.primary_ips import PrimaryIP from hcloud.servers import BoundServer, Server from ..conftest import BoundModelTestCase @@ -231,12 +233,22 @@ def test_create( "my-balancer", load_balancer_type=LoadBalancerType(name="lb11"), location=Location(id=1), + public_net=LoadBalancerCreatePublicNetwork( + ipv6=PrimaryIP(id=56367), + ), ) request_mock.assert_called_with( method="POST", url="/load_balancers", - json={"name": "my-balancer", "load_balancer_type": "lb11", "location": 1}, + json={ + "name": "my-balancer", + "load_balancer_type": "lb11", + "location": 1, + "public_net": { + "ipv6": 56367, + }, + }, ) bound_load_balancer = response.load_balancer From 9707f2589eb15ff0782b1fb1e23c67841635ed85 Mon Sep 17 00:00:00 2001 From: Jonas Lammler Date: Mon, 28 Sep 2026 19:11:50 +0200 Subject: [PATCH 2/5] fix: prepare bound models --- hcloud/load_balancers/client.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/hcloud/load_balancers/client.py b/hcloud/load_balancers/client.py index 8d841f7e..1a6df551 100644 --- a/hcloud/load_balancers/client.py +++ b/hcloud/load_balancers/client.py @@ -19,6 +19,7 @@ from ..locations import BoundLocation from ..metrics import Metrics from ..networks import BoundNetwork +from ..primary_ips import BoundPrimaryIP from ..servers import BoundServer from .domain import ( CreateLoadBalancerResponse, @@ -73,10 +74,23 @@ def __init__( public_net = data.get("public_net") if public_net: - ipv4_address = IPv4Address.from_dict(public_net["ipv4"]) - ipv6_network = IPv6Network.from_dict(public_net["ipv6"]) + if public_net_ipv4_id := public_net["ipv4"].get("id"): + public_net["ipv4"]["primary_ip"] = BoundPrimaryIP( + client._parent.primary_ips, + {"id": public_net_ipv4_id}, + complete=False, + ) + if public_net_ipv6_id := public_net["ipv6"].get("id"): + public_net["ipv6"]["primary_ip"] = BoundPrimaryIP( + client._parent.primary_ips, + {"id": public_net_ipv6_id}, + complete=False, + ) + data["public_net"] = PublicNetwork( - ipv4=ipv4_address, ipv6=ipv6_network, enabled=public_net["enabled"] + ipv4=IPv4Address.from_dict(public_net["ipv4"]), + ipv6=IPv6Network.from_dict(public_net["ipv6"]), + enabled=public_net["enabled"], ) private_nets = data.get("private_net") From 4d57ad6dc901ff6aa5fa0a1edb643719d0214fed Mon Sep 17 00:00:00 2001 From: Jonas Lammler Date: Wed, 30 Sep 2026 08:43:11 +0200 Subject: [PATCH 3/5] test: add tests for bound load balancer --- tests/unit/load_balancers/conftest.py | 18 +++- tests/unit/load_balancers/test_client.py | 107 +++++++++++++++++++++-- 2 files changed, 116 insertions(+), 9 deletions(-) diff --git a/tests/unit/load_balancers/conftest.py b/tests/unit/load_balancers/conftest.py index a3675c59..c97c56ed 100644 --- a/tests/unit/load_balancers/conftest.py +++ b/tests/unit/load_balancers/conftest.py @@ -9,8 +9,22 @@ def response_load_balancer(): "load_balancer": { "id": 4711, "name": "Web Frontend", - "ipv4": "131.232.99.1", - "ipv6": "2001:db8::1", + "public_net": { + "enabled": True, + "ipv4": { + "id": 4712, + "ip": "131.232.99.1", + "blocked": False, + "dns_ptr": "lb1.example.com", + }, + "ipv6": { + "id": 4713, + "ip": "2001:db8::1", + "blocked": False, + "dns_ptr": "lb1.example.com", + }, + }, + "private_net": [{"network": 4711, "ip": "10.0.255.1"}], "location": { "id": 1, "name": "fsn1", diff --git a/tests/unit/load_balancers/test_client.py b/tests/unit/load_balancers/test_client.py index 26100728..5c2d168e 100644 --- a/tests/unit/load_balancers/test_client.py +++ b/tests/unit/load_balancers/test_client.py @@ -1,27 +1,34 @@ from __future__ import annotations +from datetime import datetime, timezone from unittest import mock import pytest from hcloud import Client -from hcloud.load_balancer_types import LoadBalancerType +from hcloud.certificates import BoundCertificate +from hcloud.load_balancer_types import BoundLoadBalancerType, LoadBalancerType from hcloud.load_balancers import ( BoundLoadBalancer, + IPv4Address, + IPv6Network, LoadBalancer, LoadBalancerAlgorithm, LoadBalancerCreatePublicNetwork, LoadBalancerHealthCheck, + LoadBalancerHealthCheckHttp, LoadBalancersClient, LoadBalancerService, LoadBalancerServiceHttp, LoadBalancerTarget, LoadBalancerTargetIP, LoadBalancerTargetLabelSelector, + PrivateNet, + PublicNetwork, ) -from hcloud.locations import Location -from hcloud.networks import Network -from hcloud.primary_ips import PrimaryIP +from hcloud.locations import BoundLocation, Location +from hcloud.networks import BoundNetwork, Network +from hcloud.primary_ips import BoundPrimaryIP, PrimaryIP from hcloud.servers import BoundServer, Server from ..conftest import BoundModelTestCase @@ -55,13 +62,99 @@ def resource_client(self, client: Client): def bound_model(self, resource_client: LoadBalancersClient): return BoundLoadBalancer(resource_client, data=dict(id=1)) + # pylint: disable=too-many-statements def test_init(self, response_load_balancer): - bound_load_balancer = BoundLoadBalancer( + o = BoundLoadBalancer( client=mock.MagicMock(), data=response_load_balancer["load_balancer"] ) - assert bound_load_balancer.id == 4711 - assert bound_load_balancer.name == "Web Frontend" + assert o.id == 4711 + assert o.name == "Web Frontend" + assert o.created == datetime(2016, 1, 30, 23, 50, tzinfo=timezone.utc) + assert o.labels == {} + assert o.protection == {"delete": False} + assert o.outgoing_traffic == 123456 + assert o.ingoing_traffic == 123456 + assert o.included_traffic == 654321 + + assert isinstance(o.public_net, PublicNetwork) + assert o.public_net.enabled is True + assert isinstance(o.public_net.ipv4, IPv4Address) + assert isinstance(o.public_net.ipv4.primary_ip, BoundPrimaryIP) + assert o.public_net.ipv4.primary_ip.id == 4712 + assert o.public_net.ipv4.primary_ip.complete is False + assert o.public_net.ipv4.ip == "131.232.99.1" + assert o.public_net.ipv4.blocked is False + assert o.public_net.ipv4.dns_ptr == "lb1.example.com" + assert isinstance(o.public_net.ipv6, IPv6Network) + assert isinstance(o.public_net.ipv6.primary_ip, BoundPrimaryIP) + assert o.public_net.ipv6.primary_ip.id == 4713 + assert o.public_net.ipv6.primary_ip.complete is False + assert o.public_net.ipv6.ip == "2001:db8::1" + assert o.public_net.ipv6.blocked is False + assert o.public_net.ipv6.dns_ptr == "lb1.example.com" + + assert len(o.private_net) == 1 + assert isinstance(o.private_net[0], PrivateNet) + assert isinstance(o.private_net[0].network, BoundNetwork) + assert o.private_net[0].network.id == 4711 + assert o.private_net[0].network.complete is False + assert o.private_net[0].ip == "10.0.255.1" + + assert isinstance(o.location, BoundLocation) + assert o.location.id == 1 + assert o.location.name == "fsn1" + assert o.location.complete is True + + assert isinstance(o.load_balancer_type, BoundLoadBalancerType) + assert o.load_balancer_type.id == 1 + assert o.load_balancer_type.name == "lb11" + assert o.load_balancer_type.complete is True + + assert isinstance(o.algorithm, LoadBalancerAlgorithm) + assert o.algorithm.type == "round_robin" + + assert len(o.services) == 1 + service = o.services[0] + assert isinstance(service, LoadBalancerService) + assert service.protocol == "https" + assert service.listen_port == 443 + assert service.destination_port == 80 + assert service.proxyprotocol is False + assert isinstance(service.http, LoadBalancerServiceHttp) + assert service.http.cookie_name == "HCLBSTICKY" + assert service.http.cookie_lifetime == 300 + assert service.http.redirect_http is True + assert service.http.sticky_sessions is True + assert service.http.timeout_idle == 60 + assert len(service.http.certificates) == 1 + assert isinstance(service.http.certificates[0], BoundCertificate) + assert service.http.certificates[0].id == 897 + assert service.http.certificates[0].complete is False + assert isinstance(service.health_check, LoadBalancerHealthCheck) + assert service.health_check.protocol == "http" + assert service.health_check.port == 4711 + assert service.health_check.interval == 15 + assert service.health_check.timeout == 10 + assert service.health_check.retries == 3 + assert isinstance(service.health_check.http, LoadBalancerHealthCheckHttp) + assert service.health_check.http.domain == "example.com" + assert service.health_check.http.path == "/" + assert service.health_check.http.response == '{"status": "ok"}' + assert service.health_check.http.status_codes == [200] + assert service.health_check.http.tls is False + + assert len(o.targets) == 2 + target = o.targets[0] + assert isinstance(target, LoadBalancerTarget) + assert target.type == "server" + assert isinstance(target.server, BoundServer) + assert target.server.id == 80 + assert target.server.complete is False + assert target.use_private_ip is False + assert len(target.health_status) == 1 + assert target.health_status[0].listen_port == 443 + assert target.health_status[0].status == "healthy" def test_init_label_selector_nested_targets(self, response_load_balancer): bound_load_balancer = BoundLoadBalancer( From 19664b325d5e0ae986ba0aa4caff7344b886f8d8 Mon Sep 17 00:00:00 2001 From: Jonas Lammler Date: Wed, 30 Sep 2026 13:22:17 +0200 Subject: [PATCH 4/5] feat: return delete load balancer result --- hcloud/load_balancers/client.py | 21 +++++++++++---------- hcloud/load_balancers/domain.py | 15 +++++++++++++++ tests/unit/load_balancers/test_client.py | 10 +++++----- 3 files changed, 31 insertions(+), 15 deletions(-) diff --git a/hcloud/load_balancers/client.py b/hcloud/load_balancers/client.py index 1a6df551..a1da3925 100644 --- a/hcloud/load_balancers/client.py +++ b/hcloud/load_balancers/client.py @@ -23,6 +23,7 @@ from ..servers import BoundServer from .domain import ( CreateLoadBalancerResponse, + DeleteLoadBalancerResponse, GetMetricsResponse, IPv4Address, IPv6Network, @@ -228,11 +229,8 @@ def update( """ return self._client.update(self, name=name, labels=labels) - def delete(self) -> bool: - """Deletes a Load Balancer. - - :return: boolean - """ + def delete(self) -> DeleteLoadBalancerResponse: + """Deletes a Load Balancer.""" return self._client.delete(self) def get_metrics( @@ -615,17 +613,20 @@ def update( ) return BoundLoadBalancer(self, response["load_balancer"]) - def delete(self, load_balancer: LoadBalancer | BoundLoadBalancer) -> bool: + def delete( + self, load_balancer: LoadBalancer | BoundLoadBalancer + ) -> DeleteLoadBalancerResponse: """Deletes a Load Balancer. :param load_balancer: :class:`BoundLoadBalancer ` or :class:`LoadBalancer ` - :return: boolean """ - self._client.request( - url=f"{self._base_url}/{load_balancer.id}", + response = self._client.request( method="DELETE", + url=f"{self._base_url}/{load_balancer.id}", + ) + return DeleteLoadBalancerResponse( + action=BoundAction(self._parent.actions, response["action"]) ) - return True def get_metrics( self, diff --git a/hcloud/load_balancers/domain.py b/hcloud/load_balancers/domain.py index 905049c6..5a527ee0 100644 --- a/hcloud/load_balancers/domain.py +++ b/hcloud/load_balancers/domain.py @@ -714,6 +714,21 @@ def __init__( self.action = action +class DeleteLoadBalancerResponse(BaseDomain): + """ + Delete Load Balancer Response Domain. + """ + + __api_properties__ = ("action",) + __slots__ = __api_properties__ + + def __init__( + self, + action: BoundAction, + ): + self.action = action + + MetricsType = Literal[ "open_connections", "connections_per_second", diff --git a/tests/unit/load_balancers/test_client.py b/tests/unit/load_balancers/test_client.py index 5c2d168e..bf73254b 100644 --- a/tests/unit/load_balancers/test_client.py +++ b/tests/unit/load_balancers/test_client.py @@ -31,7 +31,7 @@ from hcloud.primary_ips import BoundPrimaryIP, PrimaryIP from hcloud.servers import BoundServer, Server -from ..conftest import BoundModelTestCase +from ..conftest import BoundModelTestCase, assert_bound_action1 class TestBoundLoadBalancer(BoundModelTestCase): @@ -435,18 +435,18 @@ def test_delete( request_mock: mock.MagicMock, resource_client: LoadBalancersClient, load_balancer, - action_response, + action1_running, ): - request_mock.return_value = action_response + request_mock.return_value = {"action": action1_running} - delete_success = resource_client.delete(load_balancer) + result = resource_client.delete(load_balancer) request_mock.assert_called_with( method="DELETE", url="/load_balancers/1", ) - assert delete_success is True + assert_bound_action1(result.action, resource_client._parent.actions) @pytest.mark.parametrize( "load_balancer", From 5b9309896a5ad2ac06d8e1b9ca342dd883f80552 Mon Sep 17 00:00:00 2001 From: Jonas Lammler Date: Mon, 5 Oct 2026 14:51:17 +0200 Subject: [PATCH 5/5] test: fix after rebase --- tests/unit/load_balancers/test_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/load_balancers/test_client.py b/tests/unit/load_balancers/test_client.py index bf73254b..7d7fc89b 100644 --- a/tests/unit/load_balancers/test_client.py +++ b/tests/unit/load_balancers/test_client.py @@ -152,7 +152,7 @@ def test_init(self, response_load_balancer): assert target.server.id == 80 assert target.server.complete is False assert target.use_private_ip is False - assert len(target.health_status) == 1 + assert len(target.health_status) == 2 assert target.health_status[0].listen_port == 443 assert target.health_status[0].status == "healthy"