From e4f4ea89a4116ad925cce2efd1b8821f22d8d7db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20K=C3=A4mmerling?= Date: Tue, 16 Jun 2026 14:05:37 +0200 Subject: [PATCH] Add tests and implementations for certificate, server, load balancer, and zone actions - Add `retry` method to the Certificate model with corresponding tests for retrying issuance or renewal. - Implement `addToPlacementGroup` and `removeFromPlacementGroup` methods in the Server model, including unit tests. - Add `create` method to the LoadBalancer model for creating new load balancers with related tests. - Introduce `metrics` method in the LoadBalancer model for retrieving metrics, along with tests. - Add `updateRecords` method to the Zone RRSet model to handle record updates and include tests. --- src/Models/Certificates/Certificate.php | 23 ++++++ src/Models/LoadBalancers/LoadBalancer.php | 30 +++++++ src/Models/LoadBalancers/LoadBalancers.php | 64 +++++++++++++++ src/Models/Servers/Server.php | 45 ++++++++++ src/Models/Zones/RRSet.php | 26 ++++++ .../Models/Certificates/CertificatesTest.php | 15 ++++ .../fixtures/certificate_action_retry.json | 20 +++++ .../Models/LoadBalancers/LoadBalancerTest.php | 10 +++ .../LoadBalancers/LoadBalancersTest.php | 14 ++++ .../fixtures/loadBalancer_create.json | 82 +++++++++++++++++++ .../fixtures/loadBalancer_metrics.json | 17 ++++ tests/Unit/Models/Servers/ServerTest.php | 23 ++++++ .../server_action_add_to_placement_group.json | 20 +++++ ...er_action_remove_from_placement_group.json | 20 +++++ tests/Unit/Models/Zones/RRSetTest.php | 13 +++ .../zone_rrset_action_update_records.json | 20 +++++ 16 files changed, 442 insertions(+) create mode 100644 tests/Unit/Models/Certificates/fixtures/certificate_action_retry.json create mode 100644 tests/Unit/Models/LoadBalancers/fixtures/loadBalancer_create.json create mode 100644 tests/Unit/Models/LoadBalancers/fixtures/loadBalancer_metrics.json create mode 100644 tests/Unit/Models/Servers/fixtures/server_action_add_to_placement_group.json create mode 100644 tests/Unit/Models/Servers/fixtures/server_action_remove_from_placement_group.json create mode 100644 tests/Unit/Models/Zones/fixtures/zone_rrset_action_update_records.json diff --git a/src/Models/Certificates/Certificate.php b/src/Models/Certificates/Certificate.php index 403d6689..f6267e84 100644 --- a/src/Models/Certificates/Certificate.php +++ b/src/Models/Certificates/Certificate.php @@ -9,7 +9,9 @@ namespace LKDev\HetznerCloud\Models\Certificates; +use LKDev\HetznerCloud\APIResponse; use LKDev\HetznerCloud\HetznerAPIClient; +use LKDev\HetznerCloud\Models\Actions\Action; use LKDev\HetznerCloud\Models\Contracts\Resource; use LKDev\HetznerCloud\Models\Model; @@ -145,6 +147,27 @@ public static function parse($input) return new self($input->id, $input->name, $input->certificate, $input->created, $input->not_valid_before, $input->not_valid_after, $input->domain_names, $input->fingerprint, $input->used_by, $input->labels, $input->type ?? null); } + /** + * Retry a failed Certificate issuance or renewal (only for managed certificates). + * + * @see https://docs.hetzner.cloud/#certificate-actions-retry-issuance-or-renewal + * + * @return APIResponse|null + * + * @throws \LKDev\HetznerCloud\APIException + */ + public function retry(): ?APIResponse + { + $response = $this->httpClient->post('certificates/'.$this->id.'/actions/retry', []); + if (! HetznerAPIClient::hasError($response)) { + return APIResponse::create([ + 'action' => Action::parse(json_decode((string) $response->getBody())->action), + ], $response->getHeaders()); + } + + return null; + } + /** * Reload the data of the SSH Key. * diff --git a/src/Models/LoadBalancers/LoadBalancer.php b/src/Models/LoadBalancers/LoadBalancer.php index 0a2a24fc..2b97a434 100644 --- a/src/Models/LoadBalancers/LoadBalancer.php +++ b/src/Models/LoadBalancers/LoadBalancer.php @@ -553,6 +553,36 @@ public function removeTarget(string $type, ?LoadBalancerTargetIp $ip = null, ?ar return null; } + /** + * Get Metrics for a Load Balancer. + * + * @see https://docs.hetzner.cloud/#load-balancers-get-metrics-for-a-load-balancer + * + * @param string $type Comma-separated list of metric types (open_connections, connections_per_second, requests_per_second, bandwidth) + * @param string $start Start of period (ISO 8601 date-time) + * @param string $end End of period (ISO 8601 date-time) + * @param int|null $step Resolution of results in seconds + * @return APIResponse|null + * + * @throws APIException + * @throws GuzzleException + */ + public function metrics(string $type, string $start, string $end, ?int $step = null): ?APIResponse + { + $params = compact('type', 'start', 'end'); + if ($step !== null) { + $params['step'] = $step; + } + $response = $this->httpClient->get($this->replaceServerIdInUri('load_balancers/{id}/metrics?').http_build_query($params)); + if (! HetznerAPIClient::hasError($response)) { + return APIResponse::create([ + 'metrics' => json_decode((string) $response->getBody())->metrics, + ], $response->getHeaders()); + } + + return null; + } + /** * Updates a Load Balancer Service. * diff --git a/src/Models/LoadBalancers/LoadBalancers.php b/src/Models/LoadBalancers/LoadBalancers.php index afffad21..da2d7f2b 100644 --- a/src/Models/LoadBalancers/LoadBalancers.php +++ b/src/Models/LoadBalancers/LoadBalancers.php @@ -4,6 +4,7 @@ use LKDev\HetznerCloud\APIResponse; use LKDev\HetznerCloud\HetznerAPIClient; +use LKDev\HetznerCloud\Models\Actions\Action; use LKDev\HetznerCloud\Models\Contracts\Resources; use LKDev\HetznerCloud\Models\Meta; use LKDev\HetznerCloud\Models\Model; @@ -66,6 +67,69 @@ public function list(?RequestOpts $requestOpts = null): ?APIResponse return null; } + /** + * Creates a Load Balancer. + * + * @see https://docs.hetzner.cloud/#load-balancers-create-a-load-balancer + * + * @param string $name + * @param string $loadBalancerType ID or name of the Load Balancer type + * @param string|null $location ID or name of location (mutually exclusive with $networkZone) + * @param string|null $networkZone Name of network zone (mutually exclusive with $location) + * @param array|null $algorithm + * @param array $labels + * @param int|null $network + * @param bool $publicInterface + * @param array $services + * @param array $targets + * @return APIResponse|null + * + * @throws \LKDev\HetznerCloud\APIException + */ + public function create(string $name, string $loadBalancerType, ?string $location = null, ?string $networkZone = null, ?array $algorithm = null, array $labels = [], ?int $network = null, bool $publicInterface = true, array $services = [], array $targets = []): ?APIResponse + { + $payload = [ + 'name' => $name, + 'load_balancer_type' => $loadBalancerType, + ]; + if ($location !== null) { + $payload['location'] = $location; + } + if ($networkZone !== null) { + $payload['network_zone'] = $networkZone; + } + if ($algorithm !== null) { + $payload['algorithm'] = $algorithm; + } + if (! empty($labels)) { + $payload['labels'] = $labels; + } + if ($network !== null) { + $payload['network'] = $network; + } + if (! $publicInterface) { + $payload['public_interface'] = false; + } + if (! empty($services)) { + $payload['services'] = $services; + } + if (! empty($targets)) { + $payload['targets'] = $targets; + } + + $response = $this->httpClient->post('load_balancers', ['json' => $payload]); + if (! HetznerAPIClient::hasError($response)) { + $body = json_decode((string) $response->getBody()); + + return APIResponse::create([ + 'load_balancer' => LoadBalancer::parse($body->load_balancer), + 'action' => Action::parse($body->action), + ], $response->getHeaders()); + } + + return null; + } + /** * Gets a specific Load Balancer object. * diff --git a/src/Models/Servers/Server.php b/src/Models/Servers/Server.php index d790f104..d5853d79 100644 --- a/src/Models/Servers/Server.php +++ b/src/Models/Servers/Server.php @@ -868,6 +868,51 @@ public function changeAliasIPs(Network $network, array $aliasIps) return null; } + /** + * Adds a Server to a Placement Group. + * + * @see https://docs.hetzner.cloud/#server-actions-add-a-server-to-a-placement-group + * + * @param int $placementGroupId + * @return APIResponse|null + * + * @throws \LKDev\HetznerCloud\APIException + */ + public function addToPlacementGroup(int $placementGroupId): ?APIResponse + { + $response = $this->httpClient->post($this->replaceServerIdInUri('servers/{id}/actions/add_to_placement_group'), [ + 'json' => ['placement_group' => $placementGroupId], + ]); + if (! HetznerAPIClient::hasError($response)) { + return APIResponse::create([ + 'action' => Action::parse(json_decode((string) $response->getBody())->action), + ], $response->getHeaders()); + } + + return null; + } + + /** + * Removes a Server from a Placement Group. + * + * @see https://docs.hetzner.cloud/#server-actions-remove-a-server-from-a-placement-group + * + * @return APIResponse|null + * + * @throws \LKDev\HetznerCloud\APIException + */ + public function removeFromPlacementGroup(): ?APIResponse + { + $response = $this->httpClient->post($this->replaceServerIdInUri('servers/{id}/actions/remove_from_placement_group'), []); + if (! HetznerAPIClient::hasError($response)) { + return APIResponse::create([ + 'action' => Action::parse(json_decode((string) $response->getBody())->action), + ], $response->getHeaders()); + } + + return null; + } + /** * @param string $uri * @return string diff --git a/src/Models/Zones/RRSet.php b/src/Models/Zones/RRSet.php index 8700be80..5397e00c 100644 --- a/src/Models/Zones/RRSet.php +++ b/src/Models/Zones/RRSet.php @@ -255,4 +255,30 @@ public function removeRecords(array $records) return null; } + + /** + * Update specific records in this RRSet. + * + * @see https://docs.hetzner.cloud/#zone-rrset-actions-update-records-in-a-rrset + * + * @param array $records + * @return APIResponse|null + * + * @throws \LKDev\HetznerCloud\APIException + */ + public function updateRecords(array $records): ?APIResponse + { + $response = $this->httpClient->post('zones/'.$this->zone.'/rrsets/'.$this->id.'/actions/update_records', [ + 'json' => [ + 'records' => $records, + ], + ]); + if (! HetznerAPIClient::hasError($response)) { + return APIResponse::create([ + 'action' => Action::parse(json_decode((string) $response->getBody())->action), + ], $response->getHeaders()); + } + + return null; + } } diff --git a/tests/Unit/Models/Certificates/CertificatesTest.php b/tests/Unit/Models/Certificates/CertificatesTest.php index 42425017..ec4427fc 100644 --- a/tests/Unit/Models/Certificates/CertificatesTest.php +++ b/tests/Unit/Models/Certificates/CertificatesTest.php @@ -94,4 +94,19 @@ public function testDelete() $this->assertTrue($certificate->delete()); $this->assertLastRequestEquals('DELETE', '/certificates/897'); } + + public function testRetry() + { + $this->mockHandler->append(new Response(200, [], file_get_contents(__DIR__.'/fixtures/certificate.json'))); + $certificate = $this->certificates->get(897); + + $this->mockHandler->append(new Response(201, [], file_get_contents(__DIR__.'/fixtures/certificate_action_retry.json'))); + $apiResponse = $certificate->retry(); + + $this->assertEquals('retry_issuance_or_renewal', $apiResponse->action->command); + $this->assertEquals(897, $apiResponse->action->resources[0]->id); + $this->assertEquals('certificate', $apiResponse->action->resources[0]->type); + + $this->assertLastRequestEquals('POST', '/certificates/897/actions/retry'); + } } diff --git a/tests/Unit/Models/Certificates/fixtures/certificate_action_retry.json b/tests/Unit/Models/Certificates/fixtures/certificate_action_retry.json new file mode 100644 index 00000000..07a8b8ad --- /dev/null +++ b/tests/Unit/Models/Certificates/fixtures/certificate_action_retry.json @@ -0,0 +1,20 @@ +{ + "action": { + "command": "retry_issuance_or_renewal", + "error": { + "code": "action_failed", + "message": "Action failed" + }, + "finished": "2016-01-30T23:56:00+00:00", + "id": 15, + "progress": 100, + "resources": [ + { + "id": 897, + "type": "certificate" + } + ], + "started": "2016-01-30T23:55:00+00:00", + "status": "success" + } +} diff --git a/tests/Unit/Models/LoadBalancers/LoadBalancerTest.php b/tests/Unit/Models/LoadBalancers/LoadBalancerTest.php index 58c9e60e..59dc3f52 100644 --- a/tests/Unit/Models/LoadBalancers/LoadBalancerTest.php +++ b/tests/Unit/Models/LoadBalancers/LoadBalancerTest.php @@ -340,4 +340,14 @@ public function testUpdateService() 'protocol' => 'https', ]); } + + public function testMetrics() + { + $this->mockHandler->append(new Response(200, [], file_get_contents(__DIR__.'/fixtures/loadBalancer_metrics.json'))); + $apiResponse = $this->load_balancer->metrics('open_connections', '2017-01-01T00:00:00+00:00', '2017-01-01T23:00:00+00:00', 60); + $metrics = $apiResponse->getResponsePart('metrics'); + + $this->assertEquals([[1435781470.622, '42']], $metrics->time_series->open_connections->values); + $this->assertLastRequestEquals('GET', '/load_balancers/4711/metrics'); + } } diff --git a/tests/Unit/Models/LoadBalancers/LoadBalancersTest.php b/tests/Unit/Models/LoadBalancers/LoadBalancersTest.php index d08c174d..30d6c5ef 100644 --- a/tests/Unit/Models/LoadBalancers/LoadBalancersTest.php +++ b/tests/Unit/Models/LoadBalancers/LoadBalancersTest.php @@ -63,4 +63,18 @@ public function testList() $this->assertEquals($loadBalancers[0]->name, 'my-resource'); $this->assertLastRequestEquals('GET', '/load_balancers'); } + + public function testCreate() + { + $this->mockHandler->append(new Response(201, [], file_get_contents(__DIR__.'/fixtures/loadBalancer_create.json'))); + $response = $this->loadBalancers->create('my-load-balancer', 'lb11', 'fsn1'); + + $this->assertNotNull($response); + $this->assertEquals(4711, $response->load_balancer->id); + $this->assertEquals('my-load-balancer', $response->load_balancer->name); + $this->assertEquals('create_load_balancer', $response->action->command); + + $this->assertLastRequestEquals('POST', '/load_balancers'); + $this->assertLastRequestBodyParametersEqual(['name' => 'my-load-balancer', 'load_balancer_type' => 'lb11', 'location' => 'fsn1']); + } } diff --git a/tests/Unit/Models/LoadBalancers/fixtures/loadBalancer_create.json b/tests/Unit/Models/LoadBalancers/fixtures/loadBalancer_create.json new file mode 100644 index 00000000..73ab7131 --- /dev/null +++ b/tests/Unit/Models/LoadBalancers/fixtures/loadBalancer_create.json @@ -0,0 +1,82 @@ +{ + "load_balancer": { + "algorithm": { + "type": "round_robin" + }, + "created": "2016-01-30T23:55:00+00:00", + "id": 4711, + "included_traffic": 10000, + "ingoing_traffic": null, + "labels": {}, + "load_balancer_type": { + "deprecated": "2016-01-30T23:50:00+00:00", + "description": "LB11", + "id": 1, + "max_assigned_certificates": 10, + "max_connections": 20000, + "max_services": 5, + "max_targets": 25, + "name": "lb11", + "prices": [ + { + "location": "fsn1", + "price_hourly": { + "gross": "1.1900000000000000", + "net": "1.0000000000" + }, + "price_monthly": { + "gross": "1.1900000000000000", + "net": "1.0000000000" + } + } + ] + }, + "location": { + "city": "Falkenstein", + "country": "DE", + "description": "Falkenstein DC Park 1", + "id": 1, + "latitude": 50.47612, + "longitude": 12.370071, + "name": "fsn1", + "network_zone": "eu-central" + }, + "name": "my-load-balancer", + "outgoing_traffic": null, + "private_net": [], + "protection": { + "delete": false + }, + "public_net": { + "enabled": true, + "ipv4": { + "dns_ptr": "lb1.example.com", + "ip": "1.2.3.4" + }, + "ipv6": { + "dns_ptr": "lb1.example.com", + "ip": "2001:db8::1" + } + }, + "services": [], + "targets": [] + }, + "action": { + "command": "create_load_balancer", + "error": { + "code": "action_failed", + "message": "Action failed" + }, + "finished": "2016-01-30T23:56:00+00:00", + "id": 13, + "progress": 100, + "resources": [ + { + "id": 4711, + "type": "load_balancer" + } + ], + "started": "2016-01-30T23:55:00+00:00", + "status": "success" + } +} diff --git a/tests/Unit/Models/LoadBalancers/fixtures/loadBalancer_metrics.json b/tests/Unit/Models/LoadBalancers/fixtures/loadBalancer_metrics.json new file mode 100644 index 00000000..bfd2fd80 --- /dev/null +++ b/tests/Unit/Models/LoadBalancers/fixtures/loadBalancer_metrics.json @@ -0,0 +1,17 @@ +{ + "metrics": { + "start": "2017-01-01T00:00:00+00:00", + "end": "2017-01-01T23:00:00+00:00", + "step": 60, + "time_series": { + "open_connections": { + "values": [ + [ + 1435781470.622, + "42" + ] + ] + } + } + } +} diff --git a/tests/Unit/Models/Servers/ServerTest.php b/tests/Unit/Models/Servers/ServerTest.php index 31b1353e..e71291b8 100644 --- a/tests/Unit/Models/Servers/ServerTest.php +++ b/tests/Unit/Models/Servers/ServerTest.php @@ -321,6 +321,29 @@ public function testChangeAliasIPs() $this->assertLastRequestBodyParametersEqual(['network' => 4711, 'alias_ips' => ['10.0.1.2']]); } + public function testAddToPlacementGroup() + { + $this->mockHandler->append(new Response(200, [], file_get_contents(__DIR__.'/fixtures/server_action_add_to_placement_group.json'))); + $apiResponse = $this->server->addToPlacementGroup(1); + $this->assertEquals('add_to_placement_group', $apiResponse->action->command); + $this->assertEquals($this->server->id, $apiResponse->action->resources[0]->id); + $this->assertEquals('server', $apiResponse->action->resources[0]->type); + + $this->assertLastRequestEquals('POST', '/servers/42/actions/add_to_placement_group'); + $this->assertLastRequestBodyParametersEqual(['placement_group' => 1]); + } + + public function testRemoveFromPlacementGroup() + { + $this->mockHandler->append(new Response(200, [], file_get_contents(__DIR__.'/fixtures/server_action_remove_from_placement_group.json'))); + $apiResponse = $this->server->removeFromPlacementGroup(); + $this->assertEquals('remove_from_placement_group', $apiResponse->action->command); + $this->assertEquals($this->server->id, $apiResponse->action->resources[0]->id); + $this->assertEquals('server', $apiResponse->action->resources[0]->type); + + $this->assertLastRequestEquals('POST', '/servers/42/actions/remove_from_placement_group'); + } + protected function getGenericActionResponse(string $command) { return str_replace('$command', $command, file_get_contents(__DIR__.'/fixtures/server_action_generic.json')); diff --git a/tests/Unit/Models/Servers/fixtures/server_action_add_to_placement_group.json b/tests/Unit/Models/Servers/fixtures/server_action_add_to_placement_group.json new file mode 100644 index 00000000..e123318c --- /dev/null +++ b/tests/Unit/Models/Servers/fixtures/server_action_add_to_placement_group.json @@ -0,0 +1,20 @@ +{ + "action": { + "command": "add_to_placement_group", + "error": { + "code": "action_failed", + "message": "Action failed" + }, + "finished": "2016-01-30T23:56:00+00:00", + "id": 13, + "progress": 100, + "resources": [ + { + "id": 42, + "type": "server" + } + ], + "started": "2016-01-30T23:55:00+00:00", + "status": "success" + } +} diff --git a/tests/Unit/Models/Servers/fixtures/server_action_remove_from_placement_group.json b/tests/Unit/Models/Servers/fixtures/server_action_remove_from_placement_group.json new file mode 100644 index 00000000..c1c9f0fc --- /dev/null +++ b/tests/Unit/Models/Servers/fixtures/server_action_remove_from_placement_group.json @@ -0,0 +1,20 @@ +{ + "action": { + "command": "remove_from_placement_group", + "error": { + "code": "action_failed", + "message": "Action failed" + }, + "finished": "2016-01-30T23:56:00+00:00", + "id": 14, + "progress": 100, + "resources": [ + { + "id": 42, + "type": "server" + } + ], + "started": "2016-01-30T23:55:00+00:00", + "status": "success" + } +} diff --git a/tests/Unit/Models/Zones/RRSetTest.php b/tests/Unit/Models/Zones/RRSetTest.php index 5e9f40e7..b9580f19 100644 --- a/tests/Unit/Models/Zones/RRSetTest.php +++ b/tests/Unit/Models/Zones/RRSetTest.php @@ -117,6 +117,19 @@ public function testRemoveRecords() ]]); } + public function testUpdateRecords() + { + $this->mockHandler->append(new Response(200, [], file_get_contents(__DIR__.'/fixtures/zone_rrset_action_update_records.json'))); + $apiResponse = $this->rrset->updateRecords([ + ['value' => '198.51.100.1', 'comment' => 'My web server at Hetzner Cloud.'], + ]); + $this->assertEquals('update_records', $apiResponse->action->command); + $this->assertLastRequestEquals('POST', '/zones/4711/rrsets/www/A/actions/update_records'); + $this->assertLastRequestBodyParametersEqual(['records' => [ + ['value' => '198.51.100.1', 'comment' => 'My web server at Hetzner Cloud.'], + ]]); + } + protected function getGenericActionResponse(string $command) { return str_replace('$command', $command, file_get_contents(__DIR__.'/fixtures/zone_action_generic.json')); diff --git a/tests/Unit/Models/Zones/fixtures/zone_rrset_action_update_records.json b/tests/Unit/Models/Zones/fixtures/zone_rrset_action_update_records.json new file mode 100644 index 00000000..1bef9271 --- /dev/null +++ b/tests/Unit/Models/Zones/fixtures/zone_rrset_action_update_records.json @@ -0,0 +1,20 @@ +{ + "action": { + "command": "update_records", + "error": { + "code": "action_failed", + "message": "Action failed" + }, + "finished": "2016-01-30T23:56:00+00:00", + "id": 16, + "progress": 100, + "resources": [ + { + "id": "www/A", + "type": "rrset" + } + ], + "started": "2016-01-30T23:55:00+00:00", + "status": "success" + } +}