fix(previews): keep custom preview domains on redeploy

Closes #6536

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras BacsaiandClaude Opus 5.5 committed 2026-10-24 23:42:19 +02:00
1 parent 79cf62323a
commit 7654e8d3a9
4 files changed
+141 -6

No files matched your search

+2 -2
View File
@@ -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()) {
@@ -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');
+38 -2
View File
@@ -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)) {
@@ -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');
});