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) { foreach ($runtime_environment_variables_preview as $env) {
$envs->push($env->key.'='.$env->real_value); $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 // Add PORT if not exists, use the first port as default
if ($this->build_pack !== 'dockercompose') { if ($this->build_pack !== 'dockercompose') {
if ($this->application->environment_variables_preview->where('key', 'PORT')->isEmpty()) { if ($this->application->environment_variables_preview->where('key', 'PORT')->isEmpty()) {

View file

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

View file

@ -4,7 +4,7 @@
</x-slot> </x-slot>
@if ( @if (
(data_get($application, 'fqdn') || (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, 'previews', collect([]))->count() > 0 ||
data_get($application, 'ports_mappings_array')) && data_get($application, 'ports_mappings_array')) &&
data_get($application, 'settings.is_raw_compose_deployment_enabled') !== true) data_get($application, 'settings.is_raw_compose_deployment_enabled') !== true)

View file

@ -49,7 +49,13 @@
!is_null($parsedServices) && !is_null($parsedServices) &&
count($parsedServices) > 0 && count($parsedServices) > 0 &&
!$application->settings->is_raw_compose_deployment_enabled) !$application->settings->is_raw_compose_deployment_enabled)
@php
$hasNonDatabaseService = collect(data_get($parsedServices, 'services', []))
->contains(fn($service) => !isDatabaseImage(data_get($service, 'image')));
@endphp
@if ($hasNonDatabaseService)
<h3 class="pt-6">Domains</h3> <h3 class="pt-6">Domains</h3>
@endif
@foreach (data_get($parsedServices, 'services') as $serviceName => $service) @foreach (data_get($parsedServices, 'services') as $serviceName => $service)
@if (!isDatabaseImage(data_get($service, 'image'))) @if (!isDatabaseImage(data_get($service, 'image')))
<div class="flex items-end gap-2"> <div class="flex items-end gap-2">
@ -86,6 +92,7 @@
]" /> ]" />
@endcan @endcan
@endif @endif
@if ($application->could_set_build_commands() || ($isStatic && $buildPack !== 'static'))
<div class="w-96 pb-6"> <div class="w-96 pb-6">
@if ($application->could_set_build_commands()) @if ($application->could_set_build_commands())
<x-forms.checkbox instantSave id="isStatic" label="Is it a static site?" <x-forms.checkbox instantSave id="isStatic" label="Is it a static site?"
@ -98,6 +105,7 @@
x-bind:disabled="!canUpdate"></x-forms.checkbox> x-bind:disabled="!canUpdate"></x-forms.checkbox>
@endif @endif
</div> </div>
@endif
@if ($buildPack !== 'dockercompose') @if ($buildPack !== 'dockercompose')
<div class="flex items-end gap-2"> <div class="flex items-end gap-2">
@if ($application->settings->is_container_label_readonly_enabled == false) @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 @endif
</div> </div>
@endif @endif
<div> <div class="pt-6">
<h3>Build</h3> <h3>Build</h3>
@if ($application->build_pack === 'dockerimage') @if ($application->build_pack === 'dockerimage')
<x-forms.input <x-forms.input

View file

@ -8,9 +8,6 @@
<livewire:project.application.heading :application="$resource" /> <livewire:project.application.heading :application="$resource" />
<div> <div>
<h2>Logs</h2> <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"> <div class="pt-2" wire:loading wire:target="loadAllContainers">
Loading containers... Loading containers...
</div> </div>
@ -40,7 +37,6 @@
<div>No functional server found for the application.</div> <div>No functional server found for the application.</div>
@endforelse @endforelse
</div> </div>
@endif
</div> </div>
@elseif ($type === 'database') @elseif ($type === 'database')
<h1>Logs</h1> <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();
});