fix(preview-env): ensure auto-created preview env vars inherit runtime/buildtime flags (#9164)

This commit is contained in:
Andras Bacsai 2026-03-25 13:42:18 +01:00 committed by GitHub
commit fbdcf0de51
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 318 additions and 46 deletions

View file

@ -1333,6 +1333,22 @@ private function generate_runtime_environment_variables()
foreach ($runtime_environment_variables_preview as $env) {
$envs->push($env->key.'='.$env->real_value);
}
// Fall back to production env vars for keys not overridden by preview vars,
// but only when preview vars are configured. This ensures variables like
// DB_PASSWORD that are only set for production will be available in the
// preview .env file (fixing ${VAR} interpolation in docker-compose YAML),
// while avoiding leaking production values when previews aren't configured.
if ($runtime_environment_variables_preview->isNotEmpty()) {
$previewKeys = $runtime_environment_variables_preview->pluck('key')->toArray();
$fallback_production_vars = $sorted_environment_variables->filter(function ($env) use ($previewKeys) {
return $env->is_runtime && ! in_array($env->key, $previewKeys);
});
foreach ($fallback_production_vars as $env) {
$envs->push($env->key.'='.$env->real_value);
}
}
// Add PORT if not exists, use the first port as default
if ($this->build_pack !== 'dockercompose') {
if ($this->application->environment_variables_preview->where('key', 'PORT')->isEmpty()) {

View file

@ -32,6 +32,11 @@
)]
class EnvironmentVariable extends BaseModel
{
protected $attributes = [
'is_runtime' => true,
'is_buildtime' => true,
];
protected $fillable = [
// Core identification
'key',

View file

@ -4,7 +4,7 @@
</x-slot>
@if (
(data_get($application, 'fqdn') ||
collect(json_decode($this->application->docker_compose_domains))->count() > 0 ||
collect(json_decode($this->application->docker_compose_domains))->contains(fn($fqdn) => !empty(data_get($fqdn, 'domain'))) ||
data_get($application, 'previews', collect([]))->count() > 0 ||
data_get($application, 'ports_mappings_array')) &&
data_get($application, 'settings.is_raw_compose_deployment_enabled') !== true)

View file

@ -49,7 +49,13 @@
!is_null($parsedServices) &&
count($parsedServices) > 0 &&
!$application->settings->is_raw_compose_deployment_enabled)
<h3 class="pt-6">Domains</h3>
@php
$hasNonDatabaseService = collect(data_get($parsedServices, 'services', []))
->contains(fn($service) => !isDatabaseImage(data_get($service, 'image')));
@endphp
@if ($hasNonDatabaseService)
<h3 class="pt-6">Domains</h3>
@endif
@foreach (data_get($parsedServices, 'services') as $serviceName => $service)
@if (!isDatabaseImage(data_get($service, 'image')))
<div class="flex items-end gap-2">
@ -86,18 +92,20 @@
]" />
@endcan
@endif
<div class="w-96 pb-6">
@if ($application->could_set_build_commands())
<x-forms.checkbox instantSave id="isStatic" label="Is it a static site?"
helper="If your application is a static site or the final build assets should be served as a static site, enable this."
x-bind:disabled="!canUpdate" />
@endif
@if ($isStatic && $buildPack !== 'static')
<x-forms.checkbox label="Is it a SPA (Single Page Application)?"
helper="If your application is a SPA, enable this." id="isSpa" instantSave
x-bind:disabled="!canUpdate"></x-forms.checkbox>
@endif
</div>
@if ($application->could_set_build_commands() || ($isStatic && $buildPack !== 'static'))
<div class="w-96 pb-6">
@if ($application->could_set_build_commands())
<x-forms.checkbox instantSave id="isStatic" label="Is it a static site?"
helper="If your application is a static site or the final build assets should be served as a static site, enable this."
x-bind:disabled="!canUpdate" />
@endif
@if ($isStatic && $buildPack !== 'static')
<x-forms.checkbox label="Is it a SPA (Single Page Application)?"
helper="If your application is a SPA, enable this." id="isSpa" instantSave
x-bind:disabled="!canUpdate"></x-forms.checkbox>
@endif
</div>
@endif
@if ($buildPack !== 'dockercompose')
<div class="flex items-end gap-2">
@if ($application->settings->is_container_label_readonly_enabled == false)
@ -209,7 +217,7 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
@endif
</div>
@endif
<div>
<div class="pt-6">
<h3>Build</h3>
@if ($application->build_pack === 'dockerimage')
<x-forms.input

View file

@ -8,39 +8,35 @@
<livewire:project.application.heading :application="$resource" />
<div>
<h2>Logs</h2>
@if (str($status)->contains('exited'))
<div class="pt-4">The resource is not running.</div>
@else
<div class="pt-2" wire:loading wire:target="loadAllContainers">
Loading containers...
</div>
<div x-init="$wire.loadAllContainers()" wire:loading.remove wire:target="loadAllContainers">
@forelse ($servers as $server)
<div class="py-2">
<h4>Server: {{ $server->name }}</h4>
@if ($server->isFunctional())
@if (isset($serverContainers[$server->id]) && count($serverContainers[$server->id]) > 0)
@php
$totalContainers = collect($serverContainers)->flatten(1)->count();
@endphp
@foreach ($serverContainers[$server->id] as $container)
<livewire:project.shared.get-logs
wire:key="{{ data_get($container, 'ID', uniqid()) }}" :server="$server"
:resource="$resource" :container="data_get($container, 'Names')"
:expandByDefault="$totalContainers === 1" />
@endforeach
@else
<div class="pt-2">No containers are running on server: {{ $server->name }}</div>
@endif
<div class="pt-2" wire:loading wire:target="loadAllContainers">
Loading containers...
</div>
<div x-init="$wire.loadAllContainers()" wire:loading.remove wire:target="loadAllContainers">
@forelse ($servers as $server)
<div class="py-2">
<h4>Server: {{ $server->name }}</h4>
@if ($server->isFunctional())
@if (isset($serverContainers[$server->id]) && count($serverContainers[$server->id]) > 0)
@php
$totalContainers = collect($serverContainers)->flatten(1)->count();
@endphp
@foreach ($serverContainers[$server->id] as $container)
<livewire:project.shared.get-logs
wire:key="{{ data_get($container, 'ID', uniqid()) }}" :server="$server"
:resource="$resource" :container="data_get($container, 'Names')"
:expandByDefault="$totalContainers === 1" />
@endforeach
@else
<div class="pt-2">Server {{ $server->name }} is not functional.</div>
<div class="pt-2">No containers are running on server: {{ $server->name }}</div>
@endif
</div>
@empty
<div>No functional server found for the application.</div>
@endforelse
</div>
@endif
@else
<div class="pt-2">Server {{ $server->name }} is not functional.</div>
@endif
</div>
@empty
<div>No functional server found for the application.</div>
@endforelse
</div>
</div>
@elseif ($type === 'database')
<h1>Logs</h1>

View file

@ -0,0 +1,247 @@
<?php
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->user->teams()->attach($this->team);
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create([
'project_id' => $this->project->id,
]);
$this->application = Application::factory()->create([
'environment_id' => $this->environment->id,
]);
$this->actingAs($this->user);
});
/**
* Simulate the preview .env generation logic from
* ApplicationDeploymentJob::generate_runtime_environment_variables()
* including the production fallback fix.
*/
function simulatePreviewEnvGeneration(Application $application): \Illuminate\Support\Collection
{
$sorted_environment_variables = $application->environment_variables->sortBy('id');
$sorted_environment_variables_preview = $application->environment_variables_preview->sortBy('id');
$envs = collect([]);
// Preview vars
$runtime_environment_variables_preview = $sorted_environment_variables_preview->filter(fn ($env) => $env->is_runtime);
foreach ($runtime_environment_variables_preview as $env) {
$envs->push($env->key.'='.$env->real_value);
}
// Fallback: production vars not overridden by preview,
// only when preview vars are configured
if ($runtime_environment_variables_preview->isNotEmpty()) {
$previewKeys = $runtime_environment_variables_preview->pluck('key')->toArray();
$fallback_production_vars = $sorted_environment_variables->filter(function ($env) use ($previewKeys) {
return $env->is_runtime && ! in_array($env->key, $previewKeys);
});
foreach ($fallback_production_vars as $env) {
$envs->push($env->key.'='.$env->real_value);
}
}
return $envs;
}
test('production vars fall back when preview vars exist but do not cover all keys', function () {
// Create two production vars (booted hook auto-creates preview copies)
EnvironmentVariable::create([
'key' => 'DB_PASSWORD',
'value' => 'secret123',
'is_preview' => false,
'is_runtime' => true,
'is_buildtime' => false,
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
]);
EnvironmentVariable::create([
'key' => 'APP_KEY',
'value' => 'app_key_value',
'is_preview' => false,
'is_runtime' => true,
'is_buildtime' => false,
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
]);
// Delete only the DB_PASSWORD preview copy — APP_KEY preview copy remains
$this->application->environment_variables_preview()->where('key', 'DB_PASSWORD')->delete();
$this->application->refresh();
// Preview has APP_KEY but not DB_PASSWORD
expect($this->application->environment_variables_preview()->where('key', 'APP_KEY')->count())->toBe(1);
expect($this->application->environment_variables_preview()->where('key', 'DB_PASSWORD')->count())->toBe(0);
$envs = simulatePreviewEnvGeneration($this->application);
$envString = $envs->implode("\n");
// DB_PASSWORD should fall back from production
expect($envString)->toContain('DB_PASSWORD=');
// APP_KEY should use the preview value
expect($envString)->toContain('APP_KEY=');
});
test('no fallback when no preview vars are configured at all', function () {
// Create a production-only var (booted hook auto-creates preview copy)
EnvironmentVariable::create([
'key' => 'DB_PASSWORD',
'value' => 'secret123',
'is_preview' => false,
'is_runtime' => true,
'is_buildtime' => false,
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
]);
// Delete ALL preview copies — simulates no preview config
$this->application->environment_variables_preview()->delete();
$this->application->refresh();
expect($this->application->environment_variables_preview()->count())->toBe(0);
$envs = simulatePreviewEnvGeneration($this->application);
$envString = $envs->implode("\n");
// Should NOT fall back to production when no preview vars exist
expect($envString)->not->toContain('DB_PASSWORD=');
});
test('preview var overrides production var when both exist', function () {
// Create production var (auto-creates preview copy)
EnvironmentVariable::create([
'key' => 'DB_PASSWORD',
'value' => 'prod_password',
'is_preview' => false,
'is_runtime' => true,
'is_buildtime' => false,
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
]);
// Update the auto-created preview copy with a different value
$this->application->environment_variables_preview()
->where('key', 'DB_PASSWORD')
->update(['value' => encrypt('preview_password')]);
$this->application->refresh();
$envs = simulatePreviewEnvGeneration($this->application);
// Should contain preview value only, not production
$envEntries = $envs->filter(fn ($e) => str_starts_with($e, 'DB_PASSWORD='));
expect($envEntries)->toHaveCount(1);
expect($envEntries->first())->toContain('preview_password');
});
test('preview-only var works without production counterpart', function () {
// Create a preview-only var directly (no production counterpart)
EnvironmentVariable::create([
'key' => 'PREVIEW_ONLY_VAR',
'value' => 'preview_value',
'is_preview' => true,
'is_runtime' => true,
'is_buildtime' => false,
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
]);
$this->application->refresh();
$envs = simulatePreviewEnvGeneration($this->application);
$envString = $envs->implode("\n");
expect($envString)->toContain('PREVIEW_ONLY_VAR=');
});
test('buildtime-only production vars are not included in preview fallback', function () {
// Create a runtime preview var so fallback is active
EnvironmentVariable::create([
'key' => 'SOME_PREVIEW_VAR',
'value' => 'preview_value',
'is_preview' => true,
'is_runtime' => true,
'is_buildtime' => false,
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
]);
// Create a buildtime-only production var
EnvironmentVariable::create([
'key' => 'BUILD_SECRET',
'value' => 'build_only',
'is_preview' => false,
'is_runtime' => false,
'is_buildtime' => true,
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
]);
// Delete the auto-created preview copy of BUILD_SECRET
$this->application->environment_variables_preview()->where('key', 'BUILD_SECRET')->delete();
$this->application->refresh();
$envs = simulatePreviewEnvGeneration($this->application);
$envString = $envs->implode("\n");
expect($envString)->not->toContain('BUILD_SECRET');
expect($envString)->toContain('SOME_PREVIEW_VAR=');
});
test('preview env var inherits is_runtime and is_buildtime from production var', function () {
// Create production var WITH explicit flags
EnvironmentVariable::create([
'key' => 'DB_PASSWORD',
'value' => 'secret123',
'is_preview' => false,
'is_runtime' => true,
'is_buildtime' => true,
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
]);
$preview = EnvironmentVariable::where('key', 'DB_PASSWORD')
->where('is_preview', true)
->where('resourceable_id', $this->application->id)
->first();
expect($preview)->not->toBeNull();
expect($preview->is_runtime)->toBeTrue();
expect($preview->is_buildtime)->toBeTrue();
});
test('preview env var gets correct defaults when production var created without explicit flags', function () {
// Simulate code paths (docker-compose parser, dev view bulk submit) that create
// env vars without explicitly setting is_runtime/is_buildtime
EnvironmentVariable::create([
'key' => 'DB_PASSWORD',
'value' => 'secret123',
'is_preview' => false,
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
]);
$preview = EnvironmentVariable::where('key', 'DB_PASSWORD')
->where('is_preview', true)
->where('resourceable_id', $this->application->id)
->first();
expect($preview)->not->toBeNull();
expect($preview->is_runtime)->toBeTrue();
expect($preview->is_buildtime)->toBeTrue();
});