Die abgebuchte Domain wird jetzt wirklich von der Maschine genommen
tests / pest (push) Waiting to run Details
tests / assets (push) Waiting to run Details
tests / release (push) Blocked by required conditions Details

Der Registereintrag nannte den falschen Grund: das Deaktivieren startet sehr
wohl eine Provisionierung. CustomDomainAccess::deactivate() ruft seit Langem
ReapplyInstanceAddress, das legt einen Lauf der `address`-Pipeline an und
schickt AdvanceRunJob auf die provisioning-Warteschlange; ConfigureNextcloud
loescht dort trusted_domains 2 und ConfigureDnsAndTls schreibt den Router ohne
den Namen neu. Das ist gebaut und geprueft.

Der Schaden war trotzdem echt, nur eine Tuer weiter. Erreicht wurde deactivate()
allein ueber PlanChange::settleCustomDomain, also ueber den Paketwechsel. Der
zweite und haeufigere Weg, auf dem das Recht endet — der Kunde bucht das Modul
in der Abrechnung ab, clupilot:end-cancelled-addons haelt den Termin am Ende des
bezahlten Zeitraums — ging an dieser Stelle vorbei: BookAddon::cancel() lieferte
Speicher nach und sprach mit Stripe, fragte aber niemanden nach der Adresse. Die
Domain verschwand aus jeder Ansicht und blieb auf der Maschine stehen.

BookAddon::cancel() fragt jetzt CustomDomainAccess::enforce() — die ganze Regel,
nicht den Modulschluessel: wer von Team auf Business aufgestuft hat und sein
altes Modul loswird, behaelt die Domain, weil das Paket sie selbst traegt.

Und der Anstoss darf die Entscheidung nicht kippen. deactivate() faengt jetzt
einen Fehlschlag der Nachfuehrung ab und schreibt ihn als Fehler ins Log: die
Wahrheit steht in der Datenbank, die Maschine zieht nach, und eine Kuendigung
haengt nicht daran, ob ein fremder Host gerade antwortet.

Die Gegenrichtung brauchte nichts: der Entzug loescht die Domain-Spalte, also
traegt der Kunde sie nach der Neubuchung neu ein und weist sie neu nach — und
genau dort haengt seit jeher der Lauf, der sie wieder ausliefert. Ein Test haelt
das fest, damit es keine Einbahnstrasse wird.

Registereintrag gestrichen.

Rot gesehen: ohne den settleCustomDomain-Aufruf fallen drei der vier neuen
Tests; ohne das try/catch faellt der vierte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/versandtakt
nexxo 2026-08-04 14:50:46 +02:00
parent 40e42a88fe
commit 507636f38f
4 changed files with 259 additions and 15 deletions

View File

@ -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

View File

@ -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.
*

View File

@ -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,

View File

@ -1,11 +1,14 @@
<?php
use App\Actions\BookAddon;
use App\Actions\ReapplyInstanceAddress;
use App\Livewire\CustomDomain;
use App\Models\Host;
use App\Models\Instance;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use App\Models\User;
use App\Provisioning\RunRunner;
use App\Provisioning\Steps\Customer\ConfigureDnsAndTls;
@ -32,10 +35,10 @@ use Livewire\Livewire;
*/
/** An instance that is finished, running, and reachable at its platform address. */
function servedInstance(array $attributes = [], string $subdomain = 'berger'): array
function servedInstance(array $attributes = [], string $subdomain = 'berger', string $plan = 'business'): array
{
$host = Host::factory()->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 customers 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();