get('dns.token')) ->acceptJson() ->baseUrl('https://api.hetzner.cloud/v1') ->timeout(15); } public function upsertRecord(string $fqdn, string $type, string $value): string { // The zone name goes straight into the path now — the old client had to // look its id up first, and that lookup is simply gone. $zone = RrsetId::zone(); $name = RrsetId::relativeName($fqdn); $type = Str::upper($type); $records = [['value' => $value]]; $created = $this->http()->post("/zones/{$zone}/rrsets", [ 'name' => $name, 'type' => $type, 'ttl' => self::TTL, 'records' => $records, ]); // 409 `uniqueness_error` — measured against the live account — is the // API saying the set is already there, which on a re-run is the normal // case rather than a fault. Replacing the whole set from here is what // makes the second pass land on exactly one value: the address a // customer's instance moved to, with the one it moved off gone in the // same call. // // Create-first rather than replace-first because it terminates in at // most two calls with no race window: a 409 proves something exists, and // set_records then overwrites it whatever it was. The other order // (replace, 404, create) can lose a race and 409 on the create. if ($created->status() === 409) { $this->http() ->post("/zones/{$zone}/rrsets/{$name}/{$type}/actions/set_records", ['records' => $records]) ->throw(); } else { $created->throw(); } // Composed rather than read back out of the response, because the // replace branch above and the create branch have to hand back the same // thing, and because this is the string deleteRecord() takes apart // again. The composition is the API's own — see RrsetId. return "{$name}/{$type}"; } public function deleteRecord(string $recordId): void { // An id from the OLD record API cannot address anything here, and // throwing over it would be worse than the leak it is: PurgeHost calls // this before deleting the row that holds the id, so a host carrying a // legacy one could never be purged at all — every attempt would die on // the same line, for ever, and the machine would sit in the pool. // // Said out loud rather than swallowed. The record does stay in the zone, // and this log line is the only place that can still be noticed. // See LegacyRecordIds for the ids that CAN be brought across, and for // why I was wrong to treat one empty database as proof. if (! RrsetId::isRrsetId($recordId)) { Log::warning('Skipping a legacy DNS record id — not addressable in the cloud API, remove it by hand in the Hetzner console.', [ 'record_id' => $recordId, 'zone' => RrsetId::zone(), ]); return; } [$name, $type] = RrsetId::split($recordId); $response = $this->http()->delete('/zones/'.RrsetId::zone()."/rrsets/{$name}/{$type}"); // A set 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(); } }