449 lines
20 KiB
PHP
449 lines
20 KiB
PHP
<?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\RunRunner;
|
||
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. Both
|
||
// halves of what the router file says are recorded, hostnames AND
|
||
// backend — an instance whose `routed_backend` were left null would be
|
||
// one the step has to rewrite, which is not the state being described.
|
||
'route_written' => true,
|
||
'routed_hostnames' => [platformAddress($subdomain)],
|
||
'routed_backend' => '10.20.0.7',
|
||
'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 domain’s certificate, but still fails over the platform’s', 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('does start one beside a run that will not apply the address itself', function () {
|
||
// The guard used to stand aside for ANY run in flight, on the reasoning that
|
||
// whatever is running reads the domain state when it gets to the step. True of
|
||
// `customer` and `plan-change`, which carry both address steps. False of a
|
||
// restart, which carries neither — and nothing looks again afterwards, because
|
||
// the nightly proof check only re-applies on the FLIP from unproven to proven
|
||
// and that flip has already happened. So a domain proven while the customer's
|
||
// machine happened to be going round was announced, charged for, and never
|
||
// routed for the life of the instance.
|
||
Queue::fake();
|
||
$fixture = servedInstance([
|
||
'custom_domain' => 'cloud.berger.at', 'domain_token' => 'tok', 'domain_verified_at' => now(),
|
||
]);
|
||
|
||
foreach (['restart', 'storage', 'quota'] as $pipeline) {
|
||
ProvisioningRun::query()->delete();
|
||
ProvisioningRun::factory()->create([
|
||
'subject_type' => Order::class,
|
||
'subject_id' => $fixture['order']->id,
|
||
'pipeline' => $pipeline,
|
||
'status' => ProvisioningRun::STATUS_RUNNING,
|
||
]);
|
||
|
||
expect(app(ReapplyInstanceAddress::class)($fixture['instance']))->not->toBeNull()
|
||
->and(ProvisioningRun::query()->where('pipeline', 'address')->count())->toBe(1);
|
||
}
|
||
|
||
// And still stands aside for a plan change, which reuses these two steps and
|
||
// will read the same domain state when it reaches them.
|
||
ProvisioningRun::query()->delete();
|
||
ProvisioningRun::factory()->create([
|
||
'subject_type' => Order::class,
|
||
'subject_id' => $fixture['order']->id,
|
||
'pipeline' => 'plan-change',
|
||
'status' => ProvisioningRun::STATUS_RUNNING,
|
||
]);
|
||
|
||
expect(app(ReapplyInstanceAddress::class)($fixture['instance']))->toBeNull();
|
||
});
|
||
|
||
it('keeps the domain unannounced when the run fails before it has been routed', function () {
|
||
// The half-finished address, which is the ordinary outcome of a failed run.
|
||
// With the router written first, a run that got the certificate and then fell
|
||
// over at Nextcloud left the customer's own domain serving Nextcloud's
|
||
// untrusted-domain error page under a valid certificate — and `domain_cert_ok`
|
||
// was already true, so the portal was printing that address as live.
|
||
Queue::fake();
|
||
$s = fakeServices();
|
||
$fixture = servedInstance([
|
||
'custom_domain' => 'cloud.berger.at', 'domain_token' => 'tok', 'domain_verified_at' => now(),
|
||
]);
|
||
|
||
// Nextcloud refuses the trusted_domains write; guest() throws, which the
|
||
// runner turns into a retry, so the run never reaches the router at all.
|
||
$s['pve']->guestScript('config:system:set trusted_domains', 1);
|
||
|
||
$run = addressRun($fixture);
|
||
$run->update(['status' => ProvisioningRun::STATUS_RUNNING, 'current_step' => 0]);
|
||
app(RunRunner::class)->advance($run);
|
||
|
||
$instance = $fixture['instance']->fresh();
|
||
|
||
expect($run->fresh()->status)->toBe(ProvisioningRun::STATUS_WAITING)
|
||
->and($run->fresh()->current_step)->toBe(0)
|
||
// Nothing announced, nothing routed: the customer is still on the platform
|
||
// address, which is the honest half to be left holding.
|
||
->and($instance->domain_cert_ok)->toBeFalse()
|
||
->and($instance->routed_hostnames)->toBe([platformAddress()])
|
||
->and($s['traefik']->serves('berger', 'cloud.berger.at'))->toBeFalse();
|
||
});
|
||
|
||
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 customer’s 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);
|
||
});
|