fix(env): keep pre-upgrade values for legacy compose variables

Closes #12408

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras BacsaiandClaude Opus 5.5 committed 2026-11-02 14:49:11 +01:00
1 parent bc855fdc68
commit 5cf1abc0fc
5 files changed
+177 -5

No files matched your search

+5 -1
View File
@@ -291,7 +291,11 @@ class EnvironmentVariable extends BaseModel
$value = $this->get_real_environment_variables_internal($this->value, $resource, null, $revealLockedSharedVariables, $sharedVariables);
return $value === null || ! $escapeLegacyValue || ! $this->uses_legacy_escaping ? $value : $this->legacyEscapedValue($value);
if ($value === null || ! $escapeLegacyValue || ! $this->uses_legacy_escaping) {
return $value;
}
return method_exists($resource, 'useExactEscaping') && $resource->useExactEscaping($this) ? $value : $this->legacyEscapedValue($value);
}
/**
+40 -4
View File
@@ -146,11 +146,46 @@ trait HasSecretManager
* Decides the escaping of a variable for all paths (service .env, database environment,
* Redis password). A variable saved before exact escaping keeps the old escaping, so its
* container gets the same value as before the upgrade. Saving the variable again switches
* it to exact escaping.
* it to exact escaping. Compose references that got the stored value before the upgrade
* (such as `FOO=${FOO:-}`) use exact escaping, so they keep getting that value. So do old
* .env lines that Docker Compose cannot read, because they stop the deployment.
*/
public function useExactEscaping(EnvironmentVariable $environmentVariable): bool
{
return ! $environmentVariable->uses_legacy_escaping;
if (! $environmentVariable->uses_legacy_escaping) {
return true;
}
if (! $this instanceof Service && ! $this instanceof Application) {
return false;
}
if ($this->legacyEnvFileLineIsUnreadable($environmentVariable)) {
return true;
}
$parsesCompose = $this instanceof Service
|| ($this->build_pack === 'dockercompose' && ! $this->settings?->is_raw_compose_deployment_enabled);
return $parsesCompose
&& dockerComposeVariablesWithStoredValueBeforeExactEscaping($this->docker_compose_raw)->contains($environmentVariable->key);
}
/**
* The old escaping puts literal and multiline values in single quotes without escaping. Docker
* Compose ends the value at the next quote, so a value with a quote breaks the whole .env file.
*/
private function legacyEnvFileLineIsUnreadable(EnvironmentVariable $environmentVariable): bool
{
if (! $environmentVariable->is_literal && ! $environmentVariable->is_multiline) {
return false;
}
$value = $this->resolvedEnvironmentVariableValue($environmentVariable);
return is_string($value)
&& str_contains($value, "'")
&& ! (json_validate($value) && (str_starts_with($value, '{') || str_starts_with($value, '[')));
}
/**
@@ -210,8 +245,9 @@ trait HasSecretManager
}
if ($environmentVariable->is_literal || $environmentVariable->is_multiline) {
// Service and application .env files strip the old single quotes; database environment lists keep them.
return ($this instanceof Service || $this instanceof Application) ? str_contains($value, "'") : true;
// Service and application .env files strip the old single quotes (values with a quote use exact
// escaping); database environment lists keep them.
return ! ($this instanceof Service || $this instanceof Application);
}
return escapeEnvVariables($value) !== $value
+48
View File
@@ -5605,6 +5605,54 @@ function dockerComposeEnvironmentVariableShouldBeUnset(?string $dockerCompose, s
return extractDockerComposeEnvironmentVariableReferences($dockerCompose)->contains($key);
}
/**
* Variables whose stored value the Compose parser before v4.5.0 wrote straight into the `environment:`
* list, so their containers got the value without the old .env escaping: references with a default or
* under another name (`FOO=${FOO:-}`, `BAR=${FOO}`, `BAR=$FOO`) and references in such a default.
* A plain `FOO=${FOO}` reference kept reading the escaped .env value.
*
* @return Collection<int, string>
*/
function dockerComposeVariablesWithStoredValueBeforeExactEscaping(?string $dockerCompose): Collection
{
static $cache = [];
if (blank($dockerCompose)) {
return collect();
}
$cacheKey = hash('xxh128', $dockerCompose);
if (isset($cache[$cacheKey])) {
return $cache[$cacheKey];
}
$keys = collect();
foreach (extractHardcodedEnvironmentVariables($dockerCompose) as $assignment) {
$value = $assignment['value'] ?? null;
if (! is_string($value) || ! str_starts_with($value, '$') || replaceVariables($value)->value() === (string) $assignment['key']) {
continue;
}
$reference = extractBalancedBraceContent($value, 0);
if ($reference === null) {
if (preg_match('/\A\$([A-Za-z_][A-Za-z0-9_]*)\z/', $value, $match) === 1) {
$keys->push($match[1]);
}
continue;
}
$split = splitOnOperatorOutsideNested($reference['content']);
$keys->push($split === null ? $reference['content'] : $split['variable']);
if ($split !== null) {
$keys = $keys->concat(extractDockerComposeEnvironmentVariableReferences($split['default']));
}
}
$cache = [$cacheKey => $keys->reject(fn (string $key): bool => str_starts_with($key, 'SERVICE_'))->unique()->values()];
return $cache[$cacheKey];
}
/** @param Collection<int, EnvironmentVariable> $environmentVariables */
function preserveLegacyDockerComposeNullAssignments(Collection $environment, Collection $environmentVariables): Collection
{
@@ -1842,6 +1842,56 @@ it('keeps the old runtime env format for variables saved before exact escaping',
->toContain('EXACT_PLAIN='.escapeComposeEnvFileValue('a\'b"c\\d', allowInterpolation: true));
});
it('gives legacy compose variables the value that the compose file got before exact escaping', function (bool $rawCompose, string $expectedDefault) {
$compose = <<<'YAML'
services:
app:
image: alpine
environment:
- TRUSTED_HOST_PATTERNS=${TRUSTED_HOST_PATTERNS:-}
- PLAIN_REFERENCE=${PLAIN_REFERENCE}
YAML;
[$application, $server] = makeDeploymentControlVarFixture([
'build_pack' => 'dockercompose',
'docker_compose_raw' => $compose,
'docker_compose' => $compose,
]);
$application->settings()->update(['is_raw_compose_deployment_enabled' => $rawCompose]);
foreach (['TRUSTED_HOST_PATTERNS', 'PLAIN_REFERENCE'] as $key) {
$variable = createApplicationEnvironmentVariable($application, ['key' => $key, 'value' => '^example\\.com$', 'is_runtime' => true, 'is_buildtime' => false]);
$variable->uses_legacy_escaping = true;
$variable->save();
}
[$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server);
/** @var Collection $runtimeEnvs */
$runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables');
expect($runtimeEnvs)
->toContain('TRUSTED_HOST_PATTERNS='.$expectedDefault)
->toContain('PLAIN_REFERENCE=^example\\\\.com$');
})->with([
'parsed compose' => [false, '"^example\\\\.com$"'],
'raw compose deployment' => [true, '^example\\\\.com$'],
]);
it('writes a readable runtime env line for legacy literal values with a single quote', function () {
[$application, $server] = makeDeploymentControlVarFixture();
$variable = createApplicationEnvironmentVariable($application, ['key' => 'LEGACY_QUOTE', 'value' => "it's", 'is_literal' => true, 'is_runtime' => true, 'is_buildtime' => false]);
$variable->uses_legacy_escaping = true;
$variable->save();
[$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server);
/** @var Collection $runtimeEnvs */
$runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables');
expect($runtimeEnvs)->toContain('LEGACY_QUOTE="it\'s"');
});
it('keeps the old runtime env format for preview variables saved before exact escaping', function () {
[$application, $server] = makeDeploymentControlVarFixture();
@@ -95,6 +95,40 @@ test('legacy service variables keep the old env file format', function () {
->and($this->service->composeEnvironmentFileLine($empty))->toBe('EMPTY=');
});
test('legacy service variables keep the value that the compose file got before exact escaping', function () {
$this->service->update(['docker_compose_raw' => <<<'YAML'
services:
app:
image: alpine
environment:
- WITH_DEFAULT=${WITH_DEFAULT:-x}
- OTHER_NAME=${RENAMED}
- NESTED=${NESTED:-${NESTED_DEFAULT}}
- PLAIN=${PLAIN}
YAML]);
$variables = collect(['WITH_DEFAULT', 'RENAMED', 'NESTED_DEFAULT', 'PLAIN'])
->mapWithKeys(fn (string $key) => [$key => legacyVariable($this->service, ['key' => $key, 'value' => 'a\\.b'])]);
expect($this->service->composeEnvironmentFileLine($variables['WITH_DEFAULT']))->toBe('WITH_DEFAULT="a\\\\.b"')
->and($this->service->composeEnvironmentFileLine($variables['RENAMED']))->toBe('RENAMED="a\\\\.b"')
->and($this->service->composeEnvironmentFileLine($variables['NESTED_DEFAULT']))->toBe('NESTED_DEFAULT="a\\\\.b"')
->and($this->service->composeEnvironmentFileLine($variables['PLAIN']))->toBe('PLAIN=a\\\\.b')
->and($this->service->legacyEscapingChangesValue($variables['WITH_DEFAULT']))->toBeFalse()
->and($this->service->legacyEscapingChangesValue($variables['PLAIN']))->toBeTrue()
->and($variables['WITH_DEFAULT']->fresh()->real_value)->toBe('a\\.b');
});
test('legacy literal and multiline variables with a single quote get a line that docker compose can read', function () {
$literal = legacyVariable($this->service, ['key' => 'LITERAL_QUOTE', 'value' => "it's", 'is_literal' => true]);
$multiline = legacyVariable($this->service, ['key' => 'MULTILINE_QUOTE', 'value' => "it's\n\"two\"", 'is_multiline' => true]);
$withoutQuote = legacyVariable($this->service, ['key' => 'LITERAL_PLAIN', 'value' => 'x$y', 'is_literal' => true]);
expect($this->service->composeEnvironmentFileLine($literal))->toBe('LITERAL_QUOTE="it\'s"')
->and($this->service->composeEnvironmentFileLine($multiline))->toBe("MULTILINE_QUOTE=\"it's\n\\\"two\\\"\"")
->and($this->service->composeEnvironmentFileLine($withoutQuote))->toBe("LITERAL_PLAIN='x\$y'")
->and($this->service->legacyEscapingChangesValue($literal))->toBeFalse();
});
test('legacy database variables keep the old compose environment format', function () {
$database = StandalonePostgresql::forceCreate([
'uuid' => 'postgres-legacy-test',