CluPilotCloud/tests/Feature/CustomDomainServingTest.php

373 lines
16 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?php
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\User;
use App\Provisioning\Steps\Customer\ConfigureDnsAndTls;
use App\Provisioning\Steps\Customer\ConfigureNextcloud;
use App\Services\Billing\CustomDomainAccess;
use App\Services\Domains\DomainVerifier;
use App\Support\ProvisioningSettings;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
/**
* Serving the domain, as opposed to announcing it.
*
* A verified custom domain was reported as the customer's address by
* Instance::address(), by the portal and by the credentials mail, while nothing
* on the platform ever routed it: the Traefik router's rule was hard-coded to
* `{subdomain}.{zone}`, so the address the customer had just been given
* answered nothing at all. The other direction was worse — a withdrawn domain
* stayed in the router and in Nextcloud's trusted_domains forever, because the
* only thing that ever wrote either was the initial provisioning run.
*
* These tests are about the two halves of that: an address that has to become
* real, and one that has to stop being real.
*/
/** An instance that is finished, running, and reachable at its platform address. */
function servedInstance(array $attributes = [], string $subdomain = 'berger'): array
{
$host = Host::factory()->active()->create(['datacenter' => 'fsn', 'node' => 'pve']);
$order = Order::factory()->withSubscription()->create(['datacenter' => 'fsn', 'plan' => 'business']);
$instance = Instance::factory()->create(array_merge([
'order_id' => $order->id,
'customer_id' => $order->customer_id,
'host_id' => $host->id,
'vmid' => 101,
'guest_ip' => '10.20.0.7',
'subdomain' => $subdomain,
'status' => 'active',
// The state a re-apply actually finds: built, routed, certified.
'route_written' => true,
'routed_hostnames' => [platformAddress($subdomain)],
'cert_ok' => true,
], $attributes));
return compact('host', 'order', 'instance');
}
/** A run of the `address` pipeline against that instance's order. */
function addressRun(array $fixture): ProvisioningRun
{
return ProvisioningRun::factory()->create([
'subject_type' => Order::class,
'subject_id' => $fixture['order']->id,
'pipeline' => 'address',
'context' => [
'instance_id' => $fixture['instance']->id,
'host_id' => $fixture['host']->id,
'node' => 'pve',
'vmid' => 101,
'subdomain' => $fixture['instance']->subdomain,
],
]);
}
/** No real DNS: a suite that needs a nameserver fails on a train. */
function bindDomainResolver(array $records): void
{
app()->bind(DomainVerifier::class, fn () => new DomainVerifier(
fn (string $name) => $records[$name] ?? [],
));
}
function platformAddress(string $subdomain = 'berger'): string
{
return $subdomain.'.'.ProvisioningSettings::dnsZone();
}
/** The portal account that owns the fixture's instance. */
function signIntoPortal(array $fixture): User
{
$user = User::factory()->create(['email_verified_at' => now()]);
// customers.user_id, not users.customer_id — the link runs the other way.
$fixture['order']->customer->update(['user_id' => $user->id, 'email' => $user->email]);
return $user;
}
it('puts a verified custom domain in the router beside the platform address, and an unverified one nowhere', function () {
$s = fakeServices();
// Unverified: the hostname is in the column, so everything that reads the
// column would announce it — but it is a request, not an address.
$unproven = servedInstance(['custom_domain' => 'cloud.fremde-firma.at', 'domain_token' => 'tok'], 'fremde');
expect(app(ConfigureDnsAndTls::class)->execute(addressRun($unproven))->type)->toBe('advance')
->and($s['traefik']->writes)->toBe(0) // nothing changed, so nothing was written
->and($unproven['instance']->fresh()->routed_hostnames)->toBe([platformAddress('fremde')]);
// Proven: one router, both names, the platform address first — it is what
// the instance falls back to the moment the domain goes away.
$proven = servedInstance([
'custom_domain' => 'cloud.berger.at',
'domain_token' => 'tok',
'domain_verified_at' => now(),
]);
expect(app(ConfigureDnsAndTls::class)->execute(addressRun($proven))->type)->toBe('advance')
->and($s['traefik']->hostnames['berger'])->toBe([platformAddress(), 'cloud.berger.at'])
->and($proven['instance']->fresh()->routed_hostnames)->toBe([platformAddress(), 'cloud.berger.at'])
->and($proven['instance']->fresh()->domain_cert_ok)->toBeTrue();
});
it('writes one router file for both names rather than a second one', function () {
// Two routers for one backend are two things to keep in step, and the
// second is the one nobody remembers on the day the domain is withdrawn.
$s = fakeServices();
$fixture = servedInstance([
'custom_domain' => 'cloud.berger.at', 'domain_token' => 'tok', 'domain_verified_at' => now(),
]);
app(ConfigureDnsAndTls::class)->execute(addressRun($fixture));
expect($s['traefik']->writes)->toBe(1)
->and(array_keys($s['traefik']->hostnames))->toBe(['berger']);
});
it('stops serving a withdrawn domain and takes it out of trusted_domains', function () {
$s = fakeServices();
// Verified yesterday, served ever since, and withdrawn this morning: the
// proof is gone but the router and Nextcloud have never been told.
$fixture = servedInstance([
'custom_domain' => 'cloud.berger.at',
'domain_token' => 'tok',
'domain_verified_at' => null,
'domain_cert_ok' => true,
'routed_hostnames' => [platformAddress(), 'cloud.berger.at'],
]);
$run = addressRun($fixture);
expect(app(ConfigureDnsAndTls::class)->execute($run)->type)->toBe('advance')
->and(app(ConfigureNextcloud::class)->execute($run)->type)->toBe('advance');
expect($s['traefik']->hostnames['berger'])->toBe([platformAddress()])
->and($s['traefik']->serves('berger', 'cloud.berger.at'))->toBeFalse()
->and($fixture['instance']->fresh()->routed_hostnames)->toBe([platformAddress()])
->and($fixture['instance']->fresh()->domain_cert_ok)->toBeFalse()
// Deleted, not blanked: an entry left in trusted_domains means Nextcloud
// still answers to that name whenever anything reaches it.
->and($s['pve']->guestRan('config:system:delete trusted_domains 2'))->toBeTrue()
->and($s['pve']->guestRan('config:system:set trusted_domains 2'))->toBeFalse();
});
it('trusts a verified domain and leaves the delete alone', function () {
$s = fakeServices();
$fixture = servedInstance([
'custom_domain' => 'cloud.berger.at', 'domain_token' => 'tok', 'domain_verified_at' => now(),
]);
app(ConfigureNextcloud::class)->execute(addressRun($fixture));
expect($s['pve']->guestRan("config:system:set trusted_domains 2 --value='cloud.berger.at'"))->toBeTrue()
->and($s['pve']->guestRan('config:system:delete trusted_domains 2'))->toBeFalse();
});
it('never fails a run over the custom domains certificate, but still fails over the platforms', function () {
$s = fakeServices();
// The customer has proven the domain is theirs and has NOT pointed it at
// us — which is an A record in their own zone, may take days, and may never
// happen at all. Their cloud still has to be delivered.
$s['traefik']->certUnreachable = ['cloud.berger.at'];
$fixture = servedInstance([
'custom_domain' => 'cloud.berger.at',
'domain_token' => 'tok',
'domain_verified_at' => now(),
'cert_ok' => false,
]);
$run = addressRun($fixture);
$run->update(['started_at' => now()->subSeconds(300)]); // past the short look
expect(app(ConfigureDnsAndTls::class)->execute($run)->type)->toBe('advance');
$instance = $fixture['instance']->fresh();
expect($instance->cert_ok)->toBeTrue() // ours works
->and($instance->domain_verified_at)->not->toBeNull()
->and($instance->domain_cert_ok)->toBeFalse() // theirs does not, and is recorded as such
->and($instance->domainIsVerified())->toBeTrue()
->and($instance->domainIsServed())->toBeFalse()
// Visible to an operator reading the run, not swallowed.
->and($run->events()->where('outcome', 'info')->where('step', 'configure_dns_and_tls')->exists())->toBeTrue();
// The platform address is ours: no certificate there is a broken run.
$s2 = fakeServices();
$s2['traefik']->certReady = false;
$second = servedInstance(['cert_ok' => false], 'zweite');
$failing = addressRun($second);
$failing->update(['started_at' => now()->subSeconds(900)]);
$result = app(ConfigureDnsAndTls::class)->execute($failing);
expect($result->type)->toBe('fail')
->and($result->reason)->toBe('cert_timeout');
});
it('looks again for a short while before giving up on the custom certificate', function () {
// Traefik finishes HTTP-01 seconds after the first request to a new
// hostname, so advancing on the very first probe would mark a domain that
// is about to work as not working.
$s = fakeServices();
$s['traefik']->certUnreachable = ['cloud.berger.at'];
$fixture = servedInstance([
'custom_domain' => 'cloud.berger.at', 'domain_token' => 'tok', 'domain_verified_at' => now(),
]);
$run = addressRun($fixture);
$run->update(['started_at' => now()->subSeconds(5)]);
expect(app(ConfigureDnsAndTls::class)->execute($run)->type)->toBe('poll');
});
it('starts exactly one re-apply when verification flips, and none when nothing changed', function () {
Queue::fake();
$fixture = servedInstance(['custom_domain' => 'cloud.berger.at', 'domain_token' => 'tok123']);
bindDomainResolver(['_clupilot-challenge.cloud.berger.at' => [['txt' => 'cp-verify=tok123']]]);
// The flip: proof appears, so the domain has to become an address.
$this->artisan('clupilot:verify-domains')->assertSuccessful();
$runs = ProvisioningRun::query()->where('pipeline', 'address');
expect($runs->count())->toBe(1)
->and($fixture['instance']->fresh()->domainIsVerified())->toBeTrue();
// Finished, so nothing is in flight to suppress a second one — the only
// thing stopping it must be that nothing changed.
$runs->first()->update(['status' => ProvisioningRun::STATUS_COMPLETED]);
$this->artisan('clupilot:verify-domains')->assertSuccessful();
expect(ProvisioningRun::query()->where('pipeline', 'address')->count())->toBe(1);
// And the other flip: the proof is taken away for the third time running.
bindDomainResolver([]);
$this->artisan('clupilot:verify-domains');
$this->artisan('clupilot:verify-domains');
expect(ProvisioningRun::query()->where('pipeline', 'address')->count())->toBe(1);
$this->artisan('clupilot:verify-domains');
expect($fixture['instance']->fresh()->domainIsVerified())->toBeFalse()
->and(ProvisioningRun::query()->where('pipeline', 'address')->count())->toBe(2);
});
it('stops serving the domain when the package stops allowing one', function () {
$s = fakeServices();
Queue::fake();
$fixture = servedInstance([
'custom_domain' => 'cloud.berger.at',
'domain_token' => 'tok',
'domain_verified_at' => now(),
'domain_cert_ok' => true,
'routed_hostnames' => [platformAddress(), 'cloud.berger.at'],
]);
// What PlanChange::settleCustomDomain() reaches through CustomDomainAccess
// when a downgrade lands on a package that carries no own domain.
expect(app(CustomDomainAccess::class)->deactivate($fixture['instance']))->toBeTrue();
$run = ProvisioningRun::query()->where('pipeline', 'address')->latest('id')->first();
expect($run)->not->toBeNull();
app(ConfigureDnsAndTls::class)->execute($run);
app(ConfigureNextcloud::class)->execute($run);
expect($fixture['instance']->fresh()->custom_domain)->toBeNull()
->and($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();
});
it('does not start a second run while one is already going', function () {
Queue::fake();
$fixture = servedInstance([
'custom_domain' => 'cloud.berger.at', 'domain_token' => 'tok', 'domain_verified_at' => now(),
]);
$reapply = app(ReapplyInstanceAddress::class);
expect($reapply($fixture['instance']))->not->toBeNull()
->and($reapply($fixture['instance']))->toBeNull()
->and(ProvisioningRun::query()->where('pipeline', 'address')->count())->toBe(1);
// Nor beside the build itself: the customer run applies the address on its
// way past, and two runs writing one router is the thing being avoided.
ProvisioningRun::query()->where('pipeline', 'address')->update(['status' => ProvisioningRun::STATUS_COMPLETED]);
ProvisioningRun::factory()->create([
'subject_type' => Order::class,
'subject_id' => $fixture['order']->id,
'pipeline' => 'customer',
'status' => ProvisioningRun::STATUS_RUNNING,
]);
expect($reapply($fixture['instance']))->toBeNull()
->and(ProvisioningRun::query()->where('pipeline', 'address')->count())->toBe(1);
});
it('stops serving the old domain the moment the customer changes or clears it', function () {
Queue::fake();
$fixture = servedInstance([
'custom_domain' => 'cloud.berger.at',
'domain_token' => 'tok',
'domain_verified_at' => now(),
'domain_cert_ok' => true,
'routed_hostnames' => [platformAddress(), 'cloud.berger.at'],
]);
$user = signIntoPortal($fixture);
// Moving to another domain leaves the old one routed and trusted until
// something says otherwise — the new one is unproven and serves nothing.
Livewire::actingAs($user)->test(CustomDomain::class)
->set('domain', 'neu.berger.at')
->call('save')
->assertHasNoErrors();
expect(ProvisioningRun::query()->where('pipeline', 'address')->count())->toBe(1)
->and($fixture['instance']->fresh()->domain_cert_ok)->toBeFalse();
ProvisioningRun::query()->where('pipeline', 'address')->update(['status' => ProvisioningRun::STATUS_COMPLETED]);
// Clearing an unproven domain changes nothing that is being served, so it
// is not worth a run on a live machine.
Livewire::actingAs($user)->test(CustomDomain::class)
->set('domain', '')
->call('save');
expect($fixture['instance']->fresh()->custom_domain)->toBeNull()
->and(ProvisioningRun::query()->where('pipeline', 'address')->count())->toBe(1);
});
it('makes the domain an address as soon as the customers own check finds the proof', function () {
Queue::fake();
$fixture = servedInstance(['custom_domain' => 'cloud.berger.at', 'domain_token' => 'tok123']);
$user = signIntoPortal($fixture);
bindDomainResolver(['_clupilot-challenge.cloud.berger.at' => [['txt' => 'cp-verify=tok123']]]);
Livewire::actingAs($user)->test(CustomDomain::class)->call('checkNow');
expect($fixture['instance']->fresh()->domainIsVerified())->toBeTrue()
->and(ProvisioningRun::query()->where('pipeline', 'address')->count())->toBe(1);
});
it('has nothing to re-apply for an instance with no machine', function () {
Queue::fake();
// A reservation that never became a VM, and a build that failed: the steps
// would reach for a guest agent that is not there and spend the run's
// retries finding out.
$reserved = Instance::factory()->create(['status' => 'reserving', 'vmid' => null]);
expect(app(ReapplyInstanceAddress::class)($reserved))->toBeNull()
->and(app(ReapplyInstanceAddress::class)(null))->toBeNull()
->and(ProvisioningRun::query()->where('pipeline', 'address')->count())->toBe(0);
});