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 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('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); });