fix(hosts): retry a failed DNS registration before advancing without a name

The failure may be a lost response to a request that did create the record.
Advancing straight away stored no id, so PurgeHost could never remove it and the
host's management address stayed published. The upsert is idempotent, so a retry
recovers the id; only after the attempts run out does the onboarding continue
without a name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-26 00:01:16 +02:00
parent 952e8e5d2f
commit 7b57ab21cb
2 changed files with 23 additions and 5 deletions

View File

@ -47,6 +47,17 @@ class RegisterHostDns extends HostStep
try {
$recordId = $this->dns->upsertRecord($fqdn, 'A', $host->wg_ip);
} catch (Throwable $e) {
// Retried first, because the failure may be a lost response to a
// request that DID create the record: advancing then leaves a
// published address with no id, so PurgeHost could never remove it.
// The upsert is idempotent, so a retry recovers the id.
$allowed = min(3, (int) $run->max_attempts);
if ($run->attempt + 1 < $allowed) {
return StepResult::retry(30, 'DNS unavailable: '.$e->getMessage());
}
// Out of attempts: a name is convenience, and a host that is
// otherwise ready should not be stranded for the want of one.
$run->events()->create([
'step' => $this->key(),
'outcome' => 'info',

View File

@ -287,15 +287,22 @@ it('numbers hosts per datacenter and never reuses a number', function () {
expect($host->fresh()->dns_name)->toBe('fsn-03');
});
it('does not strand an onboarding because DNS was unreachable', function () {
it('retries a failed DNS registration before giving up on the name', function () {
$s = fakeServices();
$s['dns']->failUpsert = true;
$host = App\Models\Host::factory()->active()->create(['datacenter' => 'fsn', 'wg_ip' => '10.66.0.40', 'dns_name' => null]);
$run = App\Models\ProvisioningRun::factory()->forHost($host)->create(["pipeline" => "host"]);
$run = App\Models\ProvisioningRun::factory()->forHost($host)->create(['pipeline' => 'host', 'attempt' => 0]);
// A name is convenience; the host works without it.
expect((new App\Provisioning\Steps\Host\RegisterHostDns($s['dns']))->execute($run)->type)->toBe("advance");
expect($run->events()->where('step', 'register_host_dns')->exists())->toBeTrue();
$step = new App\Provisioning\Steps\Host\RegisterHostDns($s['dns']);
// The failure may be a lost response to a request that did create the
// record — advancing straight away would orphan it.
expect($step->execute($run)->type)->toBe('retry');
// Out of attempts, the host still finishes: a name is convenience.
$run->update(['attempt' => 5]);
expect($step->execute($run)->type)->toBe('advance')
->and($run->events()->where('step', 'register_host_dns')->exists())->toBeTrue();
});
it('takes the host DNS record with it when the host is purged', function () {