CluPilotCloud/app/Services/Dns/RrsetId.php

107 lines
4.0 KiB
PHP

<?php
namespace App\Services\Dns;
use App\Support\ProvisioningSettings;
use Illuminate\Support\Str;
use RuntimeException;
/**
* How an fqdn becomes an address in the Hetzner Cloud DNS API, in one place.
*
* There are no individual records any more — an RRSet is addressed by
* `{name}/{type}`, and that pair IS its id (cloud.spec.json: "The ID is
* composed of the name and type, joined with a slash"). Everything this app
* stores in `hosts.dns_record_id`, `dns_records.record_id` and the
* `dns_record_id` run resource is such a pair.
*
* Shared by HttpHetznerDnsClient and FakeHetznerDnsClient on purpose. A fake
* that hands back `rec-1` where the real client hands back `berger/A` lets
* every test that purges a host go green over a teardown that would throw in
* production — the fake has to speak the same dialect or it proves nothing.
*/
final class RrsetId
{
/** The configured zone, lower case and without a trailing dot. */
public static function zone(): string
{
$zone = Str::lower(rtrim(trim((string) ProvisioningSettings::dnsZone()), '.'));
return $zone !== '' ? $zone : throw new RuntimeException('No DNS zone configured');
}
/** `{name}/{TYPE}` for an fqdn inside the configured zone. */
public static function for(string $fqdn, string $type): string
{
return self::relativeName($fqdn).'/'.Str::upper($type);
}
/**
* The zone-relative name Hetzner wants: lower case, no trailing dot, and
* NOT ending in the zone name.
*
* The last part is a trap with no floor under it. Measured against the live
* account on 31 July 2026: a POST carrying `berger.clupilot.cloud` as the
* name of a set in zone `clupilot.cloud` comes back 201 — the API does not
* reject it, it simply creates `berger.clupilot.cloud.clupilot.cloud`.
* Nothing downstream notices either; the run goes green and the customer's
* cloud has an address that resolves nowhere.
*
* Same reason an fqdn from outside the zone is refused rather than sent: it
* would be accepted just as quietly, under a name nobody looks for.
*/
public static function relativeName(string $fqdn): string
{
$zone = self::zone();
$name = rtrim(Str::lower(trim($fqdn)), '.');
if ($name === $zone) {
return '@';
}
if (! str_ends_with($name, '.'.$zone)) {
throw new RuntimeException("{$fqdn} does not sit in DNS zone {$zone}");
}
return substr($name, 0, -strlen('.'.$zone));
}
/** Is this the cloud API's `{name}/{type}`, or an id from the old record API? */
public static function isRrsetId(string $id): bool
{
$parts = explode('/', trim($id));
return count($parts) === 2 && $parts[0] !== '' && $parts[1] !== '';
}
/**
* `{name}/{type}` back into its two halves.
*
* An id from the old record API has no slash in it and cannot address
* anything here: passing one in would build `/rrsets/rec-123/` with an
* empty type — a call that goes somewhere else and can still come back
* looking like a success.
*
* This used to claim no such id existed anywhere, on the strength of three
* tables being empty on the DEVELOPMENT database. That proved nothing about
* any other installation, and the live server is one (Codex review
* 2026-07-31, P1). Callers therefore ask isRrsetId() first and treat a
* legacy id as a leak to report, not as a reason to die — see
* HttpHetznerDnsClient::deleteRecord() and LegacyRecordIds.
*
* @return array{0: string, 1: string}
*/
public static function split(string $id): array
{
$parts = explode('/', trim($id));
if (count($parts) !== 2 || $parts[0] === '' || $parts[1] === '') {
throw new RuntimeException(
"Not a Hetzner RRSet id: {$id} — expected {name}/{type}, e.g. berger/A"
);
}
return [$parts[0], Str::upper($parts[1])];
}
}