feat(preview-env): add production variable fallback for docker-compose
When preview environment variables are configured, fall back to production
variables for keys not overridden by preview values. This ensures variables
like DB_PASSWORD that exist only in production are available in the preview
.env file, enabling proper ${VAR} interpolation in docker-compose YAML.
Fallback only applies when preview variables are configured, preventing
unintended leakage of production values when previews aren't in use.
Also improves UI by hiding the Domains section when only database services
are present, and simplifies the logs view by removing status checks.
This commit is contained in:
parent
c2c18a2f0f
commit
3034e89edb
6 changed files with 318 additions and 46 deletions
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,11 @@
|
|||
)]
|
||||
class EnvironmentVariable extends BaseModel
|
||||
{
|
||||
protected $attributes = [
|
||||
'is_runtime' => true,
|
||||
'is_buildtime' => true,
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
// Core identification
|
||||
'key',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
247
tests/Feature/PreviewEnvVarFallbackTest.php
Normal file
247
tests/Feature/PreviewEnvVarFallbackTest.php
Normal 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();
|
||||
});
|
||||
Loading…
Reference in a new issue