From cdd8f828401df287b2f4b98f743198c5ad56a833 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sun, 11 Oct 2026 21:02:11 +0200 Subject: [PATCH] fix(compose): preserve environment variable semantics (#11794) --- app/Jobs/ApplicationDeploymentJob.php | 37 ++ .../Shared/EnvironmentVariable/All.php | 99 +++- .../Shared/EnvironmentVariable/Show.php | 51 ++ .../EnvironmentVariable/ShowHardcoded.php | 7 + app/Models/EnvironmentVariable.php | 10 + app/Models/Service.php | 6 +- bootstrap/helpers/parsers.php | 98 +++- bootstrap/helpers/shared.php | 172 ++++++ ..._compose_null_to_environment_variables.php | 36 ++ .../shared/environment-variable/all.blade.php | 6 +- .../show-hardcoded.blade.php | 16 +- .../environment-variable/show.blade.php | 25 +- ...ationDeploymentControlVarFilteringTest.php | 34 ++ ...licationParserDockerComposeDomainsTest.php | 96 ++++ .../Feature/EnvironmentVariableSearchTest.php | 495 ++++++++++++++++++ .../PreserveComposeNullMigrationTest.php | 43 ++ ...tractHardcodedEnvironmentVariablesTest.php | 64 +++ 17 files changed, 1258 insertions(+), 37 deletions(-) create mode 100644 database/migrations/2026_10_11_183411_add_preserve_compose_null_to_environment_variables.php create mode 100644 tests/Feature/PreserveComposeNullMigrationTest.php diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index f54e5d3a3f..e04580e76d 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -1759,6 +1759,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue }); foreach ($runtime_environment_variables as $env) { + if ($this->shouldOmitBlankComposeEnvironmentVariable($env)) { + continue; + } + $envs->push($env->key.'='.$this->resolve_environment_variable($env)); } @@ -1826,6 +1830,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue }); foreach ($runtime_environment_variables_preview as $env) { + if ($this->shouldOmitBlankComposeEnvironmentVariable($env)) { + continue; + } + $envs->push($env->key.'='.$this->resolve_environment_variable($env)); } @@ -1840,6 +1848,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return $env->is_runtime && ! in_array($env->key, $previewKeys); }); foreach ($fallback_production_vars as $env) { + if ($this->shouldOmitBlankComposeEnvironmentVariable($env)) { + continue; + } + $envs->push($env->key.'='.$this->resolve_environment_variable($env)); } } @@ -1869,6 +1881,15 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue || $key->startsWith('SERVICE_NAME_'); } + private function shouldOmitBlankComposeEnvironmentVariable(EnvironmentVariable $environmentVariable): bool + { + return $this->build_pack === 'dockercompose' && ! $environmentVariable->preservesLegacyComposeNull() && dockerComposeEnvironmentVariableShouldBeUnset( + $this->application->docker_compose_raw ?? $this->application->docker_compose, + $environmentVariable->key, + $environmentVariable->value + ); + } + private function save_runtime_environment_variables() { // This method saves the .env file with ALL runtime variables @@ -2107,6 +2128,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } foreach ($sorted_environment_variables as $env) { + if ($this->shouldOmitBlankComposeEnvironmentVariable($env)) { + continue; + } + if ($this->build_pack === 'railpack' && $this->is_reserved_docker_client_env_key($env->key)) { continue; } @@ -2165,6 +2190,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } foreach ($sorted_environment_variables as $env) { + if ($this->shouldOmitBlankComposeEnvironmentVariable($env)) { + continue; + } + if ($this->build_pack === 'railpack' && $this->is_reserved_docker_client_env_key($env->key)) { continue; } @@ -3876,6 +3905,10 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } foreach ($envs as $env) { + if ($this->shouldOmitBlankComposeEnvironmentVariable($env)) { + continue; + } + $resolvedValue = $this->uses_remote_secret_references($env) ? $this->resolve_environment_variable_raw($env) : $env->getResolvedValueWithServer($this->mainServer); @@ -3894,6 +3927,10 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } foreach ($envs as $env) { + if ($this->shouldOmitBlankComposeEnvironmentVariable($env)) { + continue; + } + $resolvedValue = $this->uses_remote_secret_references($env) ? $this->resolve_environment_variable_raw($env) : $env->getResolvedValueWithServer($this->mainServer); diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/All.php b/app/Livewire/Project/Shared/EnvironmentVariable/All.php index 55da7f7228..4a2b894c60 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/All.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/All.php @@ -63,6 +63,8 @@ class All extends Component */ public bool $readyToLoad = false; + private ?Collection $composeEnvironmentAssignments = null; + protected $listeners = [ 'saveKey' => 'submit', 'refreshEnvs', @@ -75,6 +77,20 @@ class All extends Component $this->clearEnvironmentVariableCaches(); } + public function focusComposeEnvironmentVariable(string $key): void + { + if (preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $key) !== 1) { + return; + } + + $this->search = $key; + $this->variableFilters = []; + $this->serviceFilters = []; + $this->environmentFilter = 'all'; + $this->page = 1; + $this->clearEnvironmentVariableCaches(); + } + private function clearEnvironmentVariableCaches(): void { unset($this->environmentVariables); @@ -684,12 +700,16 @@ class All extends Component 'kind' => 'managed', 'scope' => $environmentVariable->is_preview ? 'preview' : 'production', 'environmentVariable' => $environmentVariable, + 'composeInfo' => $this->composeInfoFor($environmentVariable->key), ]; } private function hardcodedEnvironmentVariableRow(array $environmentVariable, bool $isPreview, int $index): array { $scope = $isPreview ? 'preview' : 'production'; + $references = $this->composeReferences($environmentVariable['value'] ?? null); + $environmentVariable['compose_type'] = $references === [] ? 'literal' : 'derived'; + $environmentVariable['references'] = $references; return [ 'id' => 'hardcoded-'.$scope.'-'.$environmentVariable['key'].'-'.($environmentVariable['service_name'] ?? 'default').'-'.$index, @@ -803,24 +823,79 @@ class All extends Component private function isSelfReferencingComposeVariable(array $variable): bool { - $value = $variable['value'] ?? null; - if (! is_string($value)) { - return false; - } - - if ($value === '$'.$variable['key']) { + if ($variable['is_passthrough'] ?? false) { return true; } - $reference = extractBalancedBraceContent($value); - if ($reference === null || $reference['start'] !== 1 || $reference['end'] !== strlen($value) - 1) { - return false; + $reference = $this->directComposeReference($variable['value'] ?? null); + + return $reference !== null && $reference['key'] === $variable['key']; + } + + /** @return array{services: list, default: ?string, operator: ?string, required: bool}|null */ + private function composeInfoFor(string $key): ?array + { + return dockerComposeEnvironmentVariableInfo( + $this->resource->docker_compose_raw ?? $this->resource->docker_compose, + $key + ); + } + + private function composeEnvironmentAssignments(): Collection + { + if ($this->composeEnvironmentAssignments !== null) { + return $this->composeEnvironmentAssignments; } - $splitReference = splitOnOperatorOutsideNested($reference['content']); - $referencedKey = $splitReference['variable'] ?? $reference['content']; + $dockerCompose = $this->resource->docker_compose_raw ?? $this->resource->docker_compose; - return $referencedKey === $variable['key']; + return $this->composeEnvironmentAssignments = blank($dockerCompose) + ? collect() + : extractHardcodedEnvironmentVariables($dockerCompose); + } + + /** @return list */ + private function composeReferences(mixed $value): array + { + if (! is_string($value) || ! str_contains($value, '$')) { + return []; + } + + return extractDockerComposeEnvironmentVariableReferences($value)->all(); + } + + /** @return array{key: string, default: ?string, operator: ?string, required: bool}|null */ + private function directComposeReference(mixed $value): ?array + { + if (! is_string($value)) { + return null; + } + + if (preg_match('/^\$([A-Za-z_][A-Za-z0-9_]*)$/', $value, $bareMatch) === 1) { + return ['key' => $bareMatch[1], 'default' => null, 'operator' => null, 'required' => false]; + } + + $balancedReference = extractBalancedBraceContent($value); + if ($balancedReference === null || $balancedReference['start'] !== 1 || $balancedReference['end'] !== strlen($value) - 1) { + return null; + } + + $splitReference = splitOnOperatorOutsideNested($balancedReference['content']); + if ($splitReference === null) { + return [ + 'key' => $balancedReference['content'], + 'default' => null, + 'operator' => null, + 'required' => false, + ]; + } + + return [ + 'key' => $splitReference['variable'], + 'default' => str_contains($splitReference['operator'], '-') ? $splitReference['default'] : null, + 'operator' => $splitReference['operator'], + 'required' => str_contains($splitReference['operator'], '?'), + ]; } public function getDevView() diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php index 636a0e6ea8..738c4b7ba6 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/Show.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/Show.php @@ -25,6 +25,9 @@ class Show extends Component { public bool $showEnvironmentType = true; + /** @var array{services: list, default: ?string, operator: ?string, required: bool}|null */ + public ?array $composeInfo = null; + use AuthorizesRequests, EnvironmentVariableAnalyzer, EnvironmentVariableProtection, HasSecretManagerAutocomplete; protected function secretManagerResource(): ?Model @@ -312,12 +315,37 @@ class Show extends Component $this->submit(); } + public function useComposeDefault(): void + { + $this->authorize('update', $this->env); + $this->loadValues(); + $composeInfo = $this->serverComposeInfo(); + if ($this->env->is_shown_once || $composeInfo === null || $composeInfo['default'] === null || $composeInfo['required']) { + return; + } + + $this->env->preserve_compose_null = false; + $this->value = null; + $this->submit(); + } + public function submit() { try { $this->authorize('update', $this->env); $this->loadValues(); + $composeInfo = $this->serverComposeInfo(); + if ($composeInfo !== null) { + $storedKey = $this->env->fresh()?->key; + if ($storedKey === null || $this->key !== $storedKey) { + $this->key = $storedKey ?? $this->key; + $this->dispatch('error', 'Compose-linked environment variable names cannot be changed.'); + + return; + } + } + if (! $this->isSharedVariable && $this->is_required && str($this->value)->isEmpty()) { $oldValue = $this->env->getOriginal('value'); $this->value = $oldValue; @@ -344,6 +372,29 @@ class Show extends Component } } + /** @return array{services: list, default: ?string, operator: ?string, required: bool}|null */ + private function serverComposeInfo(): ?array + { + if ($this->isSharedVariable) { + return null; + } + + $storedEnvironmentVariable = $this->env->fresh(); + if ($storedEnvironmentVariable === null) { + return null; + } + + $resource = $storedEnvironmentVariable->resourceable; + if (! $resource instanceof Application && ! $resource instanceof Service) { + return null; + } + + return dockerComposeEnvironmentVariableInfo( + $resource->docker_compose_raw ?? $resource->docker_compose, + $storedEnvironmentVariable->key + ); + } + #[Computed] public function availableSharedVariables(): array { diff --git a/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php b/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php index a7d7a73602..9033a3d32b 100644 --- a/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php +++ b/app/Livewire/Project/Shared/EnvironmentVariable/ShowHardcoded.php @@ -27,6 +27,11 @@ class ShowHardcoded extends Component #[Locked] public bool $isPreview = false; + public string $composeType = 'literal'; + + /** @var list */ + public array $references = []; + #[Locked] public ?string $resourceableType = null; @@ -40,6 +45,8 @@ class ShowHardcoded extends Component $this->isValueHidden = $this->env['is_value_hidden'] ?? true; $this->comment = $this->env['comment'] ?? null; $this->serviceName = $this->env['service_name'] ?? null; + $this->composeType = $this->env['compose_type'] ?? 'literal'; + $this->references = $this->env['references'] ?? []; } public function copyValue(): ?string diff --git a/app/Models/EnvironmentVariable.php b/app/Models/EnvironmentVariable.php index f6a017dbb7..a89d372b22 100644 --- a/app/Models/EnvironmentVariable.php +++ b/app/Models/EnvironmentVariable.php @@ -73,6 +73,7 @@ class EnvironmentVariable extends BaseModel protected $casts = [ 'key' => 'string', 'value' => 'encrypted', + 'preserve_compose_null' => 'boolean', 'is_multiline' => 'boolean', 'is_preview' => 'boolean', 'is_runtime' => 'boolean', @@ -114,6 +115,7 @@ class EnvironmentVariable extends BaseModel 'is_literal' => $environment_variable->is_literal ?? false, 'is_runtime' => $environment_variable->is_runtime ?? false, 'is_buildtime' => $environment_variable->is_buildtime ?? false, + 'is_required' => $environment_variable->is_required ?? false, 'comment' => $environment_variable->comment, 'resourceable_type' => Application::class, 'resourceable_id' => $environment_variable->resourceable_id, @@ -128,10 +130,18 @@ class EnvironmentVariable extends BaseModel }); static::saving(function (ModelsEnvironmentVariable $environmentVariable) { + if ($environmentVariable->preserve_compose_null && $environmentVariable->isDirty('value') && $environmentVariable->value !== null) { + $environmentVariable->preserve_compose_null = false; + } $environmentVariable->updateIsShared(); }); } + public function preservesLegacyComposeNull(): bool + { + return $this->preserve_compose_null && $this->value === null; + } + public function service() { return $this->belongsTo(Service::class); diff --git a/app/Models/Service.php b/app/Models/Service.php index bcf9cf06ee..0968211790 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -1618,7 +1618,11 @@ class Service extends BaseModel return 3; }); foreach ($sorted as $env) { - $envs->push("{$env->key}={$this->resolveSecretManagerDotenvValue($env)}"); + $resolvedValue = $this->resolveSecretManagerDotenvValue($env); + if (! $env->preservesLegacyComposeNull() && dockerComposeEnvironmentVariableShouldBeUnset($this->docker_compose_raw ?? $this->docker_compose, $env->key, $env->value)) { + continue; + } + $envs->push("{$env->key}={$resolvedValue}"); } instant_remote_write_file($this->server, $environmentFile, $envs->implode("\n")); instant_remote_process(["mv {$environmentFile} $workdir/.env"], $this->server); diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index e0d857baf8..ada1bb03ec 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -1070,6 +1070,12 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $baseNetwork = collect(["{$uuid}-{$pullRequestId}"]); } + $legacyNullEnvironmentVariables = $resource->environment_variables()->get(); + if ($isPullRequest) { + $legacyNullEnvironmentVariables = $resource->environment_variables_preview()->get() + ->concat($legacyNullEnvironmentVariables)->unique('key'); + } + $parsedServices = collect([]); $allMagicEnvironments = collect([]); @@ -1646,6 +1652,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int } } + $passthroughEnvironmentKeys = extractDockerComposePassthroughKeys(data_get($service, 'environment', [])); $normalEnvironments = $environment->diffKeys($allMagicEnvironments); $normalEnvironments = $normalEnvironments->filter(function ($value, $key) { return ! str($value)->startsWith('SERVICE_'); @@ -1655,6 +1662,35 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $value = str($value); $originalValue = $value; $parsedValue = replaceVariables($value); + if ($value->isEmpty() && $passthroughEnvironmentKeys->contains($key->value())) { + $resource->environment_variables()->firstOrCreate([ + 'key' => $key, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'is_preview' => false, + ]); + + continue; + } + extractDockerComposeEnvironmentVariableReferences($value->value()) + ->reject(fn (string $reference): bool => str_starts_with($reference, 'SERVICE_')) + ->each(function (string $reference) use ($resource): void { + $environmentVariable = $resource->environment_variables()->firstOrCreate([ + 'key' => $reference, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'is_preview' => false, + ]); + $isRequired = dockerComposeEnvironmentVariableIsRequired( + $resource->docker_compose_raw ?? $resource->docker_compose, + $reference + ); + if ((bool) $environmentVariable->is_required !== $isRequired) { + $environmentVariable->update(['is_required' => $isRequired]); + } + }); if ($value->startsWith('$SERVICE_')) { $resource->environment_variables()->firstOrCreate([ 'key' => $key, @@ -1707,14 +1743,11 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ - 'value' => $defaultValue, + 'value' => $isRequired ? null : $defaultValue, 'is_preview' => false, 'is_required' => $isRequired, ]); - // Add the variable to the environment so it will be shown in the deployable compose file - $environment[$varName] = $envVar->value; - // Recursively process nested variables in default value if (str_contains($defaultValue, '${')) { $searchPos = 0; @@ -1739,7 +1772,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int 'value' => $nestedSplit['default'], 'is_preview' => false, ]); - $environment[$nestedSplit['variable']] = $nestedEnvVar->value; } else { $nestedEnvVar = $resource->environment_variables()->firstOrCreate([ 'key' => $nestedContent, @@ -1748,7 +1780,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int ], [ 'is_preview' => false, ]); - $environment[$nestedContent] = $nestedEnvVar->value; } } @@ -1770,8 +1801,6 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int 'is_preview' => false, 'is_required' => $isRequired, ]); - // Add the variable to the environment using the saved DB value - $environment[$content] = $envVar->value; } } else { // Fallback to old behavior for malformed input (backward compatibility) @@ -2117,6 +2146,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $payload['volumes'] = $volumesParsed; } if ($environment->count() > 0 || $coolifyEnvironments->count() > 0) { + $environment = preserveLegacyDockerComposeNullAssignments($environment, $legacyNullEnvironmentVariables); $payload['environment'] = $environment->merge($coolifyEnvironments)->merge($serviceNameEnvironments); } if ($logging) { @@ -2210,6 +2240,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int data_forget($resource, 'environment_variables_preview'); $resource->save(); + syncDockerComposeEnvironmentVariableRequiredState($resource); + return $topLevel; } @@ -2268,6 +2300,8 @@ function serviceParser(Service $resource): Collection // Get the base docker network $baseNetwork = collect([$uuid]); + $legacyNullEnvironmentVariables = $resource->environment_variables()->where('preserve_compose_null', true)->whereNull('value')->get(); + $parsedServices = collect([]); // Generate SERVICE_NAME variables for docker compose services @@ -2976,6 +3010,7 @@ function serviceParser(Service $resource): Collection } } + $passthroughEnvironmentKeys = extractDockerComposePassthroughKeys(data_get($service, 'environment', [])); $normalEnvironments = $environment->diffKeys($allMagicEnvironments); $normalEnvironments = $normalEnvironments->filter(function ($value, $key) { return ! str($value)->startsWith('SERVICE_'); @@ -2986,6 +3021,39 @@ function serviceParser(Service $resource): Collection $value = str($value); $originalValue = $value; $parsedValue = replaceVariables($value); + if ($value->isEmpty() && $passthroughEnvironmentKeys->contains($key->value())) { + $resource->environment_variables()->firstOrCreate([ + 'key' => $key, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'is_preview' => false, + 'comment' => $envComments[$originalKey] ?? null, + ]); + + continue; + } + $referenceContent = extractBalancedBraceContent($value->value()); + $primaryReference = $referenceContent === null ? null : splitOnOperatorOutsideNested($referenceContent['content']); + extractDockerComposeEnvironmentVariableReferences($value->value()) + ->reject(fn (string $reference): bool => $value->startsWith('$') && $reference === ($primaryReference['variable'] ?? null) && str_contains($primaryReference['operator'], '?')) + ->reject(fn (string $reference): bool => str_starts_with($reference, 'SERVICE_')) + ->each(function (string $reference) use ($resource): void { + $environmentVariable = $resource->environment_variables()->firstOrCreate([ + 'key' => $reference, + 'resourceable_type' => get_class($resource), + 'resourceable_id' => $resource->id, + ], [ + 'is_preview' => false, + ]); + $isRequired = dockerComposeEnvironmentVariableIsRequired( + $resource->docker_compose_raw ?? $resource->docker_compose, + $reference + ); + if ((bool) $environmentVariable->is_required !== $isRequired) { + $environmentVariable->update(['is_required' => $isRequired]); + } + }); if ($parsedValue->startsWith('SERVICE_')) { $resource->environment_variables()->updateOrCreate([ 'key' => $key, @@ -3041,15 +3109,12 @@ function serviceParser(Service $resource): Collection 'resourceable_type' => get_class($resource), 'resourceable_id' => $resource->id, ], [ - 'value' => $defaultValue, + 'value' => $isRequired ? $defaultValue : null, 'is_preview' => false, 'is_required' => $isRequired, 'comment' => $envComments[$originalKey] ?? null, ]); - // Add the variable to the environment so it will be shown in the deployable compose file - $environment[$varName] = $envVar->value; - // Recursively process nested variables in default value if (str_contains($defaultValue, '${')) { // Extract and create nested variables @@ -3076,8 +3141,6 @@ function serviceParser(Service $resource): Collection 'value' => $nestedSplit['default'], 'is_preview' => false, ]); - // Add nested variable to environment - $environment[$nestedSplit['variable']] = $nestedEnvVar->value; } else { // Simple nested variable without default (only if it doesn't exist) $nestedEnvVar = $resource->environment_variables()->firstOrCreate([ @@ -3087,8 +3150,6 @@ function serviceParser(Service $resource): Collection ], [ 'is_preview' => false, ]); - // Add nested variable to environment - $environment[$nestedContent] = $nestedEnvVar->value; } } @@ -3112,8 +3173,6 @@ function serviceParser(Service $resource): Collection 'is_required' => $isRequired, 'comment' => $envComments[$originalKey] ?? null, ]); - // Add the variable to the environment using the saved DB value - $environment[$content] = $envVar->value; } } else { // Fallback to old behavior for malformed input (backward compatibility) @@ -3371,6 +3430,7 @@ function serviceParser(Service $resource): Collection $payload['volumes'] = $volumesParsed; } if ($environment->count() > 0 || $coolifyEnvironments->count() > 0) { + $environment = preserveLegacyDockerComposeNullAssignments($environment, $legacyNullEnvironmentVariables); $payload['environment'] = $environment->merge($coolifyEnvironments)->merge($serviceNameEnvironments); } if ($logging) { @@ -3445,5 +3505,7 @@ function serviceParser(Service $resource): Collection data_forget($resource, 'environment_variables_preview'); $resource->save(); + syncDockerComposeEnvironmentVariableRequiredState($resource); + return $topLevel; } diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 050685f396..0b2108bfb9 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -5239,6 +5239,8 @@ function extractHardcodedEnvironmentVariables(string $dockerComposeRaw): Collect continue; } + $passthroughKeys = extractDockerComposePassthroughKeys($environment); + // Convert environment variables to key-value format $environment = convertToKeyValueCollection($environment); @@ -5248,6 +5250,7 @@ function extractHardcodedEnvironmentVariables(string $dockerComposeRaw): Collect 'value' => $value, 'comment' => $envComments[$key] ?? null, 'service_name' => $serviceName, + 'is_passthrough' => $passthroughKeys->contains($key), ]); } } @@ -5255,6 +5258,175 @@ function extractHardcodedEnvironmentVariables(string $dockerComposeRaw): Collect return $hardcodedVars; } +/** @return Collection */ +function extractDockerComposePassthroughKeys(mixed $environment): Collection +{ + return collect($environment) + ->map(function (mixed $value, mixed $key): ?string { + if (is_numeric($key)) { + return is_string($value) && ! str_contains($value, '=') ? $value : null; + } + + return $value === null ? (string) $key : null; + }) + ->filter() + ->unique() + ->values(); +} + +/** @return Collection */ +function extractDockerComposeEnvironmentVariableReferences(mixed $value): Collection +{ + if (! is_string($value) || ! str_contains($value, '$')) { + return collect(); + } + + preg_match_all('/(?unique() + ->values(); +} + +/** @return array{services: list, default: ?string, operator: ?string, required: bool}|null */ +function dockerComposeEnvironmentVariableInfo(?string $dockerCompose, string $key): ?array +{ + if (blank($dockerCompose)) { + return null; + } + + $services = []; + $default = null; + $operator = null; + $required = false; + + foreach (extractHardcodedEnvironmentVariables($dockerCompose) as $assignment) { + $value = $assignment['value'] ?? null; + $isPassthrough = ($assignment['is_passthrough'] ?? false) && $assignment['key'] === $key; + if (! $isPassthrough && ! extractDockerComposeEnvironmentVariableReferences($value)->contains($key)) { + continue; + } + + if (filled($assignment['service_name'] ?? null)) { + $services[] = $assignment['service_name']; + } + + if (is_string($value)) { + $offset = 0; + while (($referenceStart = strpos($value, '${', $offset)) !== false) { + if ($referenceStart > 0 && $value[$referenceStart - 1] === '$') { + $offset = $referenceStart + 2; + + continue; + } + + $balancedReference = extractBalancedBraceContent(substr($value, $referenceStart)); + if ($balancedReference === null) { + break; + } + + $splitReference = splitOnOperatorOutsideNested($balancedReference['content']); + if ($splitReference !== null && $splitReference['variable'] === $key) { + $matchedOperator = $splitReference['operator']; + $operator ??= $matchedOperator; + if (str_contains($matchedOperator, '-')) { + $default ??= $splitReference['default']; + } + $required = $required || str_contains($matchedOperator, '?'); + } + + $offset = $referenceStart + 2; + } + } + } + + if ($services === []) { + return null; + } + + return [ + 'services' => array_values(array_unique($services)), + 'default' => $default, + 'operator' => $operator, + 'required' => $required, + ]; +} + +function dockerComposeEnvironmentVariableIsRequired(?string $dockerCompose, string $key): bool +{ + if (blank($dockerCompose)) { + return false; + } + + $key = preg_quote($key, '/'); + + return preg_match('/(?contains(fn (array $assignment): bool => $assignment['key'] === $key && $assignment['is_passthrough'])) { + return true; + } + + return extractDockerComposeEnvironmentVariableReferences($dockerCompose)->contains($key); +} + +/** @param Collection $environmentVariables */ +function preserveLegacyDockerComposeNullAssignments(Collection $environment, Collection $environmentVariables): Collection +{ + foreach ($environmentVariables as $environmentVariable) { + if (! $environmentVariable->preservesLegacyComposeNull() || ! $environment->has($environmentVariable->key)) { + continue; + } + + $key = $environmentVariable->key; + $value = $environment->get($key); + if ($value === null || $value === '$'.$key) { + $environment->put($key, ''); + + continue; + } + if (! is_string($value) || ! str_starts_with($value, '${')) { + continue; + } + $reference = extractBalancedBraceContent($value); + if ($reference === null || $reference['end'] !== strlen($value) - 1) { + continue; + } + $split = splitOnOperatorOutsideNested($reference['content']); + if (($split['variable'] ?? $reference['content']) === $key) { + $environment->put($key, ''); + } + } + + return $environment; +} + +function syncDockerComposeEnvironmentVariableRequiredState(Application|Service $resource): void +{ + $dockerCompose = $resource->docker_compose_raw ?? $resource->docker_compose; + + $environmentVariables = $resource->environment_variables()->get(); + if ($resource instanceof Application) { + $environmentVariables = $environmentVariables->concat($resource->environment_variables_preview()->get()); + } + + $environmentVariables->each(function (EnvironmentVariable $environmentVariable) use ($dockerCompose): void { + $isRequired = ! $environmentVariable->preservesLegacyComposeNull() + && dockerComposeEnvironmentVariableIsRequired($dockerCompose, $environmentVariable->key); + if ((bool) $environmentVariable->is_required !== $isRequired) { + $environmentVariable->update(['is_required' => $isRequired]); + } + }); +} + /** * Downsample metrics using the Largest-Triangle-Three-Buckets (LTTB) algorithm. * This preserves the visual shape of the data better than simple averaging. diff --git a/database/migrations/2026_10_11_183411_add_preserve_compose_null_to_environment_variables.php b/database/migrations/2026_10_11_183411_add_preserve_compose_null_to_environment_variables.php new file mode 100644 index 0000000000..31d2e5c1e4 --- /dev/null +++ b/database/migrations/2026_10_11_183411_add_preserve_compose_null_to_environment_variables.php @@ -0,0 +1,36 @@ +boolean('preserve_compose_null')->default(false); + }); + + EnvironmentVariable::query() + ->whereNull('value') + ->where(function ($query) { + $query->where('resourceable_type', Service::class) + ->orWhere(function ($query) { + $query->where('resourceable_type', Application::class) + ->whereIn('resourceable_id', Application::query()->where('build_pack', 'dockercompose')->select('id')->getQuery()); + }); + }) + ->toBase()->update(['preserve_compose_null' => true]); + } + + public function down(): void + { + Schema::table('environment_variables', function (Blueprint $table) { + $table->dropColumn('preserve_compose_null'); + }); + } +}; diff --git a/resources/views/livewire/project/shared/environment-variable/all.blade.php b/resources/views/livewire/project/shared/environment-variable/all.blade.php index 41b62e3c69..ddb61852f8 100644 --- a/resources/views/livewire/project/shared/environment-variable/all.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/all.blade.php @@ -15,7 +15,8 @@ } $activeFilterText = $activeFilterLabels->implode(', '); @endphp -
+
@can('manageEnvironment', $resource) @@ -225,7 +226,8 @@ @endif @if ($row['kind'] === 'managed') + :env="$row['environmentVariable']" :type="$resource->type()" + :composeInfo="$row['composeInfo']" :showEnvironmentType="$showEnvironmentType" /> @else {{ $serviceName }} @endif + {{ $composeType === 'derived' ? 'Derived in Compose' : 'Defined in Compose' }}
@endif - - Update this value in the Compose file. + + @if ($composeType === 'derived') + This value is derived from + @foreach ($references as $reference) + {{ $loop->last ? '' : ',' }} + @endforeach. + Edit those variables or update the Compose file. + @else + This value is defined directly in Docker Compose. Update it in the Compose file. + @endif
diff --git a/resources/views/livewire/project/shared/environment-variable/show.blade.php b/resources/views/livewire/project/shared/environment-variable/show.blade.php index d53719aca0..3e05e533a6 100644 --- a/resources/views/livewire/project/shared/environment-variable/show.blade.php +++ b/resources/views/livewire/project/shared/environment-variable/show.blade.php @@ -8,7 +8,7 @@ $showBuildtime = !$is_redis_credential && !$isMagicVariable && !$isSharedVariable; $showRuntime = !$is_redis_credential && !$isMagicVariable && !$isSharedVariable; @endphp -
@@ -36,6 +36,10 @@ @if ($is_really_required) Required @endif + @if ($composeInfo) + Used by Compose + @endif
@if (! $isSharedVariable) @if ($isMagicVariable) @@ -102,7 +106,7 @@ x-data="{ isMultiline: $wire.entangle('is_multiline') }">
+ :disabled="!$canEditValue || $is_redis_credential || $composeInfo !== null" /> @@ -159,6 +163,7 @@ @endif + @if ($composeInfo) + + Used by: {{ implode(', ', $composeInfo['services']) }}. + @if ($composeInfo['default'] !== null) + Compose default: {{ $composeInfo['default'] }}. + @endif + @if ($composeInfo['default'] !== null && !$composeInfo['required'] && $canEditValue && !$isLocked) + + Use Compose default + + @endif + @if ($composeInfo['required']) + Compose requires this variable to have a value. + @endif + + @endif @if ($showValueType || $showInterpolation || $showBuildtime || $showRuntime)
has('NIXPACKS_NODE_VERSION'))->toBeFalse(); expect($variables->has('PREVIEW_RUNTIME_ONLY'))->toBeFalse(); }); + +it('omits only unset Compose overrides from production and preview runtime files', function (int $pullRequestId) { + Server::flushIdentityMap(); + [$application, $server] = makeDeploymentControlVarFixture([ + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => "services:\n app:\n environment:\n INHERITED: \${INHERITED:-info}\n EMPTY: \${EMPTY-default}\n", + ]); + $application->update(['docker_compose' => $application->docker_compose_raw]); + foreach (['INHERITED' => null, 'EMPTY' => ''] as $key => $value) { + createApplicationEnvironmentVariable($application, ['key' => $key, 'value' => $value]); + $application->environment_variables_preview()->where('key', $key)->firstOrFail()->update(['value' => $value]); + } + [$job, $reflection] = makeControlVarFilteringJob($application, $server, ['pull_request_id' => $pullRequestId]); + $variables = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables'); + + expect($variables->contains('INHERITED='))->toBeFalse() + ->and($variables->contains('EMPTY='))->toBeTrue(); +})->with([0, 42]); + +it('includes migrated null Compose overrides in production and preview runtime files', function (int $pullRequestId) { + Server::flushIdentityMap(); + [$application, $server] = makeDeploymentControlVarFixture([ + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => "services:\n app:\n environment:\n LEGACY: \${LEGACY:-info}\n", + ]); + $application->update(['docker_compose' => $application->docker_compose_raw]); + createApplicationEnvironmentVariable($application, ['key' => 'LEGACY', 'value' => null]); + foreach ([$application->environment_variables(), $application->environment_variables_preview()] as $relation) { + $relation->where('key', 'LEGACY')->firstOrFail()->forceFill(['preserve_compose_null' => true])->save(); + } + [$job, $reflection] = makeControlVarFilteringJob($application, $server, ['pull_request_id' => $pullRequestId]); + + expect(invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables')->contains('LEGACY='))->toBeTrue(); +})->with([0, 42]); diff --git a/tests/Feature/ApplicationParserDockerComposeDomainsTest.php b/tests/Feature/ApplicationParserDockerComposeDomainsTest.php index 160abcc5de..6e3d9131a2 100644 --- a/tests/Feature/ApplicationParserDockerComposeDomainsTest.php +++ b/tests/Feature/ApplicationParserDockerComposeDomainsTest.php @@ -13,6 +13,7 @@ use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\DB; use phpseclib3\Crypt\EC; +use Symfony\Component\Yaml\Yaml; uses(RefreshDatabase::class); @@ -26,6 +27,7 @@ function disableExactProxyLabels(Application $application): Application } beforeEach(function () { + Server::flushIdentityMap(); $this->user = User::factory()->create(); $this->team = Team::factory()->create(); $this->user->teams()->attach($this->team); @@ -60,6 +62,59 @@ beforeEach(function () { ]); }); +test('applicationParser creates an editable variable for bare Compose passthrough syntax', function () { + $dockerCompose = <<<'YAML' +services: + app: + image: nginx + environment: + - API_TOKEN +YAML; + + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => StandaloneDocker::class, + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => $dockerCompose, + ]); + + applicationParser($application); + + expect($application->environment_variables()->where('key', 'API_TOKEN')->exists())->toBeTrue() + ->and(Yaml::parse($application->fresh()->docker_compose_raw)) + ->toBe(Yaml::parse($dockerCompose)); +}); + +test('applicationParser creates derived inputs and preserves required Compose expressions', function () { + $dockerCompose = <<<'YAML' +services: + app: + image: nginx + environment: + API_URL: https://${API_HOST}/v1 + API_TOKEN: ${API_TOKEN:?API_TOKEN must be set} +YAML; + + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => StandaloneDocker::class, + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => $dockerCompose, + ]); + + $parsedCompose = applicationParser($application); + $requiredVariable = $application->environment_variables()->where('key', 'API_TOKEN')->firstOrFail(); + + expect($application->environment_variables()->where('key', 'API_HOST')->exists())->toBeTrue() + ->and($requiredVariable->value)->toBeNull() + ->and((bool) $requiredVariable->is_required)->toBeTrue() + ->and(data_get($parsedCompose, 'services.app.environment.API_URL'))->toBe('https://${API_HOST}/v1') + ->and(data_get($parsedCompose, 'services.app.environment.API_TOKEN'))->toBe('${API_TOKEN:?API_TOKEN must be set}') + ->and(Yaml::parse($application->fresh()->docker_compose_raw))->toBe(Yaml::parse($dockerCompose)); +}); + test('applicationParser populates docker_compose_domains for KEY-based SERVICE_FQDN variables', function () { $dockerCompose = <<<'YAML' services: @@ -724,3 +779,44 @@ YAML, ->and($frontendLabels->contains(fn (string $label): bool => str_contains($label, '.loadbalancer.server.port='))) ->toBeFalse(); }); + +test('applicationParser preserves an existing required value matching the Compose message', function () { + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => StandaloneDocker::class, + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => "services:\n app:\n image: nginx\n environment:\n TOKEN: \${TOKEN:?configured}\n", + ]); + $variable = $application->environment_variables()->create(['key' => 'TOKEN', 'value' => 'configured']); + + applicationParser($application); + + expect($variable->fresh()->value)->toBe('configured'); +}); + +it('preserves migrated null assignments separately for production and previews', function (int $pullRequestId) { + $compose = "services:\n app:\n image: nginx\n environment:\n LEGACY: \${LEGACY:-default}\n"; + $application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => StandaloneDocker::class, + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => $compose, + ]); + $env = $application->environment_variables()->create(['key' => 'LEGACY', 'value' => null]); + $env->forceFill(['preserve_compose_null' => true])->save(); + $previewEnv = $application->environment_variables_preview()->where('key', 'LEGACY')->firstOrFail(); + $previewEnv->update(['value' => 'preview-value']); + $previewId = $pullRequestId ? ApplicationPreview::create([ + 'application_id' => $application->id, + 'pull_request_id' => $pullRequestId, + 'pull_request_html_url' => 'https://example.invalid/pull/42', + ])->id : null; + + $parsed = applicationParser($application, $pullRequestId, $previewId); + + expect(data_get($parsed, 'services.'.($pullRequestId ? 'app-pr-42' : 'app').'.environment.LEGACY')) + ->toBe($pullRequestId ? '${LEGACY:-default}' : '') + ->and($application->fresh()->docker_compose_raw)->toBe($compose); +})->with([0, 42]); diff --git a/tests/Feature/EnvironmentVariableSearchTest.php b/tests/Feature/EnvironmentVariableSearchTest.php index 5ce4c09c44..67c713cad1 100644 --- a/tests/Feature/EnvironmentVariableSearchTest.php +++ b/tests/Feature/EnvironmentVariableSearchTest.php @@ -1,20 +1,24 @@ 0]); $this->user = User::factory()->create(); @@ -246,6 +250,385 @@ YAML, ->and($component->instance()->environmentVariablePageRows->first()['environmentVariable']['value'])->toBe('from-compose'); }); +it('keeps a self-referencing Compose environment variable editable without changing Compose', function () { + $dockerCompose = <<<'YAML' +services: + app: + image: nginx + environment: + - API_TOKEN=${API_TOKEN} +YAML; + + $service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'docker_compose_raw' => $dockerCompose, + 'docker_compose' => $dockerCompose, + ]); + + $environmentVariable = EnvironmentVariable::create([ + 'key' => 'API_TOKEN', + 'value' => 'from-environment-tab', + 'resourceable_type' => Service::class, + 'resourceable_id' => $service->id, + ]); + + $component = Livewire::test(All::class, ['resource' => $service]) + ->call('loadEnvironmentVariables'); + + expect($component->instance()->environmentVariablePageRows) + ->toHaveCount(1) + ->and($component->instance()->environmentVariablePageRows->first()['kind'])->toBe('managed') + ->and($component->instance()->environmentVariablePageRows->first()['environmentVariable']->is($environmentVariable))->toBeTrue() + ->and($component->instance()->environmentVariablePageRows->first()['composeInfo'])->toBe([ + 'services' => ['app'], + 'default' => null, + 'operator' => null, + 'required' => false, + ]) + ->and($service->fresh()->docker_compose_raw)->toBe($dockerCompose) + ->and($service->fresh()->docker_compose)->toBe($dockerCompose); +}); + +it('keeps a self-referencing Compose variable with a default editable', function () { + $service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'docker_compose_raw' => <<<'YAML' +services: + app: + image: nginx + environment: + LOG_LEVEL: ${LOG_LEVEL:-info} +YAML, + ]); + + EnvironmentVariable::create([ + 'key' => 'LOG_LEVEL', + 'value' => 'debug', + 'resourceable_type' => Service::class, + 'resourceable_id' => $service->id, + ]); + + $component = Livewire::test(All::class, ['resource' => $service]) + ->call('loadEnvironmentVariables'); + + expect($component->instance()->environmentVariablePageRows) + ->toHaveCount(1) + ->and($component->instance()->environmentVariablePageRows->first()['kind'])->toBe('managed') + ->and($component->instance()->environmentVariablePageRows->first()['composeInfo'])->toBe([ + 'services' => ['app'], + 'default' => 'info', + 'operator' => ':-', + 'required' => false, + ]); +}); + +it('describes derived Compose assignments without replacing them', function () { + $dockerCompose = <<<'YAML' +services: + api: + image: nginx + environment: + API_URL: https://${API_HOST}/v1 +YAML; + + $service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'docker_compose_raw' => $dockerCompose, + 'docker_compose' => $dockerCompose, + ]); + + EnvironmentVariable::create([ + 'key' => 'API_HOST', + 'value' => 'api.example.com', + 'resourceable_type' => Service::class, + 'resourceable_id' => $service->id, + ]); + + $rows = Livewire::test(All::class, ['resource' => $service]) + ->call('loadEnvironmentVariables') + ->instance() + ->environmentVariablePageRows; + + expect($rows)->toHaveCount(2) + ->and($rows->firstWhere('kind', 'managed')['composeInfo'])->toBe([ + 'services' => ['api'], + 'default' => null, + 'operator' => null, + 'required' => false, + ]) + ->and($rows->firstWhere('kind', 'hardcoded')['environmentVariable']['compose_type'])->toBe('derived') + ->and($rows->firstWhere('kind', 'hardcoded')['environmentVariable']['references'])->toBe(['API_HOST']) + ->and($service->fresh()->docker_compose_raw)->toBe($dockerCompose) + ->and($service->fresh()->docker_compose)->toBe($dockerCompose); + + Livewire::test(All::class, ['resource' => $service]) + ->call('loadEnvironmentVariables') + ->set('serviceFilters', ['api']) + ->set('variableFilters', ['managed']) + ->set('environmentFilter', 'production') + ->call('focusComposeEnvironmentVariable', 'API_HOST') + ->assertSet('search', 'API_HOST') + ->assertSet('serviceFilters', []) + ->assertSet('variableFilters', []) + ->assertSet('environmentFilter', 'all') + ->assertSet('page', 1); +}); + +it('creates editable inputs referenced inside derived Compose values', function () { + $dockerCompose = <<<'YAML' +services: + api: + image: nginx + environment: + API_URL: ${API_SCHEME}://${API_HOST}/v1 +YAML; + + $service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'server_id' => Server::factory()->create(['team_id' => $this->team->id])->id, + 'docker_compose_raw' => $dockerCompose, + ]); + + $service->parse(); + + expect($service->environment_variables()->where('key', 'API_SCHEME')->exists())->toBeTrue() + ->and($service->environment_variables()->where('key', 'API_HOST')->exists())->toBeTrue() + ->and(Yaml::parse($service->fresh()->docker_compose_raw))->toBe(Yaml::parse($dockerCompose)); +}); + +it('keeps editable and literal rows when services use the same key differently', function () { + $service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'docker_compose_raw' => <<<'YAML' +services: + api: + image: nginx + environment: + FOO: ${FOO} + worker: + image: nginx + environment: + FOO: fixed +YAML, + ]); + + EnvironmentVariable::create([ + 'key' => 'FOO', + 'value' => 'editable', + 'resourceable_type' => Service::class, + 'resourceable_id' => $service->id, + ]); + + $rows = Livewire::test(All::class, ['resource' => $service]) + ->call('loadEnvironmentVariables') + ->instance() + ->environmentVariablePageRows; + + expect($rows)->toHaveCount(2) + ->and($rows->pluck('kind')->all())->toBe(['managed', 'hardcoded']); +}); + +it('treats escaped Compose dollars as a literal value', function () { + $service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'docker_compose_raw' => <<<'YAML' +services: + app: + image: nginx + environment: + SHELL_EXPRESSION: $${NOT_AN_INPUT} +YAML, + ]); + + $row = Livewire::test(All::class, ['resource' => $service]) + ->call('loadEnvironmentVariables') + ->instance() + ->environmentVariablePageRows + ->first(); + + expect($row['kind'])->toBe('hardcoded') + ->and($row['environmentVariable']['compose_type'])->toBe('literal') + ->and($row['environmentVariable']['references'])->toBe([]); +}); + +it('preserves required Compose prefills for service template compatibility', function () { + $dockerCompose = <<<'YAML' +services: + app: + image: nginx + environment: + API_TOKEN: ${API_TOKEN:?API_TOKEN must be set} +YAML; + + $service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'server_id' => Server::factory()->create(['team_id' => $this->team->id])->id, + 'docker_compose_raw' => $dockerCompose, + ]); + + $parsedCompose = $service->parse(); + $environmentVariable = $service->environment_variables()->where('key', 'API_TOKEN')->firstOrFail(); + + expect($environmentVariable->value)->toBe('API_TOKEN must be set') + ->and((bool) $environmentVariable->is_required)->toBeTrue() + ->and(data_get($parsedCompose, 'services.app.environment.API_TOKEN'))->toBe('${API_TOKEN:?API_TOKEN must be set}') + ->and(Yaml::parse($service->fresh()->docker_compose_raw))->toBe(Yaml::parse($dockerCompose)); +}); + +it('synchronizes required metadata when Compose expressions change', function () { + $server = Server::factory()->create(['team_id' => $this->team->id]); + $service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'server_id' => $server->id, + 'docker_compose_raw' => <<<'YAML' +services: + app: + image: nginx + environment: + API_TOKEN: ${API_TOKEN:?API_TOKEN must be set} +YAML, + ]); + $environmentVariable = EnvironmentVariable::create([ + 'key' => 'API_TOKEN', + 'value' => 'configured', + 'is_required' => false, + 'resourceable_type' => Service::class, + 'resourceable_id' => $service->id, + ]); + + $service->parse(); + expect((bool) $environmentVariable->fresh()->is_required)->toBeTrue(); + + $service->update([ + 'docker_compose_raw' => <<<'YAML' +services: + app: + image: nginx + environment: + API_TOKEN: ${API_TOKEN:-optional} +YAML, + ]); + $service->parse(); + + expect((bool) $environmentVariable->fresh()->is_required)->toBeFalse(); + + $service->update([ + 'docker_compose_raw' => <<<'YAML' +services: + app: + image: nginx +YAML, + ]); + $service->parse(); + + expect((bool) $environmentVariable->fresh()->is_required)->toBeFalse(); +}); + +it('rejects renaming a Compose-linked variable through Livewire state', function () { + $service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'server_id' => Server::factory()->create(['team_id' => $this->team->id])->id, + 'docker_compose_raw' => "services:\n app:\n image: nginx\n environment:\n API_TOKEN: \${API_TOKEN}\n", + ]); + $environmentVariable = EnvironmentVariable::create([ + 'key' => 'API_TOKEN', + 'value' => 'secret', + 'resourceable_type' => Service::class, + 'resourceable_id' => $service->id, + ]); + + Livewire::test(Show::class, [ + 'env' => $environmentVariable, + 'type' => 'service', + 'composeInfo' => [ + 'services' => ['app'], + 'default' => null, + 'operator' => null, + 'required' => false, + ], + ])->call('loadValues') + ->set('composeInfo', null) + ->set('key', 'RENAMED_TOKEN') + ->call('submit'); + + expect($environmentVariable->fresh()->key)->toBe('API_TOKEN'); +}); + +it('creates an editable variable for bare Compose passthrough syntax without rewriting Compose', function () { + $dockerCompose = <<<'YAML' +services: + app: + image: nginx + environment: + - API_TOKEN + - EMPTY_VALUE= +YAML; + + $service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'server_id' => Server::factory()->create(['team_id' => $this->team->id])->id, + 'docker_compose_raw' => $dockerCompose, + ]); + $storedDockerCompose = $service->docker_compose_raw; + + $service->parse(); + $rows = Livewire::test(All::class, ['resource' => $service->fresh()]) + ->call('loadEnvironmentVariables') + ->instance() + ->environmentVariablePageRows; + + expect($service->environment_variables()->where('key', 'API_TOKEN')->exists())->toBeTrue() + ->and($service->environment_variables()->where('key', 'EMPTY_VALUE')->exists())->toBeFalse() + ->and($rows)->toHaveCount(2) + ->and($rows->first()['kind'])->toBe('managed') + ->and($rows->first()['composeInfo'])->toBe([ + 'services' => ['app'], + 'default' => null, + 'operator' => null, + 'required' => false, + ]) + ->and($rows->last()['kind'])->toBe('hardcoded') + ->and($rows->last()['environmentVariable']['key'])->toBe('EMPTY_VALUE') + ->and($rows->last()['environmentVariable']['compose_type'])->toBe('literal') + ->and(Yaml::parse($service->fresh()->docker_compose_raw))->toBe(Yaml::parse($storedDockerCompose)); +}); + +it('preserves explicit empty values and removes overrides only on request', function () { + $service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'server_id' => Server::factory()->create(['team_id' => $this->team->id])->id, + 'docker_compose_raw' => "services:\n app:\n image: nginx\n environment:\n LOG_LEVEL: \${LOG_LEVEL:-info}\n", + ]); + $environmentVariable = EnvironmentVariable::create([ + 'key' => 'LOG_LEVEL', + 'value' => 'info', + 'resourceable_type' => Service::class, + 'resourceable_id' => $service->id, + ]); + + $component = Livewire::test(Show::class, [ + 'env' => $environmentVariable, + 'type' => 'service', + 'composeInfo' => [ + 'services' => ['app'], + 'default' => 'info', + 'operator' => ':-', + 'required' => false, + ], + ])->call('loadValues'); + + $component->assertSet('value', 'info') + ->set('value', 'debug') + ->call('submit') + ->set('value', '') + ->call('submit'); + + expect($environmentVariable->fresh()->value)->toBe(''); + + $component->call('useComposeDefault'); + expect($environmentVariable->fresh()->value)->toBeNull(); +}); + it('searches service environment variables without requiring preview variables', function () { $service = Service::factory()->create([ 'environment_id' => $this->environment->id, @@ -430,3 +813,115 @@ it('hides the preview section when search filters out all preview variables', fu ->toBe(['API_KEY']) ->and($component->instance()->environmentVariablesPreview)->toHaveCount(0); }); + +it('keeps a required stored value when saving only a comment', function () { + $service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'server_id' => Server::factory()->create(['team_id' => $this->team->id])->id, + 'docker_compose_raw' => "services:\n app:\n environment:\n LEVEL: \${LEVEL:-info}\n CHECK: \${LEVEL:?required}\n", + ]); + $variable = $service->environment_variables()->create(['key' => 'LEVEL', 'value' => 'info', 'is_required' => true]); + $component = Livewire::test(Show::class, [ + 'env' => $variable, + 'type' => 'service', + 'composeInfo' => dockerComposeEnvironmentVariableInfo($service->docker_compose_raw, 'LEVEL'), + ])->call('loadValues')->set('comment', 'Keep this value')->call('submit'); + + expect($variable->fresh()->value)->toBe('info') + ->and($variable->fresh()->comment)->toBe('Keep this value'); + $component->call('useComposeDefault'); + expect($variable->fresh()->value)->toBe('info'); +}); + +it('preserves existing required service template values on repeated parses', function () { + $service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'server_id' => Server::factory()->create(['team_id' => $this->team->id])->id, + 'docker_compose_raw' => "services:\n app:\n image: alpine\n environment:\n TZ: \${TZ:?UTC}\n", + ]); + $variable = $service->environment_variables()->create(['key' => 'TZ', 'value' => 'UTC']); + $service->parse(); + $service->parse(); + expect($variable->fresh()->value)->toBe('UTC'); +}); + +it('prefills required service template values without saving fallback overrides', function () { + $service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'server_id' => Server::factory()->create(['team_id' => $this->team->id])->id, + 'docker_compose_raw' => "services:\n app:\n image: alpine\n environment:\n TZ: \${TZ:?UTC}\n LEVEL: \${LEVEL:-info}\n worker:\n image: alpine\n environment:\n LEVEL: \${LEVEL:-warn}\n", + ]); + $service->parse(); + expect($service->environment_variables()->where('key', 'TZ')->firstOrFail()->value)->toBe('UTC') + ->and($service->environment_variables()->where('key', 'LEVEL')->firstOrFail()->value)->toBeNull(); +}); + +it('authorizes removing a Compose override on the server', function (string $role) { + $service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'server_id' => Server::factory()->create(['team_id' => $this->team->id])->id, + 'docker_compose_raw' => "services:\n app:\n environment:\n LEVEL: \${LEVEL:-info}\n", + ]); + $variable = $service->environment_variables()->create(['key' => 'LEVEL', 'value' => 'debug']); + $component = Livewire::test(Show::class, ['env' => $variable, 'type' => 'service']); + $user = User::factory()->create(); + if ($role === 'member') { + $this->team->members()->attach($user, ['role' => 'member']); + } + $this->actingAs($user); + $component->call('useComposeDefault')->assertForbidden(); + expect($variable->fresh()->value)->toBe('debug'); +})->with(['member', 'other-team']); + +it('preserves migrated null Compose assignments through parsing and comment-only saves', function () { + $compose = "services:\n app:\n image: nginx\n environment:\n COLON: \${COLON:-default}\n DASH: \${DASH-default}\n REQUIRED_NULL: \${REQUIRED_NULL:?required}\n PASSTHROUGH:\n"; + $service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'server_id' => Server::factory()->create(['team_id' => $this->team->id])->id, + 'docker_compose_raw' => $compose, + ]); + foreach (['COLON', 'DASH', 'REQUIRED_NULL', 'PASSTHROUGH'] as $key) { + $env = $service->environment_variables()->create(['key' => $key, 'value' => null]); + $env->forceFill(['preserve_compose_null' => true])->save(); + } + + $parsed = $service->parse(); + foreach (['COLON', 'DASH', 'REQUIRED_NULL', 'PASSTHROUGH'] as $key) { + $env = $service->environment_variables()->where('key', $key)->firstOrFail(); + expect(data_get($parsed, 'services.app.environment.'.$key))->toBe(''); + Livewire::test(Show::class, ['env' => $env, 'type' => 'service']) + ->call('loadValues')->set('comment', 'unchanged value')->call('submit'); + expect($env->fresh()->value)->toBeNull() + ->and($env->fresh()->preserve_compose_null)->toBeTrue() + ->and($env->fresh()->comment)->toBe('unchanged value'); + } + expect($service->fresh()->docker_compose_raw)->toBe($compose); +}); + +it('explicitly releases a migrated null variable to Compose defaults', function () { + $compose = "services:\n app:\n image: nginx\n environment:\n LOG_LEVEL: \${LOG_LEVEL:-info}\n"; + $service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'server_id' => Server::factory()->create(['team_id' => $this->team->id])->id, + 'docker_compose_raw' => $compose, + ]); + $env = $service->environment_variables()->create(['key' => 'LOG_LEVEL', 'value' => null]); + $env->forceFill(['preserve_compose_null' => true])->save(); + + Livewire::test(Show::class, ['env' => $env, 'type' => 'service'])->call('useComposeDefault'); + + expect($env->fresh()->value)->toBeNull() + ->and($env->fresh()->preserve_compose_null)->toBeFalse() + ->and(data_get($service->parse(), 'services.app.environment.LOG_LEVEL'))->toBe('${LOG_LEVEL:-info}'); +}); + +it('releases null compatibility after an explicit value edit', function () { + $service = Service::factory()->create(['environment_id' => $this->environment->id]); + $env = $service->environment_variables()->create(['key' => 'EDITED', 'value' => null]); + $env->forceFill(['preserve_compose_null' => true])->save(); + + $env->update(['value' => 'configured']); + expect($env->fresh()->preserve_compose_null)->toBeFalse(); + $env->update(['value' => null]); + expect($env->fresh()->preserve_compose_null)->toBeFalse(); +}); diff --git a/tests/Feature/PreserveComposeNullMigrationTest.php b/tests/Feature/PreserveComposeNullMigrationTest.php new file mode 100644 index 0000000000..d3a2e6ddbe --- /dev/null +++ b/tests/Feature/PreserveComposeNullMigrationTest.php @@ -0,0 +1,43 @@ +create(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $composeApp = Application::factory()->create(['environment_id' => $environment->id, 'build_pack' => 'dockercompose']); + $otherApp = Application::factory()->create(['environment_id' => $environment->id, 'build_pack' => 'nixpacks']); + $service = Service::factory()->create(['environment_id' => $environment->id]); + $migration = require database_path('migrations/2026_10_11_183411_add_preserve_compose_null_to_environment_variables.php'); + $migration->down(); + foreach ([$composeApp, $otherApp, $service] as $resource) { + foreach (['NULL_VALUE' => null, 'EMPTY_VALUE' => '', 'SET_VALUE' => 'configured'] as $key => $value) { + $resource->environment_variables()->create(['key' => $key, 'value' => $value]); + } + } + $before = EnvironmentVariable::query()->get()->mapWithKeys(fn ($env) => [$env->id => [$env->getRawOriginal('value'), $env->getRawOriginal('updated_at')]]); + + $migration->up(); + + foreach (EnvironmentVariable::query()->get() as $env) { + $expected = $env->key === 'NULL_VALUE' && ($env->resourceable_type === Service::class || $env->resourceable_id === $composeApp->id); + expect($env->preserve_compose_null)->toBe($expected) + ->and([$env->getRawOriginal('value'), $env->getRawOriginal('updated_at')])->toBe($before[$env->id]); + } + $newEnv = $service->environment_variables()->create(['key' => 'NEW_NULL', 'value' => null]); + expect($newEnv->fresh()->preserve_compose_null)->toBeFalse(); + + $migration->down(); + expect(Schema::hasColumn('environment_variables', 'preserve_compose_null'))->toBeFalse(); + $migration->up(); +}); diff --git a/tests/Unit/ExtractHardcodedEnvironmentVariablesTest.php b/tests/Unit/ExtractHardcodedEnvironmentVariablesTest.php index 8d8caacaf8..b3bad67f9b 100644 --- a/tests/Unit/ExtractHardcodedEnvironmentVariablesTest.php +++ b/tests/Unit/ExtractHardcodedEnvironmentVariablesTest.php @@ -93,6 +93,70 @@ YAML; ->and($result[1]['value'])->toBe('false'); }); +test('distinguishes passthrough variables from explicit empty values', function () { + $yaml = <<<'YAML' +services: + list-app: + environment: + - PASSTHROUGH + - EMPTY= + map-app: + environment: + MAPPED_PASSTHROUGH: + MAPPED_EMPTY: "" +YAML; + + $result = extractHardcodedEnvironmentVariables($yaml)->keyBy('key'); + + expect($result['PASSTHROUGH']['is_passthrough'])->toBeTrue() + ->and($result['EMPTY']['is_passthrough'])->toBeFalse() + ->and($result['MAPPED_PASSTHROUGH']['is_passthrough'])->toBeTrue() + ->and($result['MAPPED_EMPTY']['is_passthrough'])->toBeFalse(); +}); + +test('detects Compose inputs that must remain unset when blank', function () { + $yaml = <<<'YAML' +services: + app: + environment: + BARE: + DASH: ${DASH-default} + QUESTION: ${QUESTION?required} + COLON_DASH: ${COLON_DASH:-default} + ESCAPED: $${ESCAPED-nope} +YAML; + + expect(dockerComposeEnvironmentVariableShouldBeUnset($yaml, 'BARE', null))->toBeTrue() + ->and(dockerComposeEnvironmentVariableShouldBeUnset($yaml, 'DASH', null))->toBeTrue() + ->and(dockerComposeEnvironmentVariableShouldBeUnset($yaml, 'QUESTION', null))->toBeTrue() + ->and(dockerComposeEnvironmentVariableShouldBeUnset($yaml, 'COLON_DASH', null))->toBeTrue() + ->and(dockerComposeEnvironmentVariableShouldBeUnset($yaml, 'ESCAPED', null))->toBeFalse() + ->and(dockerComposeEnvironmentVariableShouldBeUnset($yaml, 'DASH', ''))->toBeFalse() + ->and(dockerComposeEnvironmentVariableShouldBeUnset($yaml, 'COLON_DASH', 'debug'))->toBeFalse(); +}); + +test('extracts fallback and required metadata from derived Compose values', function () { + $compose = <<<'YAML' +services: + app: + environment: + URL: https://${HOST:-${INNER_HOST:-localhost}/api} + PATH: ${TOKEN?TOKEN is required}/v1 +YAML; + + expect(dockerComposeEnvironmentVariableInfo($compose, 'HOST'))->toMatchArray([ + 'services' => ['app'], + 'default' => '${INNER_HOST:-localhost}/api', + 'operator' => ':-', + 'required' => false, + ])->and(dockerComposeEnvironmentVariableInfo($compose, 'TOKEN'))->toMatchArray([ + 'services' => ['app'], + 'default' => null, + 'operator' => '?', + 'required' => true, + ]); +}); + test('returns empty collection for malformed YAML', function () { $yaml = 'invalid: yaml: content::: [[[';