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)
<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) @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,18 +92,20 @@
]" /> ]" />
@endcan @endcan
@endif @endif
<div class="w-96 pb-6"> @if ($application->could_set_build_commands() || ($isStatic && $buildPack !== 'static'))
@if ($application->could_set_build_commands()) <div class="w-96 pb-6">
<x-forms.checkbox instantSave id="isStatic" label="Is it a static site?" @if ($application->could_set_build_commands())
helper="If your application is a static site or the final build assets should be served as a static site, enable this." <x-forms.checkbox instantSave id="isStatic" label="Is it a static site?"
x-bind:disabled="!canUpdate" /> helper="If your application is a static site or the final build assets should be served as a static site, enable this."
@endif x-bind:disabled="!canUpdate" />
@if ($isStatic && $buildPack !== 'static') @endif
<x-forms.checkbox label="Is it a SPA (Single Page Application)?" @if ($isStatic && $buildPack !== 'static')
helper="If your application is a SPA, enable this." id="isSpa" instantSave <x-forms.checkbox label="Is it a SPA (Single Page Application)?"
x-bind:disabled="!canUpdate"></x-forms.checkbox> helper="If your application is a SPA, enable this." id="isSpa" instantSave
@endif x-bind:disabled="!canUpdate"></x-forms.checkbox>
</div> @endif
</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,39 +8,35 @@
<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-2" wire:loading wire:target="loadAllContainers">
<div class="pt-4">The resource is not running.</div> Loading containers...
@else </div>
<div class="pt-2" wire:loading wire:target="loadAllContainers"> <div x-init="$wire.loadAllContainers()" wire:loading.remove wire:target="loadAllContainers">
Loading containers... @forelse ($servers as $server)
</div> <div class="py-2">
<div x-init="$wire.loadAllContainers()" wire:loading.remove wire:target="loadAllContainers"> <h4>Server: {{ $server->name }}</h4>
@forelse ($servers as $server) @if ($server->isFunctional())
<div class="py-2"> @if (isset($serverContainers[$server->id]) && count($serverContainers[$server->id]) > 0)
<h4>Server: {{ $server->name }}</h4> @php
@if ($server->isFunctional()) $totalContainers = collect($serverContainers)->flatten(1)->count();
@if (isset($serverContainers[$server->id]) && count($serverContainers[$server->id]) > 0) @endphp
@php @foreach ($serverContainers[$server->id] as $container)
$totalContainers = collect($serverContainers)->flatten(1)->count(); <livewire:project.shared.get-logs
@endphp wire:key="{{ data_get($container, 'ID', uniqid()) }}" :server="$server"
@foreach ($serverContainers[$server->id] as $container) :resource="$resource" :container="data_get($container, 'Names')"
<livewire:project.shared.get-logs :expandByDefault="$totalContainers === 1" />
wire:key="{{ data_get($container, 'ID', uniqid()) }}" :server="$server" @endforeach
: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
@else @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 @endif
</div> @else
@empty <div class="pt-2">Server {{ $server->name }} is not functional.</div>
<div>No functional server found for the application.</div> @endif
@endforelse </div>
</div> @empty
@endif <div>No functional server found for the application.</div>
@endforelse
</div>
</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();
});