CluPilotCloud/app/Actions/EndInstanceService.php

137 lines
5.9 KiB
PHP

<?php
namespace App\Actions;
use App\Models\Instance;
use App\Services\Dns\HetznerDnsClient;
use App\Services\Traefik\TraefikWriter;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
/**
* Take an instance's address down when its service has genuinely ended.
*
* `TraefikWriter::remove()` had no caller anywhere in the application. Every
* router this platform ever wrote was written for good: the file stayed on the
* serving host, the hostname kept resolving, and it kept being routed to a guest
* address that the host is free to hand to somebody else's VM later. That is not
* a tidiness problem — it is one customer's hostname pointing at another
* customer's Nextcloud, with a valid certificate on it.
*
* ## What "ended" means here
*
* Exactly one thing: the instance is `cancellation_scheduled` AND its
* `service_ends_at` has passed. Not the moment somebody cancels — a customer who
* schedules a cancellation on the second of the month has paid to the end of the
* month and must keep working through it, and pulling their address that
* afternoon would be a serious fault, worse than the leak this closes. Not a
* `failed` build either: those never got as far as a router, and the ones that
* did are being retried from the console. A `cancellation_scheduled` row without
* a `service_ends_at` at all is left alone on purpose — that is a broken record
* rather than an expired one, and guessing an end date for it would invent the
* very moment this action exists to respect.
*
* ## The DNS record goes with the route
*
* Deliberately, and for the same reason the route does. The A record for
* `{subdomain}.{zone}` is in OUR zone and points at the serving host's public
* address — a host that goes on serving other customers. Left published it is a
* name we own, resolving to a live machine, with nothing behind it that belongs
* to the person it is named after: the shape of every subdomain-takeover there
* has ever been. It also keeps the customer's old address looking alive, and it
* would collide the day the same subdomain is issued again. Removing the route
* without the record would leave the worse half standing.
*
* The CUSTOM domain is the opposite case and nothing is done to it. Its A record
* lives in the customer's own zone, which we have never had access to and never
* will; it is theirs, it stops reaching us the moment the router goes, and it is
* for them to point somewhere else.
*
* The virtual machine is NOT touched. The founder's cancellation flow promises a
* finished data export before deprovisioning, and destroying somebody's disks is
* not a decision this action gets to make on the way past. It takes the address
* away — which is what was leaking — and leaves the machine for whatever carries
* out that flow.
*/
class EndInstanceService
{
public function __construct(
private TraefikWriter $traefik,
private HetznerDnsClient $dns,
) {}
/**
* Has this instance's paid service actually run out?
*
* The rule lives here rather than in the command's query so that anything
* else that ever ends an instance asks the same question — a second copy of
* "is it over yet" is how one caller ends up a month out from the other.
*/
public function hasEnded(Instance $instance, ?Carbon $at = null): bool
{
return $instance->status === 'cancellation_scheduled'
&& $instance->service_ends_at !== null
&& $instance->service_ends_at->lessThanOrEqualTo($at ?? now());
}
/**
* Tear the address down and mark the instance ended.
*
* Re-entrant: removing a router file that is not there is `rm -f`, a record
* id that has already been deleted is deleted again for nothing, and an
* instance already marked `ended` is returned false straight away. A DNS
* failure is deliberately not swallowed — the record id survives on its row,
* so a retry can finish the job, and a provider that is permanently broken
* becomes a visible failure rather than a silent leak.
*
* @return bool whether this call was the one that ended it
*/
public function __invoke(Instance $instance): bool
{
if (! $this->hasEnded($instance)) {
return false;
}
$host = $instance->host;
if ($host !== null) {
// The host that actually serves the traffic — DNS points at it, and
// it is where ConfigureDnsAndTls wrote the file in the first place.
$this->traefik->remove($host->wg_ip ?? $host->public_ip, $instance->subdomain);
} else {
// No host left to reach: the router file went with the machine it
// lived on. Said out loud rather than passed over, because it means
// this teardown is only doing half of what it usually does.
Log::warning('Ending an instance whose host is gone — no router to remove.', [
'instance' => $instance->uuid,
]);
}
foreach ($instance->dnsRecords()->get() as $record) {
$this->dns->deleteRecord($record->record_id);
$record->delete();
}
$instance->update([
'status' => 'ended',
// The address state has to go with the address. Left as it was, a
// later run would read `route_written` plus a matching hostname list
// and decide there was nothing to write — so an instance that was
// ever revived would be announced and routed nowhere.
'route_written' => false,
'routed_hostnames' => null,
'routed_backend' => null,
'cert_ok' => false,
'domain_cert_ok' => false,
]);
Log::info('Instance service ended; address withdrawn.', [
'instance' => $instance->uuid,
'subdomain' => $instance->subdomain,
'service_ended_at' => $instance->service_ends_at?->toIso8601String(),
]);
return true;
}
}