(string) app(SecretVault::class)->get('dns.token')]) ->baseUrl('https://dns.hetzner.com/api/v1') ->timeout(15); } private function zoneId(): string { $zone = ProvisioningSettings::dnsZone(); $zones = $this->http()->get('/zones', ['name' => $zone])->throw()->json('zones', []); return $zones[0]['id'] ?? throw new RuntimeException("DNS zone {$zone} not found"); } public function upsertRecord(string $fqdn, string $type, string $value): string { $zoneId = $this->zoneId(); $name = rtrim(str_replace('.'.ProvisioningSettings::dnsZone(), '', $fqdn), '.'); $existing = $this->findRecord($zoneId, $name, $type); if ($existing) { $this->http()->put("/records/{$existing['id']}", [ 'zone_id' => $zoneId, 'type' => $type, 'name' => $name, 'value' => $value, 'ttl' => 300, ])->throw(); return (string) $existing['id']; } return (string) $this->http()->post('/records', [ 'zone_id' => $zoneId, 'type' => $type, 'name' => $name, 'value' => $value, 'ttl' => 300, ])->throw()->json('record.id'); } /** * The record with this name and type in the zone, or null. * * Paged, because Hetzner pages. `GET /records` answers with at most 100 * entries and this asked only for the first page — so once the zone held more * than a hundred records the lookup stopped finding entries that were plainly * there, fell through to POST, and Hetzner accepted a SECOND A record for the * same name. Two addresses for one instance, resolved round-robin, one of them * a host the customer's machine is not on: the cloud was up about half the * time, and every subsequent run made it worse, because the `address` and * `plan-change` pipelines upsert on every pass. * * A hundred is not a distant number here. The zone carries one A record per * customer instance plus one per host, and nothing prunes it faster than * instances are sold. * * @return array|null */ private function findRecord(string $zoneId, string $name, string $type): ?array { $page = 1; do { $response = $this->http() ->get('/records', ['zone_id' => $zoneId, 'page' => $page, 'per_page' => self::PAGE_SIZE]) ->throw(); foreach ($response->json('records', []) as $record) { if (($record['name'] ?? null) === $name && ($record['type'] ?? null) === $type) { return $record; } } // Absent or nonsensical pagination means "this was the lot": a client // that trusted the field to be there could loop for ever against an // API having a bad day, and MAX_PAGES is the floor under that even // when it answers. $lastPage = (int) $response->json('meta.pagination.last_page', $page); } while ($page++ < min($lastPage, self::MAX_PAGES)); return null; } public function deleteRecord(string $recordId): void { $response = $this->http()->delete("/records/{$recordId}"); // A record that is already gone is the outcome we wanted. Without this, // a lost response or a retry after a later failure turns every further // attempt into a 404 — and a host that can then never be purged. if ($response->notFound()) { return; } $response->throw(); } }