From 6977caaa9b4f62d58c6f76d6227cc14753c794af Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:55:38 +0100 Subject: [PATCH] fix(service): store the Compose default for bare variable references Closes #12412 --- bootstrap/helpers/parsers.php | 2 + bootstrap/helpers/shared.php | 57 +++++++++++++++++++ .../Feature/EnvironmentVariableSearchTest.php | 39 +++++++++++++ 3 files changed, 98 insertions(+) diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 7aa5ef279e..b723993b67 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -2422,6 +2422,7 @@ function serviceParser(Service $resource): Collection ->orWhere('key', 'LIKE', '%}'); }) ->delete(); + $existingEnvironmentVariableKeys = $resource->environment_variables()->pluck('key'); $topLevel = collect([ 'volumes' => collect(data_get($yaml, 'volumes', [])), @@ -3671,6 +3672,7 @@ function serviceParser(Service $resource): Collection data_forget($resource, 'environment_variables_preview'); $resource->save(); + fillDockerComposeDefaultsForBareReferences($resource, $existingEnvironmentVariableKeys); syncDockerComposeEnvironmentVariableRequiredState($resource); return $topLevel; diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index e852ec50f0..420f4322fe 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -5580,6 +5580,63 @@ function dockerComposeEnvironmentVariableInfo(?string $dockerCompose, string $ke ]; } +/** + * Docker Compose gives a bare $VAR or ${VAR} reference no default, and the service parser does not store + * defaults. So `/$POSTGRES_DB` next to `${POSTGRES_DB:-umami}` in another service resolves to an empty value. + * For new variables, store the default when the Compose file has a bare reference and exactly one default. + * + * @param Collection $existingKeys + */ +function fillDockerComposeDefaultsForBareReferences(Service $resource, Collection $existingKeys): void +{ + $dockerCompose = $resource->docker_compose_raw; + if (blank($dockerCompose)) { + return; + } + + $resource->environment_variables() + ->whereNull('value') + ->whereNotIn('key', $existingKeys) + ->get() + ->each(function (EnvironmentVariable $environmentVariable) use ($dockerCompose): void { + $default = dockerComposeBareReferenceDefault($dockerCompose, $environmentVariable->key); + // A nested default (`${A:-${B}}`) must stay with Compose: stored, it would be escaped to a literal. + if (filled($default) && ! str_contains($default, '$')) { + $environmentVariable->update(['value' => $default]); + } + }); +} + +/** The single default of a variable that the Compose file also references without a default, otherwise null. */ +function dockerComposeBareReferenceDefault(string $dockerCompose, string $key): ?string +{ + $quotedKey = preg_quote($key, '/'); + if (preg_match('/(? 0 && $dockerCompose[$referenceStart - 1] === '$') { + continue; + } + + $reference = extractBalancedBraceContent(substr($dockerCompose, $referenceStart)); + $split = $reference === null ? null : splitOnOperatorOutsideNested($reference['content']); + if ($split === null || $split['variable'] !== $key) { + continue; + } + if (str_contains($split['operator'], '?')) { + return null; + } + $defaults[$split['default']] = true; + } + + return count($defaults) === 1 ? (string) array_key_first($defaults) : null; +} + function dockerComposeEnvironmentVariableIsRequired(?string $dockerCompose, string $key): bool { if (blank($dockerCompose)) { diff --git a/tests/Feature/EnvironmentVariableSearchTest.php b/tests/Feature/EnvironmentVariableSearchTest.php index 67c713cad1..9b522e060c 100644 --- a/tests/Feature/EnvironmentVariableSearchTest.php +++ b/tests/Feature/EnvironmentVariableSearchTest.php @@ -396,6 +396,45 @@ YAML; ->and(Yaml::parse($service->fresh()->docker_compose_raw))->toBe(Yaml::parse($dockerCompose)); }); +it('stores the Compose default for a variable that another service references without one', function (string $dockerCompose) { + $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', 'POSTGRES_DB')->value('value'))->toBe('umami'); +})->with([ + 'reference before default' => "services:\n app:\n image: nginx\n environment:\n DATABASE_URL: postgres://user:pass@postgresql:5432/\$POSTGRES_DB\n postgresql:\n image: postgres:16-alpine\n environment:\n POSTGRES_DB: \${POSTGRES_DB:-umami}\n", + 'default before reference' => "services:\n postgresql:\n image: postgres:16-alpine\n environment:\n POSTGRES_DB: \${POSTGRES_DB:-umami}\n app:\n image: nginx\n environment:\n DATABASE_URL: postgres://user:pass@postgresql:5432/\${POSTGRES_DB}\n", +]); + +it('does not store a nested Compose default for a bare reference', 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 DB: \${DB:-\${APP_NAME}}\n URL: postgres://db/\$DB\n", + ]); + + $service->parse(); + + expect($service->environment_variables()->where('key', 'DB')->firstOrFail()->value)->toBeNull(); +}); + +it('does not store a Compose default when the defaults for a bare reference conflict', 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 LEVEL: \${LEVEL:-info}\n LOG: level=\$LEVEL\n worker:\n image: alpine\n environment:\n LEVEL: \${LEVEL:-warn}\n", + ]); + + $service->parse(); + + expect($service->environment_variables()->where('key', 'LEVEL')->firstOrFail()->value)->toBeNull(); +}); + it('keeps editable and literal rows when services use the same key differently', function () { $service = Service::factory()->create([ 'environment_id' => $this->environment->id,