From 7654e8d3a98fe094442fd222bcfe3fb31d005876 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:42:19 +0200 Subject: [PATCH] fix(previews): keep custom preview domains on redeploy Closes #6536 Co-Authored-By: Claude Opus 5.5 --- app/Jobs/ApplicationDeploymentJob.php | 4 +- app/Livewire/Project/Application/Previews.php | 4 +- app/Models/ApplicationPreview.php | 40 +++++++- .../PreviewDomainPortOverridesTest.php | 99 +++++++++++++++++++ 4 files changed, 141 insertions(+), 6 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 92d5917eaf..c5f82c073f 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -292,9 +292,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } if ($this->preview) { if ($this->application->build_pack === 'dockercompose') { - $this->preview->generate_preview_fqdn_compose(); + $this->preview->generate_preview_fqdn_compose(keepExistingDomains: true); } else { - $this->preview->generate_preview_fqdn(); + $this->preview->generate_preview_fqdn(keepExistingDomains: true); } } if ($this->application->isGithubAppSource()) { diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index 34d2ae169a..7de6552b94 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -147,7 +147,7 @@ class Previews extends Component ]); $this->auditPreviewEvent('ui.application.preview_created', $pull_request_id); } - $found->generate_preview_fqdn_compose(); + $found->generate_preview_fqdn_compose(keepExistingDomains: ! $found->wasRecentlyCreated); $this->application->refresh(); $this->syncDockerTags(); } else { @@ -165,7 +165,7 @@ class Previews extends Component if ($found && $this->application->build_pack === 'dockerimage' && str($docker_registry_image_tag)->isNotEmpty()) { $this->saveDockerTagWithAudit($found, $docker_registry_image_tag); } - $found->generate_preview_fqdn(generateWithoutApplicationDomain: true); + $found->generate_preview_fqdn(generateWithoutApplicationDomain: true, keepExistingDomains: true); $this->application->refresh(); $this->syncDockerTags(); $this->dispatch('update_links'); diff --git a/app/Models/ApplicationPreview.php b/app/Models/ApplicationPreview.php index 17b791d733..22b1856814 100644 --- a/app/Models/ApplicationPreview.php +++ b/app/Models/ApplicationPreview.php @@ -196,8 +196,23 @@ class ApplicationPreview extends BaseModel return $this->morphMany(LocalPersistentVolume::class, 'resource'); } - public function generate_preview_fqdn(bool $generateWithoutApplicationDomain = false) + /** + * Set the preview domains from the preview URL template. + * + * With $keepExistingDomains, domains already stored on the preview (for example custom + * domains set in the UI) are kept, and only an empty preview gets generated domains. + */ + public function generate_preview_fqdn(bool $generateWithoutApplicationDomain = false, bool $keepExistingDomains = false) { + if ($keepExistingDomains && filled($this->fqdn)) { + $normalized = DomainPortOverrides::normalize($this->fqdn, $this->domain_port_overrides); + $this->fqdn = $normalized['fqdn']; + $this->domain_port_overrides = $normalized['overrides']; + $this->save(); + + return $this; + } + $applicationFqdn = $this->application->fqdn; if (! $applicationFqdn && $generateWithoutApplicationDomain) { $applicationFqdn = generateUrl( @@ -221,7 +236,13 @@ class ApplicationPreview extends BaseModel return $this; } - public function generate_preview_fqdn_compose(bool $generateWithoutApplicationDomain = false) + /** + * Set the preview domain of every compose service from the preview URL template. + * + * With $keepExistingDomains, a service that already has domains on the preview keeps them, + * and only services without a preview domain get generated domains. + */ + public function generate_preview_fqdn_compose(bool $generateWithoutApplicationDomain = false, bool $keepExistingDomains = false) { $applicationDomains = json_decode($this->application->docker_compose_domains ?: '[]', true) ?: []; $previewDomains = json_decode(data_get($this, 'docker_compose_domains') ?: '[]', true) ?: []; @@ -262,6 +283,21 @@ class ApplicationPreview extends BaseModel $docker_compose_domains = []; $previewPortOverrides = []; foreach ($serviceNames as $service_name) { + $existingPreviewDomains = $keepExistingDomains + ? DomainPortOverrides::normalize(getComposeServiceDomainString($previewDomains, $service_name), $this->domain_port_overrides) + : ['fqdn' => null, 'overrides' => null]; + if (filled($existingPreviewDomains['fqdn'])) { + $docker_compose_domains = putComposeServiceDomain( + $docker_compose_domains, + $service_name, + $existingPreviewDomains['fqdn'], + $serviceNames, + ); + $previewPortOverrides = array_merge($previewPortOverrides, $existingPreviewDomains['overrides'] ?? []); + + continue; + } + $domain_string = getComposeServiceDomainString($applicationDomains, $service_name); if (empty($domain_string)) { diff --git a/tests/Feature/PreviewDomainPortOverridesTest.php b/tests/Feature/PreviewDomainPortOverridesTest.php index 20050dc485..4c41f3234e 100644 --- a/tests/Feature/PreviewDomainPortOverridesTest.php +++ b/tests/Feature/PreviewDomainPortOverridesTest.php @@ -1113,3 +1113,102 @@ it('does not queue file sync jobs when polling dns checks for compose previews', ->and(Queue::pushed(ServerFilesFromServerJob::class))->toHaveCount(1) ->and($component->get('composeServices'))->toBe(['web', 'api']); }); + +it('keeps custom preview domains and port overrides on redeploy', function () { + $this->application->update([ + 'fqdn' => 'https://app.example.com', + 'preview_url_template' => '{{pr_id}}.{{domain}}', + ]); + $preview = createPreviewForPortTests($this->application, 6536, [ + 'fqdn' => 'https://custom-one.example.com,https://custom-two.example.com', + 'domain_port_overrides' => ['https://custom-two.example.com' => 9000], + ]); + + // The deployment job keeps the stored preview domains. + $preview->fresh()->generate_preview_fqdn(keepExistingDomains: true); + + expect($preview->fresh()->fqdn)->toBe('https://custom-one.example.com,https://custom-two.example.com') + ->and($preview->fresh()->domain_port_overrides)->toBe(['https://custom-two.example.com' => 9000]); + + // Without the flag (the "Generate domain" action), the template replaces them. + $preview->fresh()->generate_preview_fqdn(); + + expect($preview->fresh()->fqdn)->toBe('https://6536.app.example.com') + ->and($preview->fresh()->domain_port_overrides)->toBeNull(); +}); + +it('moves a port from a kept legacy preview domain into the overrides', function () { + $this->application->update(['fqdn' => 'https://app.example.com']); + $preview = createPreviewForPortTests($this->application, 6537, [ + 'fqdn' => 'https://custom.example.com:3000', + ]); + + $preview->generate_preview_fqdn(keepExistingDomains: true); + + expect($preview->fresh()->fqdn)->toBe('https://custom.example.com') + ->and($preview->fresh()->domain_port_overrides)->toBe(['https://custom.example.com' => 3000]); +}); + +it('generates a domain on redeploy when the preview has none', function () { + $this->application->update([ + 'fqdn' => 'https://app.example.com', + 'preview_url_template' => '{{pr_id}}.{{domain}}', + ]); + $preview = createPreviewForPortTests($this->application, 6538); + + $preview->generate_preview_fqdn(keepExistingDomains: true); + + expect($preview->fresh()->fqdn)->toBe('https://6538.app.example.com'); +}); + +it('keeps custom compose preview domains on redeploy and fills empty services', function () { + $this->application->update([ + 'build_pack' => 'dockercompose', + 'preview_url_template' => '{{pr_id}}.{{domain}}', + 'docker_compose_raw' => "services:\n frontend:\n image: nginx:alpine\n backend:\n image: nginx:alpine\n", + 'docker_compose_domains' => json_encode([ + 'frontend' => ['domain' => 'https://app.example.com'], + 'backend' => ['domain' => 'https://api.example.com:3001'], + ]), + ]); + $preview = createPreviewForPortTests($this->application->fresh(), 6539, [ + 'docker_compose_domains' => json_encode([ + 'frontend' => ['domain' => 'https://custom.example.com'], + 'backend' => ['domain' => ''], + ]), + 'domain_port_overrides' => ['https://custom.example.com' => 8080], + ]); + + $preview->generate_preview_fqdn_compose(keepExistingDomains: true); + $preview->refresh(); + + expect(json_decode($preview->docker_compose_domains, true))->toBe([ + 'frontend' => ['domain' => 'https://custom.example.com'], + 'backend' => ['domain' => 'https://6539.api.example.com'], + ]) + ->and($preview->fqdn)->toBe('https://custom.example.com,https://6539.api.example.com') + ->and($preview->domain_port_overrides)->toBe([ + 'https://custom.example.com' => 8080, + 'https://6539.api.example.com' => 3001, + ]); + + $preview->generate_preview_fqdn_compose(); + + expect(json_decode($preview->fresh()->docker_compose_domains, true)['frontend']['domain'])->toBe('https://6539.app.example.com'); +}); + +it('replaces a custom preview domain when the generate action runs', function () { + $this->application->update([ + 'fqdn' => 'https://app.example.com', + 'preview_url_template' => '{{pr_id}}.{{domain}}', + ]); + $preview = createPreviewForPortTests($this->application, 6540, [ + 'fqdn' => 'https://custom.example.com', + ]); + + Livewire::test(PreviewDomains::class, ['preview' => $preview]) + ->call('generateDomain') + ->assertHasNoErrors(); + + expect($preview->fresh()->fqdn)->toBe('https://6540.app.example.com'); +});