fix(service): store the Compose default for bare variable references

Closes #12412
This commit is contained in:
Andras Bacsai
2026-11-02 14:55:44 +01:00
parent 5cf1abc0fc
commit 6977caaa9b
3 changed files with 98 additions and 0 deletions
+2
View File
@@ -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;
+57
View File
@@ -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<int, string> $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('/(?<!\$)\$(?:'.$quotedKey.'(?![A-Za-z0-9_])|\{'.$quotedKey.'\})/', $dockerCompose) !== 1) {
return null;
}
$defaults = [];
$offset = 0;
while (($referenceStart = strpos($dockerCompose, '${'.$key, $offset)) !== false) {
$offset = $referenceStart + 2;
if ($referenceStart > 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)) {
@@ -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,