diff --git a/app/Actions/BookAddon.php b/app/Actions/BookAddon.php index e9f1093..1e8bee9 100644 --- a/app/Actions/BookAddon.php +++ b/app/Actions/BookAddon.php @@ -385,11 +385,56 @@ class BookAddon }); $this->deliverStorage($cancelled->subscription, (string) $cancelled->addon_key); + $this->settleCustomDomain($cancelled->subscription, (string) $cancelled->addon_key); $this->billThroughStripe($cancelled->subscription); return $cancelled; } + /** + * Eine eigene Domain, die niemand mehr bezahlt, wirklich von der Maschine + * nehmen. + * + * Das Gegenstück zu deliverStorage(): dort wird aus einem Paket + * Plattenplatz, hier hört eine Adresse auf, eine zu sein. Solange nur die + * Zeile in `subscription_addons` gekündigt war, nannte Traefik den Namen + * weiter und Nextcloud vertraute ihm weiter — der Kunde zahlte nicht mehr + * und bekam die Leistung trotzdem, und legte er die Domain später woanders + * hin, zeigte unser Host weiter darauf. + * + * Der Paketwechsel hatte diesen Griff längst (PlanChange::settleCustomDomain + * über CustomDomainAccess::enforce()). Die Kündigung des Moduls selbst hatte + * ihn nicht — und das ist der zweite, häufigere Weg, auf dem das Recht auf + * eine eigene Domain verfällt: der Kunde bucht sie in der Abrechnung ab, + * clupilot:end-cancelled-addons hält den Termin am Ende des bezahlten + * Zeitraums, und danach fragte niemand mehr nach der Adresse. + * + * Gefragt wird hier nicht „war das die Domain?", sondern die ganze Regel: + * enforce() lässt alles stehen, solange der Vertrag die Domain nach dieser + * Kündigung immer noch trägt. Ein Kunde, der von Team auf Business + * aufgestuft hat und sein altes Modul loswird, verliert nichts, weil das + * Paket sie jetzt selbst enthält — ein blindes Abräumen an dieser Stelle + * würde ihm die Adresse nehmen, für die er weiter bezahlt. + * + * enforce() kündigt seinerseits noch laufende Buchungen desselben Moduls + * durch genau diese Aktion. Das ist nur erreichbar, wenn ein Vertrag mehr + * als eine hält (was die Buchungssperre verhindert), und jede Runde kündigt + * eine davon — es endet also, und zwar nach der ersten. + * + * Nach der Transaktion und aus demselben Grund wie die beiden Nachbarn: den + * Lauf, den enforce() anstösst, nimmt ein Arbeiter in einem anderen Prozess + * auf, und einer, der zuerst da wäre, läse die Buchung so, wie sie einen + * Moment vorher war. + */ + private function settleCustomDomain(?Subscription $subscription, string $addonKey): void + { + if ($addonKey !== CustomDomainAccess::ADDON || $subscription === null) { + return; + } + + app(CustomDomainAccess::class)->enforce($subscription->refresh()); + } + /** * Make a change to the storage packs real on the machine. * @@ -397,7 +442,9 @@ class BookAddon * BE: a pack is a hundred gigabytes the disk, the guest's filesystem and * Nextcloud's quota all have to grow to, and cancelling one is the same * sentence read backwards. Every other module is a licence, a support tier - * or a flag — nothing on the machine moves when one is booked. + * or a flag — nothing on the machine moves when one is booked. Die eigene + * Domain ist die eine Ausnahme in der Gegenrichtung: gebucht ändert sie + * nichts, gekündigt schon — siehe settleCustomDomain() unten. * * Deliberately AFTER the transaction has committed, never inside it. The run * this starts is picked up by a queue worker in another process, and a diff --git a/app/Services/Billing/CustomDomainAccess.php b/app/Services/Billing/CustomDomainAccess.php index 8ce612f..b22062d 100644 --- a/app/Services/Billing/CustomDomainAccess.php +++ b/app/Services/Billing/CustomDomainAccess.php @@ -9,7 +9,9 @@ use App\Models\Instance; use App\Models\PlanFamily; use App\Models\Subscription; use App\Support\ProvisioningSettings; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Number; +use Throwable; /** * Whether a customer may have their own domain — asked and answered once. @@ -246,10 +248,16 @@ final class CustomDomainAccess /** * Make the state match the rule, after a change has landed. * - * Called by PlanChange when a plan change takes effect. Two things happen, - * in this order: the module stops being charged for, and the domain stops - * being presented. Charging first, because a customer who has lost a - * feature and is still billed for it has a complaint we would deserve. + * Called from the two moments the right can end. PlanChange calls it when a + * plan change takes effect, and BookAddon::cancel() when the module itself + * stops — der zweite Weg, und der häufigere: der Kunde bucht die Domain in + * der Abrechnung ab, und ohne diesen Aufruf bliebe sie auf der Maschine + * stehen, obwohl niemand sie mehr bezahlt. + * + * Two things happen, in this order: the module stops being charged for, and + * the domain stops being presented. Charging first, because a customer who + * has lost a feature and is still billed for it has a complaint we would + * deserve. * * Returns whether anything was actually taken away. */ @@ -316,12 +324,43 @@ final class CustomDomainAccess ])->save(); if ($wasVerified) { - app(ReapplyInstanceAddress::class)($instance); + $this->stopServing($instance); } return true; } + /** + * Die Maschine nachziehen lassen — und daran nicht scheitern. + * + * Diese Klasse wird aus Vertrags- und Zahlungsentscheidungen heraus + * gerufen: aus einem Paketwechsel, der bereits gebucht und bezahlt ist + * (ApplyPlanChange), und aus einer Modulkündigung, deren Termin gerade + * gehalten wurde (BookAddon::cancel). Beide sind geschrieben, wenn es + * hierher kommt. Ein Host, der nicht erreichbar ist, oder eine + * Warteschlange, die gerade nicht antwortet, darf diese Entscheidung nicht + * zurückdrehen — sonst hinge eine Kündigung daran, dass eine fremde Maschine + * antwortet. Der Zustand in der Datenbank ist die Wahrheit; die Maschine + * zieht nach. + * + * Darum geschrieben und nicht geschwiegen: startet der Lauf nicht, bleibt + * die Adresse auf der Maschine stehen, und nichts sucht sie je wieder — die + * Spalte ist eine Zeile weiter oben geleert worden, also findet die + * nächtliche Prüfung (clupilot:verify-domains) sie nicht mehr. Diese + * Meldung ist die einzige Spur, die ein Betreiber davon bekommt. + */ + private function stopServing(Instance $instance): void + { + try { + app(ReapplyInstanceAddress::class)($instance); + } catch (Throwable $e) { + Log::error('Die entzogene Adresse konnte nicht von der Maschine genommen werden.', [ + 'instance' => $instance->uuid, + 'error' => $e->getMessage(), + ]); + } + } + /** * The machine this contract pays for. * diff --git a/app/Support/OpenWork.php b/app/Support/OpenWork.php index 7ecb352..6251f0d 100644 --- a/app/Support/OpenWork.php +++ b/app/Support/OpenWork.php @@ -49,13 +49,6 @@ final class OpenWork 'missing' => 'Kostet 22,80 € im Monat und verspricht mehr gleichzeitige Bearbeiter und erweiterte Office-Funktionen. Es gibt keine Collabora-Konfiguration, die vom gebuchten Modul abhängt.', 'risk' => 'Wie oben: bezahlt und nicht erbracht. Die Paketsperre für Start ist inzwischen gesetzt, das Modul selbst tut weiterhin nichts.', ], - [ - 'title' => 'Eine entzogene Domain bleibt auf der Maschine stehen', - 'state' => self::PARTIAL, - 'where' => 'App\\Services\\Billing\\CustomDomainAccess', - 'missing' => 'Das Deaktivieren setzt den Zustand in der Datenbank, startet aber keine Provisionierung. Traefik und Nextclouds trusted_domains nennen die Adresse weiter, bis ConfigureDnsAndTls und ConfigureNextcloud erneut laufen.', - 'risk' => 'Eine Adresse, die der Kunde nicht mehr bezahlt, antwortet weiter.', - ], [ 'title' => 'Die Zertifikats-Spec beschreibt einen Weg, den es nicht gibt', 'state' => self::PLANNED, diff --git a/tests/Feature/CustomDomainServingTest.php b/tests/Feature/CustomDomainServingTest.php index 3ae01af..4afaa38 100644 --- a/tests/Feature/CustomDomainServingTest.php +++ b/tests/Feature/CustomDomainServingTest.php @@ -1,11 +1,14 @@ active()->create(['datacenter' => 'fsn', 'node' => 'pve']); - $order = Order::factory()->withSubscription()->create(['datacenter' => 'fsn', 'plan' => 'business']); + $order = Order::factory()->withSubscription()->create(['datacenter' => 'fsn', 'plan' => $plan]); $instance = Instance::factory()->create(array_merge([ 'order_id' => $order->id, @@ -434,6 +437,168 @@ it('makes the domain an address as soon as the customer’s own check finds the ->and(ProvisioningRun::query()->where('pipeline', 'address')->count())->toBe(1); }); +/* +| Der zweite Weg, auf dem das Recht auf eine eigene Domain endet. +| +| Der Paketwechsel war gebaut und geprüft (siehe „stops serving the domain when +| the package stops allowing one" oben). Die Kündigung des Moduls war es nicht: +| BookAddon::cancel() setzte `cancelled_at`, damit war die Domain in jeder +| Ansicht weg — und auf der Maschine stand sie weiter. Traefik nannte den Namen, +| Nextcloud vertraute ihm, das Zertifikat blieb. Ein Kunde, der nicht mehr zahlt, +| wurde weiter bedient; und legte er die Domain später woanders hin, zeigte +| unser Host weiter darauf. +*/ + +/** Ein Team-Vertrag, der die eigene Domain als Modul gebucht hat und bereits ausgeliefert bekommt. */ +function bookedDomainModule(): array +{ + $fixture = servedInstance([ + 'custom_domain' => 'cloud.berger.at', + 'domain_token' => 'tok123', + 'domain_verified_at' => now(), + 'domain_cert_ok' => true, + 'routed_hostnames' => [platformAddress(), 'cloud.berger.at'], + ], 'berger', 'team'); + + $contract = Subscription::query()->where('order_id', $fixture['order']->id)->sole(); + $addon = app(BookAddon::class)($contract, CustomDomainAccess::ADDON); + + return [...$fixture, 'contract' => $contract, 'addon' => $addon]; +} + +it('nimmt die eigene Domain von der Maschine, sobald das gekündigte Modul endet', function () { + $s = fakeServices(); + Queue::fake(); + + $fixture = bookedDomainModule(); + + // Ausgangslage: gebucht, bewiesen, ausgeliefert. + expect(app(CustomDomainAccess::class)->allows($fixture['contract']->refresh()))->toBeTrue() + ->and($fixture['instance']->fresh()->domainIsServed())->toBeTrue(); + + // Der Kunde kündigt monatlich; der bezahlte Zeitraum läuft ab, und der + // nächtliche Lauf hält den Termin. Genau der Weg, den das Portal geht. + app(BookAddon::class)->cancelAtPeriodEnd($fixture['addon']); + SubscriptionAddon::query()->whereKey($fixture['addon']->getKey())->update(['cancels_at' => now()->subDay()]); + + $this->artisan('clupilot:end-cancelled-addons')->assertSuccessful(); + + // Die Datenbank sagt es, und ein Lauf ist unterwegs, der es der Maschine sagt. + expect($fixture['instance']->fresh()->custom_domain)->toBeNull() + ->and($fixture['contract']->addons()->active()->count())->toBe(0); + + $run = ProvisioningRun::query()->where('pipeline', 'address')->latest('id')->first(); + expect($run)->not->toBeNull(); + + app(ConfigureNextcloud::class)->execute($run); + app(ConfigureDnsAndTls::class)->execute($run); + + // Und erst hier ist die Adresse wirklich weg: aus dem Router, aus + // trusted_domains, und als beantwortete Adresse des Kunden. + expect($s['traefik']->serves('berger', 'cloud.berger.at'))->toBeFalse() + ->and($s['traefik']->hostnames['berger'])->toBe([platformAddress()]) + ->and($s['pve']->guestRan('config:system:delete trusted_domains 2'))->toBeTrue() + ->and($fixture['instance']->fresh()->domain_cert_ok)->toBeFalse() + ->and($fixture['instance']->fresh()->address(ProvisioningSettings::dnsZone()))->toBe(platformAddress()); +}); + +it('lässt die Domain stehen, wenn das Paket sie nach der Kündigung selbst trägt', function () { + Queue::fake(); + + // Team gebucht, danach auf Business aufgestuft: das Modul läuft weiter mit + // und ist dort überflüssig, weil das Paket die Domain enthält. Wer es + // kündigt, spart neun Euro — und verliert nichts. Ein Abräumen, das nur auf + // den Modulschlüssel schaut statt auf die Regel, nähme ihm hier die + // Adresse, für die er weiter bezahlt. + $fixture = servedInstance([ + 'custom_domain' => 'cloud.berger.at', + 'domain_token' => 'tok123', + 'domain_verified_at' => now(), + 'domain_cert_ok' => true, + 'routed_hostnames' => [platformAddress(), 'cloud.berger.at'], + ]); + + $contract = Subscription::query()->where('order_id', $fixture['order']->id)->sole(); + + expect(app(CustomDomainAccess::class)->includedInPlan($contract))->toBeTrue(); + + $addon = SubscriptionAddon::create([ + 'subscription_id' => $contract->id, + 'addon_key' => CustomDomainAccess::ADDON, + 'price_cents' => 900, + 'currency' => 'EUR', + 'quantity' => 1, + 'booked_at' => now(), + ]); + + app(BookAddon::class)->cancel($addon); + + expect($fixture['instance']->fresh()->custom_domain)->toBe('cloud.berger.at') + ->and($fixture['instance']->fresh()->domainIsServed())->toBeTrue() + ->and(ProvisioningRun::query()->where('pipeline', 'address')->count())->toBe(0); +}); + +it('kippt die Kündigung nicht, wenn die Maschine gerade nicht erreichbar ist', function () { + Queue::fake(); + + $fixture = bookedDomainModule(); + + // Der Tunnel steht nicht, die Warteschlange antwortet nicht — irgendetwas + // zwischen uns und dem Host. Die Kündigung ist eine Vertragsentscheidung + // und darf daran nicht hängen. + app()->bind(ReapplyInstanceAddress::class, fn () => new class extends ReapplyInstanceAddress + { + public function __invoke(?Instance $instance): ?ProvisioningRun + { + throw new RuntimeException('Host nicht erreichbar'); + } + }); + + app(BookAddon::class)->cancel($fixture['addon']); + + expect($fixture['addon']->fresh()->cancelled_at)->not->toBeNull() + // Die Wahrheit steht trotzdem geschrieben: die Adresse ist entzogen, + // die Maschine zieht nach. + ->and($fixture['instance']->fresh()->custom_domain)->toBeNull() + ->and(app(CustomDomainAccess::class)->allows($fixture['contract']->refresh()))->toBeFalse(); +}); + +it('bedient die Domain wieder, wenn der Kunde das Modul erneut bucht', function () { + // Die Gegenrichtung. Ohne sie wäre das eine Einbahnstrasse: entzogen wird + // die Adresse von der Maschine genommen, und neu gebucht käme sie nie + // zurück. + Queue::fake(); + + $fixture = bookedDomainModule(); + $user = signIntoPortal($fixture); + + app(BookAddon::class)->cancel($fixture['addon']); + + // Ohne Modul ist die Seite zu — auch für einen alten Tab. + Livewire::actingAs($user)->test(CustomDomain::class)->assertStatus(403); + + ProvisioningRun::query()->where('pipeline', 'address')->update(['status' => ProvisioningRun::STATUS_COMPLETED]); + app(BookAddon::class)($fixture['contract']->refresh(), CustomDomainAccess::ADDON); + + // Die Domain selbst ist mit dem Entzug gelöscht worden, also trägt der + // Kunde sie neu ein und weist sie neu nach — derselbe Weg wie beim ersten + // Mal, und genau dort hängt der Lauf, der sie wieder ausliefert. + bindDomainResolver(['_clupilot-challenge.cloud.berger.at' => [['txt' => 'cp-verify=neu123']]]); + + Livewire::actingAs($user)->test(CustomDomain::class) + ->set('domain', 'cloud.berger.at') + ->call('save') + ->assertHasNoErrors(); + + $fixture['instance']->fresh()->update(['domain_token' => 'neu123']); + + Livewire::actingAs($user)->test(CustomDomain::class)->call('checkNow'); + + expect($fixture['instance']->fresh()->domainIsVerified())->toBeTrue() + ->and(ProvisioningRun::query()->where('pipeline', 'address')->where('status', '!=', ProvisioningRun::STATUS_COMPLETED)->count()) + ->toBe(1); +}); + it('has nothing to re-apply for an instance with no machine', function () { Queue::fake();