fix(env): pass exact env values to containers, keep old escaping for existing variables (#12024)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-10-12 13:06:45 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 9893a5f503
commit 213a61b7ab
24 changed files with 1660 additions and 180 deletions
+1
View File
@@ -20,6 +20,7 @@
- Do not use a passing unit test, a successful build, or a healthy process as proof for a reported UI failure.
- Verify the exact live flow, persisted state, relevant logs, and queue state when they affect the result.
- When the request covers more than one interface or resource type, inventory and verify each supported path.
- When a change alters how stored data reaches running containers, simulate the upgrade on the dev VM: start resources on the old code, then migrate and restart them on the new code, and compare the results.
## Preserve product scope
- Do not replace required SPA navigation with a full-page redirect to hide a lifecycle or ordering defect.
+108 -85
View File
@@ -216,8 +216,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private bool $dockerSecretsSupported = false;
private bool $dockerSecretsAvailable = false;
private bool $useBuildtimeEnvironmentLauncher = false;
private bool $skip_build = false;
@@ -506,7 +504,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->dockerBuildkitSupported = false;
$this->dockerBuildxAvailable = false;
$this->dockerSecretsSupported = false;
$this->dockerSecretsAvailable = false;
$serverToCheck = $this->use_build_server ? $this->build_server : $this->server;
$serverName = $this->use_build_server ? "build server ({$serverToCheck->name})" : "deployment server ({$serverToCheck->name})";
@@ -558,19 +555,16 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
}
if ($this->dockerBuildkitSupported) {
if ($this->application->settings->use_build_secrets && $this->dockerBuildkitSupported) {
$secretsTest = instant_remote_process(
["docker build --help 2>&1 | grep -q 'secret' && echo 'supported' || echo 'not-supported'"],
$serverToCheck
);
if (trim($secretsTest) === 'supported') {
$this->dockerSecretsAvailable = true;
if ($this->application->settings->use_build_secrets) {
$this->dockerSecretsSupported = true;
$this->application_deployment_queue->addLogEntry('Build secrets are enabled and will be used for enhanced security.');
}
} elseif ($this->application->settings->use_build_secrets) {
$this->dockerSecretsSupported = true;
$this->application_deployment_queue->addLogEntry('Build secrets are enabled and will be used for enhanced security.');
} else {
$this->application_deployment_queue->addLogEntry("Docker on {$serverName} does not support build secrets. Using traditional build arguments.");
}
}
@@ -578,7 +572,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->dockerBuildkitSupported = false;
$this->dockerBuildxAvailable = false;
$this->dockerSecretsSupported = false;
$this->dockerSecretsAvailable = false;
$this->application_deployment_queue->addLogEntry("Could not detect BuildKit capabilities on {$serverName}: {$e->getMessage()}");
}
}
@@ -1763,7 +1756,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
continue;
}
$envs->push($env->key.'='.$this->resolve_environment_variable($env));
$envs->push($this->runtimeEnvironmentAssignment($env));
}
// Check for PORT environment variable mismatch with ports_exposes
@@ -1834,7 +1827,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
continue;
}
$envs->push($env->key.'='.$this->resolve_environment_variable($env));
$envs->push($this->runtimeEnvironmentAssignment($env));
}
// Fall back to production env vars for keys not overridden by preview vars,
@@ -1852,7 +1845,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
continue;
}
$envs->push($env->key.'='.$this->resolve_environment_variable($env));
$envs->push($this->runtimeEnvironmentAssignment($env));
}
}
@@ -1872,6 +1865,32 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
return $envs;
}
/**
* A KEY="value" line for the runtime .env file, which Docker Compose reads. Variables saved
* before exact escaping keep the old line, so their containers get the same value as before.
*/
private function runtimeEnvironmentAssignment(EnvironmentVariable $environmentVariable): string
{
if (! $this->application->useExactEscaping($environmentVariable)) {
return $environmentVariable->key.'='.$this->resolve_environment_variable($environmentVariable);
}
$resolvedValue = (string) $environmentVariable->get_real_environment_variables_with_server(
$environmentVariable->value,
$this->application,
$this->mainServer,
);
if (RemoteSecretReferences::containsReference($environmentVariable->value) || RemoteSecretReferences::containsReference($resolvedValue)) {
return $environmentVariable->key.'='.escapeComposeEnvFileValue($this->resolve_environment_variable_raw($environmentVariable));
}
return $environmentVariable->key.'='.escapeComposeEnvFileValue(
$resolvedValue,
allowInterpolation: $this->application->environmentVariableAllowsInterpolation($environmentVariable, $resolvedValue),
);
}
private function isGeneratedDockerComposeEnvironmentVariable(EnvironmentVariable $environmentVariable): bool
{
$key = str($environmentVariable->key);
@@ -2121,6 +2140,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
->where('is_buildtime', true) // ONLY build-time variables
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
->get();
$sorted_environment_variables = $this->withoutLegacyEnvironmentVariableKeys($sorted_environment_variables);
// For Docker Compose, filter out generated SERVICE_* variables as we generate these
if ($this->build_pack === 'dockercompose') {
@@ -2183,6 +2203,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
->where('is_buildtime', true) // ONLY build-time variables
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
->get();
$sorted_environment_variables = $this->withoutLegacyEnvironmentVariableKeys($sorted_environment_variables);
// For Docker Compose, filter out generated SERVICE_* variables as we generate these with PR-specific values
if ($this->build_pack === 'dockercompose') {
@@ -2343,23 +2364,22 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$environmentVariables = $this->pull_request_id === 0
? $this->application->environment_variables()->get(['key', 'is_buildtime', 'is_runtime'])
: $this->application->environment_variables_preview()->get(['key', 'is_buildtime', 'is_runtime']);
$validatesBuildtimeKeys = $buildsImage && $this->deploymentPassesBuildtimeVariables();
$passesBuildtimeVariables = $this->deploymentPassesBuildtimeVariables();
foreach ($environmentVariables as $environmentVariable) {
$key = (string) $environmentVariable->key;
$isEnvFileSafe = $key !== '' && strpbrk($key, "=\n\r\0") === false;
if (($environmentVariable->is_buildtime && $validatesBuildtimeKeys) || ! $isEnvFileSafe) {
$this->validatedBuildtimeEnvironmentVariableKey($key, 'the deployment environment');
if (ValidationPatterns::isValidEnvironmentVariableKey($key)) {
continue;
}
continue;
}
// The deployment start already logged the warnings.
if ($buildsImage) {
continue;
}
if (! ValidationPatterns::isValidEnvironmentVariableKey($key)) {
$this->logLegacyEnvironmentVariableKey($key, (bool) $environmentVariable->is_buildtime, (bool) $environmentVariable->is_runtime);
$isEnvFileSafe = $key !== '' && strpbrk($key, "=\n\r\0") === false;
$isLegacyBuildtimeKey = preg_match('/\A[A-Za-z0-9_.-]+\z/', $key) === 1;
$isPassedToBuild = $environmentVariable->is_buildtime && $passesBuildtimeVariables && $buildsImage;
if (! $isEnvFileSafe || ($isPassedToBuild && ! $isLegacyBuildtimeKey)) {
$this->validatedBuildtimeEnvironmentVariableKey($key, 'the deployment environment');
}
$this->logLegacyEnvironmentVariableKey($key, (bool) $environmentVariable->is_buildtime, (bool) $environmentVariable->is_runtime, $passesBuildtimeVariables);
}
}
@@ -2372,7 +2392,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
return $this->application->build_pack !== 'dockerimage';
}
private function logLegacyEnvironmentVariableKey(string $key, bool $isBuildtime, bool $isRuntime): void
private function logLegacyEnvironmentVariableKey(string $key, bool $isBuildtime, bool $isRuntime, bool $passesBuildtimeVariables): void
{
$suggestedKey = (string) preg_replace('/[^A-Za-z0-9_]/', '_', $key);
if (preg_match('/\A[0-9]/', $suggestedKey) === 1) {
@@ -2381,18 +2401,33 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$displayKey = ValidationPatterns::displayShellEnvironmentVariableKey($key);
if ($isBuildtime) {
$this->application_deployment_queue->addLogEntry("⚠️ Build-time variable {$displayKey} uses a name that new variables cannot use. It is not used as a build-time variable unless the deployment builds an image, and that build fails until you rename it.", 'stderr');
}
if ($isRuntime) {
$this->application_deployment_queue->addLogEntry("⚠️ Runtime variable {$displayKey} uses a name that new variables cannot use. It is still passed to the container, but shell scripts cannot read it.", 'stderr');
}
if (! $isBuildtime && ! $isRuntime) {
$this->application_deployment_queue->addLogEntry("⚠️ Variable {$displayKey} uses a name that new variables cannot use. This deployment does not use it.", 'stderr');
if ($isBuildtime && $passesBuildtimeVariables) {
$this->application_deployment_queue->addLogEntry("⚠️ Build-time variable {$displayKey} uses a name that new variables cannot use. It is skipped during the build".($isRuntime ? ', but is still passed to the container.' : '.'), 'stderr');
} else {
if ($isBuildtime) {
$this->application_deployment_queue->addLogEntry("⚠️ Build-time variable {$displayKey} uses a name that new variables cannot use. This deployment does not build an image, so it is not used as a build-time variable. Rename it before you use it in a build.", 'stderr');
}
if ($isRuntime) {
$this->application_deployment_queue->addLogEntry("⚠️ Runtime variable {$displayKey} uses a name that new variables cannot use. It is still passed to the container, but shell scripts cannot read it.", 'stderr');
}
if (! $isBuildtime && ! $isRuntime) {
$this->application_deployment_queue->addLogEntry("⚠️ Variable {$displayKey} uses a name that new variables cannot use. This deployment does not use it.", 'stderr');
}
}
$this->application_deployment_queue->addLogEntry(' Suggested name: '.ValidationPatterns::displayShellEnvironmentVariableKey($suggestedKey), type: 'info');
}
/**
* Skip build-time variables with names that new variables can no longer use (such as my-var).
* validateDeploymentEnvironmentVariableKeys() logs a warning for them before the deployment starts.
*/
private function withoutLegacyEnvironmentVariableKeys(Collection $environmentVariables): Collection
{
return $environmentVariables->filter(
fn (EnvironmentVariable $environmentVariable): bool => ValidationPatterns::isValidEnvironmentVariableKey((string) $environmentVariable->key)
);
}
private function logInvalidBuildtimeEnvironmentVariableKey(string $key, string $origin): void
{
$displayKey = ValidationPatterns::displayShellEnvironmentVariableKey($key);
@@ -2428,6 +2463,33 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->application_deployment_queue->addLogEntry(" Suggested name: {$displaySuggestedKey}", type: 'info');
}
/**
* Remove variables with dotted names from a Nixpacks or Railpack build.
*
* Nixpacks and Railpack cut these names at the first dot, and their Debian-based
* images run build commands through dash, which drops dotted names from the
* environment. The variables are still available at runtime.
*/
private function withoutDottedBuildVariables(Collection $variables, string $builder): Collection
{
$dottedKeys = $variables->keys()
->map(fn ($key): string => (string) $key)
->filter(fn (string $key): bool => str_contains($key, '.'))
->values();
if ($dottedKeys->isEmpty()) {
return $variables;
}
foreach ($dottedKeys as $key) {
$this->application_deployment_queue->addLogEntry('⚠️ Build-time variable '.ValidationPatterns::displayShellEnvironmentVariableKey($key)." has a dot in its name, which {$builder} cannot pass to the build. It is skipped during the build, but is still passed to the container.", 'stderr');
$this->logSuggestedShellEnvironmentVariableKey($key);
}
$this->application_deployment_queue->addLogEntry(' If the build needs the value, rename the variable. Otherwise, disable "Available at Buildtime" for it.', type: 'info');
return $variables->reject(fn ($value, $key): bool => str_contains((string) $key, '.'));
}
private function save_buildtime_environment_variables()
{
$environment_variables = $this->generate_buildtime_environment_variables();
@@ -3218,7 +3280,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
// Do any modifications here
// We need to generate envs here because nixpacks need to know to generate a proper Dockerfile
$this->generate_env_variables();
$merged_envs = collect(data_get($parsed, 'variables', []))->merge($this->env_args);
$merged_envs = $this->withoutDottedBuildVariables(
collect(data_get($parsed, 'variables', []))->merge($this->env_args),
'Nixpacks',
);
$aptPkgs = data_get($parsed, 'phases.setup.aptPkgs', []);
if (count($aptPkgs) === 0) {
$aptPkgs = ['curl', 'wget'];
@@ -3388,8 +3453,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
? $this->application->railpack_environment_variables()->get()
: $this->application->railpack_environment_variables_preview()->get();
$variables = $genericBuildVariables
->merge($railpackVariables)
$variables = $this->withoutLegacyEnvironmentVariableKeys($genericBuildVariables->merge($railpackVariables))
->mapWithKeys(function (EnvironmentVariable $environmentVariable) {
$value = $this->normalize_resolved_build_variable_value($environmentVariable);
if (is_null($value) || $value === '') {
@@ -3398,6 +3462,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
return [$environmentVariable->key => $value];
});
$variables = $this->withoutDottedBuildVariables($variables, 'Railpack');
if ($this->application->install_command) {
$variables->put('RAILPACK_INSTALL_CMD', $this->application->install_command);
@@ -3899,6 +3964,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
->withoutBuildpackControlVariables()
->where('is_buildtime', true)
->get();
$envs = $this->withoutLegacyEnvironmentVariableKeys($envs);
if ($this->build_pack === 'dockercompose') {
$envs = $envs->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
@@ -3921,6 +3987,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
->withoutBuildpackControlVariables()
->where('is_buildtime', true)
->get();
$envs = $this->withoutLegacyEnvironmentVariableKeys($envs);
if ($this->build_pack === 'dockercompose') {
$envs = $envs->reject(fn (EnvironmentVariable $env) => $this->isGeneratedDockerComposeEnvironmentVariable($env));
@@ -4930,21 +4997,6 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$this->analyzeBuildTimeVariables($variables);
}
$requiresDottedEnvironmentSecrets = $this->application->build_pack === 'nixpacks'
&& $variables->keys()->contains(fn ($key): bool => str_contains((string) $key, '.'));
if ($requiresDottedEnvironmentSecrets) {
if (! $this->dockerSecretsAvailable) {
$dottedKeys = $variables->keys()
->filter(fn ($key): bool => str_contains((string) $key, '.'))
->implode(', ');
throw new DeploymentException("Dotted Nixpacks build-time environment variable names require Docker BuildKit secret support: {$dottedKeys}. Rename these keys to use underscores instead of dots, or upgrade Docker on the build server.");
}
$this->dockerSecretsSupported = true;
}
if ($this->dockerSecretsSupported) {
$this->generate_build_secrets($variables);
$this->build_args = '';
@@ -5129,6 +5181,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
->withoutBuildpackControlVariables()
->where('is_buildtime', true)
->get();
$envs = $this->withoutLegacyEnvironmentVariableKeys($envs);
foreach ($envs as $env) {
$key = $this->validatedBuildtimeEnvironmentVariableKey((string) $env->key, 'the generated Dockerfile');
$argsToInsert->push("ARG {$key}");
@@ -5148,6 +5201,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
->withoutBuildpackControlVariables()
->where('is_buildtime', true)
->get();
$envs = $this->withoutLegacyEnvironmentVariableKeys($envs);
foreach ($envs as $env) {
$key = $this->validatedBuildtimeEnvironmentVariableKey((string) $env->key, 'the generated Dockerfile');
$argsToInsert->push("ARG {$key}");
@@ -5238,37 +5292,6 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
return;
}
$dottedKeys = $variables->keys()
->map(fn ($key): string => (string) $key)
->filter(fn (string $key): bool => str_contains($key, '.'));
if ($dottedKeys->isNotEmpty()) {
$originalDockerfile = $dockerfile;
$dockerfile = $dockerfile->map(function (string $line) use ($dottedKeys): ?string {
$trimmedLine = trim($line);
if (! str_starts_with($trimmedLine, 'ARG ') && ! str_starts_with($trimmedLine, 'ENV ')) {
return $line;
}
[$instruction, $arguments] = explode(' ', $trimmedLine, 2);
$filteredArguments = collect(preg_split('/\s+/', $arguments))
->reject(function (string $argument) use ($dottedKeys): bool {
$key = str($argument)->before('=')->toString();
return $dottedKeys->contains($key);
});
if ($filteredArguments->isEmpty()) {
return null;
}
return $instruction.' '.$filteredArguments->implode(' ');
})->filter()->values();
$modified = $dockerfile->all() !== $originalDockerfile->values()->all();
}
// Generate mount strings for all secrets
$mountStrings = $variables->map(function ($value, $key) {
$key = $this->validatedBuildtimeEnvironmentVariableKey((string) $key, 'the generated Dockerfile');
@@ -5279,7 +5302,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
// Add mount for the secrets hash to ensure cache invalidation
$mountStrings .= ' --mount=type=secret,id=COOLIFY_BUILD_SECRETS_HASH,env=COOLIFY_BUILD_SECRETS_HASH';
$modified ??= false;
$modified = false;
$dockerfile = $dockerfile->map(function ($line) use ($mountStrings, &$modified) {
$trimmed = ltrim($line);
@@ -95,6 +95,11 @@ class Show extends Component
public array $problematicVariables = [];
/**
* The variable was saved before exact escaping, and the container gets a different value.
*/
public bool $legacyEscapingChangesValue = false;
protected $listeners = [
'refresh',
'compose_loaded' => '$refresh',
@@ -171,6 +176,10 @@ class Show extends Component
}
$this->hydrateValueFields();
$this->legacyEscapingChangesValue = $this->env instanceof ModelsEnvironmentVariable
&& is_object($this->env->resourceable)
&& method_exists($this->env->resourceable, 'legacyEscapingChangesValue')
&& $this->env->resourceable->legacyEscapingChangesValue($this->env);
$this->valuesLoaded = true;
}
+27 -14
View File
@@ -78,6 +78,7 @@ class EnvironmentVariable extends BaseModel
'is_preview' => 'boolean',
'is_runtime' => 'boolean',
'is_buildtime' => 'boolean',
'uses_legacy_escaping' => 'boolean',
'version' => 'string',
'resourceable_type' => 'string',
'resourceable_id' => 'integer',
@@ -93,6 +94,8 @@ class EnvironmentVariable extends BaseModel
protected $hidden = [
'value',
'real_value',
// Internal: decides the escaping of variables saved before exact escaping.
'uses_legacy_escaping',
];
protected static function booted()
@@ -192,6 +195,10 @@ class EnvironmentVariable extends BaseModel
return $this->resourceable;
}
/**
* The value that the container gets: the old escaped form for variables saved before exact
* escaping, otherwise the resolved value without escaping.
*/
public function realValue(): Attribute
{
return Attribute::make(
@@ -208,7 +215,7 @@ class EnvironmentVariable extends BaseModel
return $this->resolveRealValue(revealLockedSharedVariables: false);
}
private function resolveRealValue(bool $revealLockedSharedVariables = true): ?string
private function resolveRealValue(bool $revealLockedSharedVariables = true, bool $escapeLegacyValue = true): ?string
{
if (! $this->relationLoaded('resourceable')) {
$this->load('resourceable');
@@ -229,20 +236,24 @@ class EnvironmentVariable extends BaseModel
$resource->load('destination.server');
}
$real_value = $this->get_real_environment_variables_internal($this->value, $resource, null, $revealLockedSharedVariables);
$value = $this->get_real_environment_variables_internal($this->value, $resource, null, $revealLockedSharedVariables);
return $value === null || ! $escapeLegacyValue || ! $this->uses_legacy_escaping ? $value : $this->legacyEscapedValue($value);
}
/**
* The escaping used before exact escaping.
*/
private function legacyEscapedValue(string $value): string
{
// Skip escaping for valid JSON objects/arrays to prevent quote corruption (see #6160)
if (json_validate($real_value) && (str_starts_with($real_value, '{') || str_starts_with($real_value, '['))) {
return $real_value;
if (json_validate($value) && (str_starts_with($value, '{') || str_starts_with($value, '['))) {
return $value;
}
if ($this->is_literal || $this->is_multiline) {
$real_value = '\''.$real_value.'\'';
} else {
$real_value = escapeEnvVariables($real_value);
}
return $real_value;
return $this->is_literal || $this->is_multiline
? '\''.$value.'\''
: escapeEnvVariables($value);
}
protected function isReallyRequired(): Attribute
@@ -335,12 +346,14 @@ class EnvironmentVariable extends BaseModel
/** @return array<int, string> */
public function logRedactionValues(): array
{
$value = $this->real_value;
if (! is_string($value) || $value === '') {
$resolvedValue = $this->resolveRealValue(escapeLegacyValue: false);
if (! is_string($resolvedValue) || $resolvedValue === '') {
return [];
}
$values = [$value];
// Build-time paths still write the old escaped form, so redact both forms.
$value = $this->legacyEscapedValue($resolvedValue);
$values = [$value, $resolvedValue];
if ($this->is_multiline || $this->is_literal) {
$unquoted = str_starts_with($value, "'") && str_ends_with($value, "'")
? substr($value, 1, -1)
+10 -2
View File
@@ -1618,16 +1618,24 @@ class Service extends BaseModel
return 3;
});
foreach ($sorted as $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}");
$envs->push($this->composeEnvironmentFileLine($env));
}
instant_remote_write_file($this->server, $environmentFile, $envs->implode("\n"));
instant_remote_process(["mv {$environmentFile} $workdir/.env"], $this->server);
}
/**
* Build a KEY="value" line for the service .env file, which Docker Compose reads.
* Only plain values allow $VAR interpolation, so $SERVICE_* references keep working.
*/
public function composeEnvironmentFileLine(EnvironmentVariable $environmentVariable): string
{
return $environmentVariable->key.'='.$this->resolveSecretManagerDotenvValue($environmentVariable);
}
public function parse(bool $isNew = false): Collection
{
if ((int) $this->compose_parsing_version >= 3) {
@@ -863,6 +863,7 @@ class ServerTransferExporter
'is_shown_once' => (bool) $variable->is_shown_once,
'is_shared' => (bool) $variable->is_shared,
'is_required' => (bool) ($variable->is_required ?? false),
'uses_legacy_escaping' => (bool) $variable->uses_legacy_escaping,
'comment' => $variable->comment,
'order' => $variable->order,
];
@@ -1031,6 +1031,8 @@ class ServerTransferImporter
'is_shown_once' => (bool) data_get($variable, 'is_shown_once', false),
'is_shared' => (bool) data_get($variable, 'is_shared', false),
'is_required' => (bool) data_get($variable, 'is_required', false),
// Exports from older versions have no flag; their values use the old escaping.
'uses_legacy_escaping' => (bool) data_get($variable, 'uses_legacy_escaping', true),
'comment' => data_get($variable, 'comment'),
'order' => data_get($variable, 'order'),
'resourceable_type' => $resource->getMorphClass(),
+107 -4
View File
@@ -3,8 +3,13 @@
namespace App\Traits;
use App\Exceptions\RemoteSecretException;
use App\Models\Application;
use App\Models\EnvironmentVariable;
use App\Models\SecretManagerLink;
use App\Models\Service;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneRedis;
use App\Support\RemoteSecretReferences;
use App\Support\RemoteSecretValueFormatter;
use Illuminate\Database\Eloquent\Model;
@@ -69,7 +74,9 @@ trait HasSecretManager
return RemoteSecretValueFormatter::dotenv($value);
}
return $this->formatLocalEnvironmentVariableValue($environmentVariable, $value);
return $value === null ? null : ($this->useExactEscaping($environmentVariable)
? escapeComposeEnvFileValue($value, $this->environmentVariableAllowsInterpolation($environmentVariable, $value))
: $this->legacyFormatEnvironmentVariableValue($environmentVariable, $value));
}
/**
@@ -92,9 +99,16 @@ trait HasSecretManager
*/
public function formatComposeFileValue(EnvironmentVariable $environmentVariable, string $value): string
{
return $this->environmentVariableUsesSecretManager($environmentVariable)
? RemoteSecretValueFormatter::composeFile($value)
: $value;
if ($this->environmentVariableUsesSecretManager($environmentVariable)) {
return RemoteSecretValueFormatter::composeFile($value);
}
if ($environmentVariable->key === 'REDIS_PASSWORD' && $this->useExactEscaping($environmentVariable)
&& ($this instanceof StandaloneRedis || $this instanceof StandaloneKeydb || $this instanceof StandaloneDragonfly)) {
return escapeDollarSign($value);
}
return $this->composeCommandValue($environmentVariable, $value);
}
/**
@@ -119,6 +133,46 @@ trait HasSecretManager
return null;
}
if (! $this->useExactEscaping($environmentVariable)) {
return $this->legacyFormatEnvironmentVariableValue($environmentVariable, $value);
}
return $this->environmentVariableAllowsInterpolation($environmentVariable, $value)
? $value
: escapeDollarSign($value);
}
/**
* 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.
*/
public function useExactEscaping(EnvironmentVariable $environmentVariable): bool
{
return ! $environmentVariable->uses_legacy_escaping;
}
/**
* A variable value for a Docker Compose command or healthcheck. Compose interpolates `$` there
* too, so the value gets the same `$` escaping as in the environment list, and the command uses
* the same value as the container.
*/
public function composeCommandValue(EnvironmentVariable $environmentVariable, string $value): string
{
if (! $this->useExactEscaping($environmentVariable) || $this->environmentVariableAllowsInterpolation($environmentVariable, $value)) {
return $value;
}
return escapeDollarSign($value);
}
/**
* The escaping used before exact escaping. Variables saved before the upgrade keep it,
* so their containers get the same values as before.
*/
public function legacyFormatEnvironmentVariableValue(EnvironmentVariable $environmentVariable, string $value): string
{
if (json_validate($value) && (str_starts_with($value, '{') || str_starts_with($value, '['))) {
return $value;
}
@@ -128,6 +182,55 @@ trait HasSecretManager
: escapeEnvVariables($value);
}
/**
* Whether the container gets a different value than the saved value, because this variable
* uses the old escaping. Secret manager values are not fetched here, so they never show it.
*/
public function legacyEscapingChangesValue(EnvironmentVariable $environmentVariable): bool
{
if ($this->useExactEscaping($environmentVariable)) {
return false;
}
$value = $this->resolvedEnvironmentVariableValue($environmentVariable);
if (blank($value) || RemoteSecretReferences::containsReference($value)) {
return false;
}
$isRedisPassword = $environmentVariable->key === 'REDIS_PASSWORD'
&& ($this instanceof StandaloneRedis || $this instanceof StandaloneKeydb || $this instanceof StandaloneDragonfly);
if ($isRedisPassword && str_contains($value, '$')) {
return true;
}
if (json_validate($value) && (str_starts_with($value, '{') || str_starts_with($value, '['))) {
return str_contains($value, '$') || (! $this instanceof Application && str_contains($value, "\n"));
}
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;
}
return escapeEnvVariables($value) !== $value
|| (($this instanceof Service || $this instanceof Application) && str_contains($value, ' #'));
}
/**
* Only plain values allow Compose $VAR interpolation.
*/
public function environmentVariableAllowsInterpolation(EnvironmentVariable $environmentVariable, string $value): bool
{
$isJson = json_validate($value) && in_array(ltrim($value)[0] ?? '', ['{', '['], true);
return ! $isJson
&& ! $environmentVariable->is_literal
&& ! $environmentVariable->is_multiline
&& ! $this->environmentVariableUsesSecretManager($environmentVariable);
}
public function resolveSecretManagerEnvironmentVariableValue(EnvironmentVariable $environmentVariable): ?string
{
$value = $this->resolvedEnvironmentVariableValue($environmentVariable);
+11
View File
@@ -1912,6 +1912,17 @@ function escapeBashEnvValue(?string $value): string
return "'{$escaped}'";
}
function escapeComposeEnvFileValue(?string $value, bool $allowInterpolation = false): string
{
$escaped = str_replace(['\\', '"'], ['\\\\', '\\"'], $value ?? '');
if (! $allowInterpolation) {
$escaped = str_replace('$', '\\$', $escaped);
}
return '"'.$escaped.'"';
}
/**
* Escape a value for bash double-quoted strings (allows $VAR expansion)
*
@@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Existing variables keep the old value escaping, so containers get the same values after the upgrade.
* New variables, and variables that users save again, use exact escaping.
*/
public function up(): void
{
if (Schema::hasColumn('environment_variables', 'uses_legacy_escaping')) {
return;
}
Schema::table('environment_variables', function (Blueprint $table) {
$table->boolean('uses_legacy_escaping')->default(true);
});
Schema::table('environment_variables', function (Blueprint $table) {
$table->boolean('uses_legacy_escaping')->default(false)->change();
});
}
public function down(): void
{
if (! Schema::hasColumn('environment_variables', 'uses_legacy_escaping')) {
return;
}
Schema::table('environment_variables', function (Blueprint $table) {
$table->dropColumn('uses_legacy_escaping');
});
}
};
@@ -184,6 +184,15 @@
@endif
</div>
@if ($legacyEscapingChangesValue)
<x-callout type="warning" title="Old escaping">
This variable was saved before Coolify passed exact values, so the container gets a
changed value (for example, with extra backslashes or quotes). To pass the exact value,
delete this variable and add it again, then restart. If a database already uses this
value as a password, update the password in the database too.
</x-callout>
@endif
@if ($is_shared)
<x-forms.input disabled type="password" id="real_value" label="Resolved value" />
@endif
@@ -550,84 +550,88 @@ it('keeps the original sourced environment path when build-time keys are shell s
->not->toContain(ApplicationDeploymentJob::BUILD_TIME_ENV_LAUNCHER_PATH);
});
it('uses BuildKit secrets for dotted Nixpacks variables instead of invalid Dockerfile expansion', function () {
it('skips dotted keys from the Nixpacks plan without failing the deployment', function () {
[$application, $server] = makeDeploymentControlVarFixture([
'build_pack' => 'nixpacks',
]);
createApplicationEnvironmentVariable($application, [
'key' => 'spring.profiles.active',
'value' => 'prod',
]);
[$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, [
'saved_outputs' => collect([
'nixpacks_plan' => json_encode([
'variables' => [
'X.VALUE' => 'dotted-from-toml',
'SAFE_FROM_TOML' => 'ok-from-toml',
'NIXPACKS_NODE_VERSION' => '22',
],
]),
'nixpacks_type' => 'node',
]),
]);
invokeDeploymentJobMethod($job, $reflection, 'generate_nixpacks_confs');
$variables = collect(readDeploymentJobProperty($job, $reflection, 'nixpacks_plan_json')->get('variables'));
expect($variables->has('SAFE_FROM_TOML'))->toBeTrue();
expect($variables->has('X.VALUE'))->toBeFalse();
expect($variables->has('spring.profiles.active'))->toBeFalse();
$logs = implode("\n", $job->recordedLogEntries);
expect($logs)
->toContain('Build-time variable X.VALUE has a dot in its name, which Nixpacks cannot pass to the build')
->toContain('Build-time variable spring.profiles.active has a dot in its name')
->toContain('Suggested name: X_VALUE')
->toContain('Available at Buildtime')
->not->toContain('dotted-from-toml')
->not->toContain('prod');
});
it('still allows underscore Nixpacks plan keys through generate_build_env_variables', function () {
[$application, $server] = makeDeploymentControlVarFixture([
'build_pack' => 'nixpacks',
]);
[$job, $reflection] = makeControlVarFilteringJob($application, $server, [
'dockerBuildkitSupported' => true,
'dockerSecretsAvailable' => true,
'env_args' => collect(['X.VALUE' => 'dotted-buildtime-ok']),
'nixpacks_plan_json' => collect([
'variables' => ['X.VALUE' => 'dotted-buildtime-ok'],
]),
'saved_outputs' => collect([
'dockerfile_content' => "FROM alpine\nARG SAFE X.VALUE\nENV SAFE=\$SAFE X.VALUE=\$X.VALUE\nRUN printenv X.VALUE",
'variables' => [
'SAFE_FROM_TOML' => 'ok-from-toml',
'NIXPACKS_NODE_VERSION' => '22',
],
]),
]);
invokeDeploymentJobMethod($job, $reflection, 'generate_build_env_variables');
expect(readDeploymentJobProperty($job, $reflection, 'dockerSecretsSupported'))->toBeTrue();
expect(readDeploymentJobProperty($job, $reflection, 'build_secrets'))->toContain("--secret 'id=X.VALUE,env=X.VALUE'");
invokeDeploymentJobMethod($job, $reflection, 'modify_dockerfile_for_secrets', '/artifacts/test-app/.nixpacks/Dockerfile');
$dockerfile = $job->writtenArtifacts['/artifacts/test-app/.nixpacks/Dockerfile'];
expect($dockerfile)
->not->toContain('ARG X.VALUE')
->not->toContain('X.VALUE=$X.VALUE')
->toContain('ARG SAFE')
->toContain('ENV SAFE=$SAFE')
->toContain('RUN --mount=type=secret,id=X.VALUE,env=X.VALUE')
->toContain('printenv X.VALUE');
expect(readDeploymentJobProperty($job, $reflection, 'dockerSecretsSupported'))->toBeFalse();
});
it('rejects dotted Nixpacks variables when Docker build secrets are unavailable', function () {
it('mounts underscore nixpacks secrets on RUN lines when build secrets are enabled', function () {
[$application, $server] = makeDeploymentControlVarFixture([
'build_pack' => 'nixpacks',
]);
[$job, $reflection] = makeControlVarFilteringJob($application, $server, [
'dockerBuildkitSupported' => true,
'dockerSecretsAvailable' => false,
'env_args' => collect(['SAFE_FROM_TOML' => 'ok-from-toml']),
'nixpacks_plan_json' => collect([
'variables' => [
'X.VALUE' => 'dotted-buildtime-ok',
'ANOTHER.DOTTED.VALUE' => 'also-dotted',
],
'variables' => ['SAFE_FROM_TOML' => 'ok-from-toml'],
]),
]);
expect(fn () => invokeDeploymentJobMethod($job, $reflection, 'generate_build_env_variables'))
->toThrow(
DeploymentException::class,
'Dotted Nixpacks build-time environment variable names require Docker BuildKit secret support: X.VALUE, ANOTHER.DOTTED.VALUE. Rename these keys to use underscores instead of dots, or upgrade Docker on the build server.'
);
});
it('writes dotted Nixpacks ARG and ENV removal when the Dockerfile has no run command', function () {
[$application, $server] = makeDeploymentControlVarFixture([
'build_pack' => 'nixpacks',
]);
[$job, $reflection] = makeControlVarFilteringJob($application, $server, [
'env_args' => collect(['X.VALUE' => 'dotted-buildtime-ok']),
'nixpacks_plan_json' => collect([
'variables' => ['X.VALUE' => 'dotted-buildtime-ok'],
]),
'build_secrets' => '--secret id=X.VALUE,env=X.VALUE',
'build_secrets' => '--secret id=SAFE_FROM_TOML,env=SAFE_FROM_TOML',
'saved_outputs' => collect([
'dockerfile_content' => "FROM alpine\nARG X.VALUE=default\nENV X.VALUE=\$X.VALUE",
'dockerfile_content' => "FROM alpine\nARG SAFE_FROM_TOML\nRUN printenv SAFE_FROM_TOML",
]),
]);
invokeDeploymentJobMethod($job, $reflection, 'modify_dockerfile_for_secrets', '/artifacts/test-app/.nixpacks/Dockerfile');
expect($job->writtenArtifacts['/artifacts/test-app/.nixpacks/Dockerfile'])
->not->toContain('X.VALUE');
->toContain('RUN --mount=type=secret,id=SAFE_FROM_TOML,env=SAFE_FROM_TOML')
->toContain('printenv SAFE_FROM_TOML');
});
it('skips unsafe reserved Nixpacks plan variable keys before validation', function (string $key) {
@@ -976,6 +980,43 @@ it('filters buildpack control vars from dockerfile arg injection', function () {
expect($job->writtenDockerfile)->not->toContain('ARG RAILPACK_NODE_VERSION=');
});
it('passes dotted keys as Docker build secrets for Dockerfile builds', function () {
[$application, $server] = makeDeploymentControlVarFixture([
'build_pack' => 'dockerfile',
]);
[$job, $reflection] = makeControlVarFilteringJob($application, $server, [
'dockerSecretsSupported' => true,
]);
invokeDeploymentJobMethod($job, $reflection, 'generate_build_secrets', collect([
'DOTTED.USER' => 'df-dotted',
'SAFE' => 'ok',
]));
expect(readDeploymentJobProperty($job, $reflection, 'build_secrets'))
->toContain('id=DOTTED.USER,env=DOTTED.USER')
->toContain('id=SAFE,env=SAFE');
});
it('keeps dotted dockerfile build args when secrets are not used', function () {
[$application, $server] = makeDeploymentControlVarFixture();
createApplicationEnvironmentVariable($application, [
'key' => 'DOTTED.USER',
'value' => 'df-dotted',
]);
[$job, $reflection] = makeControlVarFilteringJob($application, $server);
invokeDeploymentJobMethod($job, $reflection, 'generate_env_variables');
/** @var Collection $envArgs */
$envArgs = readDeploymentJobProperty($job, $reflection, 'env_args');
expect($envArgs->get('DOTTED.USER'))->toBe('df-dotted');
});
it('does not write environment values into generated Dockerfile ARG declarations', function () {
[$application, $server] = makeDeploymentControlVarFixture();
createApplicationEnvironmentVariable($application, [
@@ -1075,9 +1116,9 @@ it('rejects existing variable names that would break the .env file or build comm
expect(fn () => invokeDeploymentJobMethod($job, $reflection, 'validateDeploymentEnvironmentVariableKeys', $buildsImage))
->toThrow(DeploymentException::class, 'Invalid environment variable name from the deployment environment');
})->with([
'runtime-only name with = before the build is decided' => ['A=B', false, false],
'runtime-only name with a newline before the build is decided' => ["A\nB", false, false],
'build-time name with a hyphen when the deployment builds' => ['my-var', true, true],
'runtime-only name with =' => ['A=B', false, false],
'runtime-only name with a newline' => ["A\nB", false, false],
'build-time name with shell characters' => ['my;var', true, true],
]);
it('does not fail restarts or deployments that reuse an image on invalid build-time names', function (string $buildPack) {
@@ -1182,22 +1223,68 @@ it('still rejects Docker image variable names that would break the .env file', f
->toThrow(DeploymentException::class, 'Invalid environment variable name from the deployment environment');
});
it('fails with a clear message for invalid build-time names when the deployment builds an image', function (string $buildPack) {
[$application, $server] = makeDeploymentControlVarFixture(['build_pack' => $buildPack]);
it('skips existing build-time variables whose names new variables cannot use', function () {
[$application, $server] = makeDeploymentControlVarFixture([
'build_pack' => 'dockerfile',
]);
$environmentVariable = createApplicationEnvironmentVariable($application, [
'key' => 'SAFE_KEY',
'value' => 'secret',
'value' => 'legacy-value',
'is_buildtime' => true,
'is_runtime' => true,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'VALID_KEY',
'value' => 'valid-value',
'is_buildtime' => true,
'is_runtime' => true,
]);
// Names like my-var were accepted before the current rules; the model no longer allows them.
DB::table('environment_variables')->where('id', $environmentVariable->id)->update(['key' => 'my-var']);
[$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server);
invokeDeploymentJobMethod($job, $reflection, 'validateDeploymentEnvironmentVariableKeys');
expect(collect($job->recordedLogEntries)->implode("\n"))
->toContain('Build-time variable my-var uses a name that new variables cannot use. It is skipped during the build, but is still passed to the container.')
->toContain('Suggested name: my_var')
->not->toContain('legacy-value');
/** @var Collection $buildtimeEnvs */
$buildtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables');
expect($buildtimeEnvs->contains(fn (string $env) => str_starts_with($env, 'my-var=')))->toBeFalse();
expect($buildtimeEnvs->contains(fn (string $env) => str_starts_with($env, 'VALID_KEY=')))->toBeTrue();
invokeDeploymentJobMethod($job, $reflection, 'generate_env_variables');
$envArgs = readDeploymentJobProperty($job, $reflection, 'env_args');
expect($envArgs->has('my-var'))->toBeFalse();
expect($envArgs->get('VALID_KEY'))->not->toBeNull();
/** @var Collection $runtimeEnvs */
$runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables');
expect($runtimeEnvs->contains(fn (string $env) => str_starts_with($env, 'my-var=')))->toBeTrue();
});
it('skips existing build-time variables whose names new variables cannot use from Railpack builds', function () {
[$application, $server] = makeDeploymentControlVarFixture([
'build_pack' => 'railpack',
]);
$environmentVariable = createApplicationEnvironmentVariable($application, [
'key' => 'SAFE_KEY',
'value' => 'legacy-value',
'is_buildtime' => true,
]);
DB::table('environment_variables')->where('id', $environmentVariable->id)->update(['key' => 'my-var']);
[$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, [
'build_pack' => 'railpack',
'branch' => 'main',
]);
[$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, ['build_pack' => $buildPack]);
/** @var Collection $variables */
$variables = invokeDeploymentJobMethod($job, $reflection, 'railpack_build_variables');
expect(fn () => invokeDeploymentJobMethod($job, $reflection, 'validateDeploymentEnvironmentVariableKeys', true))
->toThrow(DeploymentException::class, 'Invalid environment variable name from the deployment environment: my-var');
expect(collect($job->recordedLogEntries)->implode("\n"))
->toContain('Build-time variable names must start with a letter or underscore');
})->with(['dockerfile', 'nixpacks', 'static', 'railpack', 'dockercompose']);
expect($variables->has('my-var'))->toBeFalse();
});
it('does not pass build-time variables to the helper container of Docker image deployments', function () {
[$application, $server] = makeDeploymentControlVarFixture(['build_pack' => 'dockerimage']);
@@ -1378,6 +1465,39 @@ it('builds railpack variables from generic buildtime vars railpack vars and cool
expect($variables->has('RUNTIME_ONLY'))->toBeFalse();
});
it('skips dotted keys from Railpack build variables without failing the deployment', function () {
[$application, $server] = makeDeploymentControlVarFixture([
'build_pack' => 'railpack',
]);
createApplicationEnvironmentVariable($application, [
'key' => 'APP_ENV',
'value' => 'production',
'is_runtime' => false,
'is_buildtime' => true,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'spring.profiles.active',
'value' => 'prod',
'is_runtime' => true,
'is_buildtime' => true,
]);
[$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, [
'build_pack' => 'railpack',
'branch' => 'main',
]);
/** @var Collection $variables */
$variables = invokeDeploymentJobMethod($job, $reflection, 'railpack_build_variables');
expect($variables->get('APP_ENV'))->toBe('production');
expect($variables->has('spring.profiles.active'))->toBeFalse();
expect(implode("\n", $job->recordedLogEntries))
->toContain('Build-time variable spring.profiles.active has a dot in its name, which Railpack cannot pass to the build')
->toContain('Suggested name: spring_profiles_active');
});
it('builds preview railpack variables without leaking stale nixpacks vars', function () {
[$application, $server] = makeDeploymentControlVarFixture([
'build_pack' => 'railpack',
@@ -1430,6 +1550,237 @@ it('builds preview railpack variables without leaking stale nixpacks vars', func
expect($variables->has('PREVIEW_RUNTIME_ONLY'))->toBeFalse();
});
it('writes compose-safe runtime env files for user values', function () {
[$application, $server] = makeDeploymentControlVarFixture();
createApplicationEnvironmentVariable($application, [
'key' => 'QUOTED_VALUE',
'value' => 'hello world; quotes" \'$`',
'is_literal' => true,
'is_runtime' => true,
'is_buildtime' => false,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'EXPAND_ME',
'value' => '$BOTH_PHASES',
'is_literal' => false,
'is_runtime' => true,
'is_buildtime' => true,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'BOTH_PHASES',
'value' => 'both-phases-value',
'is_literal' => true,
'is_runtime' => true,
'is_buildtime' => true,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'INJECT_CMD',
'value' => '$(touch /tmp/coolify-value-injection)',
'is_literal' => true,
'is_runtime' => true,
'is_buildtime' => true,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'EDGE_QUOTES',
'value' => "'keep these quotes'",
'is_literal' => true,
'is_runtime' => true,
'is_buildtime' => false,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'MULTILINE_VALUE',
'value' => "\$BOTH_PHASES\nsecond line",
'is_literal' => false,
'is_multiline' => true,
'is_runtime' => true,
'is_buildtime' => false,
]);
[$job, $reflection] = makeControlVarFilteringJob($application, $server);
/** @var Collection $runtimeEnvs */
$runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables');
expect($runtimeEnvs)
->toContain('QUOTED_VALUE='.escapeComposeEnvFileValue('hello world; quotes" \'$`'))
->toContain('INJECT_CMD='.escapeComposeEnvFileValue('$(touch /tmp/coolify-value-injection)'))
->toContain('EXPAND_ME='.escapeComposeEnvFileValue('$BOTH_PHASES', allowInterpolation: true))
->toContain('EDGE_QUOTES='.escapeComposeEnvFileValue("'keep these quotes'"))
->toContain('MULTILINE_VALUE='.escapeComposeEnvFileValue("\$BOTH_PHASES\nsecond line"));
});
it('writes compose-safe preview runtime env files for user values', function () {
[$application, $server] = makeDeploymentControlVarFixture();
createApplicationEnvironmentVariable($application, [
'key' => 'PREVIEW_QUOTED_VALUE',
'value' => 'preview quotes" \'$`',
'is_preview' => true,
'is_literal' => true,
'is_runtime' => true,
'is_buildtime' => false,
]);
[$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, [
'pull_request_id' => 42,
]);
/** @var Collection $runtimeEnvs */
$runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables');
expect($runtimeEnvs)
->toContain('PREVIEW_QUOTED_VALUE='.escapeComposeEnvFileValue('preview quotes" \'$`'));
});
it('writes compose-safe production fallbacks to preview runtime env files', function () {
[$application, $server] = makeDeploymentControlVarFixture();
createApplicationEnvironmentVariable($application, [
'key' => 'PREVIEW_TRIGGER',
'value' => 'preview-value',
'is_preview' => true,
'is_literal' => false,
'is_runtime' => true,
'is_buildtime' => false,
]);
createApplicationEnvironmentVariable($application, [
'key' => 'FALLBACK_QUOTED_VALUE',
'value' => 'fallback quotes" \'$`',
'is_literal' => true,
'is_runtime' => true,
'is_buildtime' => false,
]);
$application->environment_variables_preview()
->where('key', 'FALLBACK_QUOTED_VALUE')
->delete();
[$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, [
'pull_request_id' => 42,
]);
/** @var Collection $runtimeEnvs */
$runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables');
expect($runtimeEnvs)
->toContain('FALLBACK_QUOTED_VALUE='.escapeComposeEnvFileValue('fallback quotes" \'$`'));
});
it('writes compose-safe runtime env values for remote secret references', function () {
[$application, $server] = makeDeploymentControlVarFixture();
createApplicationEnvironmentVariable($application, [
'key' => 'SECRET_TOKEN',
'value' => '{{vault.API_TOKEN}}',
'is_literal' => false,
'is_runtime' => true,
'is_buildtime' => false,
]);
$secret = "secret\$value'quoted\"";
[$job, $reflection] = makeControlVarFilteringJob($application, $server, [
'remote_secrets_cache' => ['API_TOKEN' => $secret],
]);
/** @var Collection $runtimeEnvs */
$runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables');
expect($runtimeEnvs)->toContain('SECRET_TOKEN='.escapeComposeEnvFileValue($secret));
});
it('keeps the old runtime env format for variables saved before exact escaping', function () {
[$application, $server] = makeDeploymentControlVarFixture();
foreach ([
['key' => 'LEGACY_PLAIN', 'value' => 'a\'b"c\\d', 'is_literal' => false],
['key' => 'LEGACY_LITERAL', 'value' => 'x$y', 'is_literal' => true],
] as $attributes) {
$variable = createApplicationEnvironmentVariable($application, [...$attributes, 'is_runtime' => true, 'is_buildtime' => false]);
$variable->uses_legacy_escaping = true;
$variable->save();
}
createApplicationEnvironmentVariable($application, [
'key' => 'EXACT_PLAIN',
'value' => 'a\'b"c\\d',
'is_literal' => false,
'is_runtime' => true,
'is_buildtime' => false,
]);
[$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server);
/** @var Collection $runtimeEnvs */
$runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables');
expect($runtimeEnvs)
->toContain('LEGACY_PLAIN=a\\\'b\\"c\\\\d')
->toContain("LEGACY_LITERAL='x\$y'")
->toContain('EXACT_PLAIN='.escapeComposeEnvFileValue('a\'b"c\\d', allowInterpolation: true));
});
it('keeps the old runtime env format for preview variables saved before exact escaping', function () {
[$application, $server] = makeDeploymentControlVarFixture();
$variable = createApplicationEnvironmentVariable($application, [
'key' => 'LEGACY_PREVIEW',
'value' => "array:'self'",
'is_preview' => true,
'is_runtime' => true,
'is_buildtime' => false,
]);
$variable->uses_legacy_escaping = true;
$variable->save();
[$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, ['pull_request_id' => 42]);
/** @var Collection $runtimeEnvs */
$runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables');
expect($runtimeEnvs)->toContain("LEGACY_PREVIEW=array:\\'self\\'");
});
it('resolves exact runtime env values without loading each variable resource', function () {
[$application, $server] = makeDeploymentControlVarFixture();
foreach (['FIRST_EXACT', 'SECOND_EXACT'] as $key) {
createApplicationEnvironmentVariable($application, [
'key' => $key,
'value' => "array:'self'",
'is_runtime' => true,
'is_buildtime' => false,
]);
}
[$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server);
DB::enableQueryLog();
/** @var Collection $runtimeEnvs */
$runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables');
$applicationLookups = collect(DB::getQueryLog())
->filter(fn (array $query) => str($query['query'])->contains('from "applications"'));
DB::disableQueryLog();
expect($runtimeEnvs)->toContain('FIRST_EXACT='.escapeComposeEnvFileValue("array:'self'", allowInterpolation: true))
->and($applicationLookups)->toBeEmpty();
});
it('keeps dollars literal in runtime JSON values', function () {
[$application, $server] = makeDeploymentControlVarFixture();
createApplicationEnvironmentVariable($application, [
'key' => 'JSON_CONFIG',
'value' => '{"price":"$5"}',
'is_runtime' => true,
'is_buildtime' => false,
]);
[$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server);
/** @var Collection $runtimeEnvs */
$runtimeEnvs = invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables');
expect($runtimeEnvs)->toContain('JSON_CONFIG='.escapeComposeEnvFileValue('{"price":"$5"}'));
});
it('omits only unset Compose overrides from production and preview runtime files', function (int $pullRequestId) {
Server::flushIdentityMap();
[$application, $server] = makeDeploymentControlVarFixture([
@@ -1444,8 +1795,8 @@ it('omits only unset Compose overrides from production and preview runtime files
[$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();
expect($variables->contains(fn (string $line) => str_starts_with($line, 'INHERITED=')))->toBeFalse()
->and($variables->contains('EMPTY='.escapeComposeEnvFileValue('')))->toBeTrue();
})->with([0, 42]);
it('includes migrated null Compose overrides in production and preview runtime files', function (int $pullRequestId) {
@@ -1461,5 +1812,5 @@ it('includes migrated null Compose overrides in production and preview runtime f
}
[$job, $reflection] = makeControlVarFilteringJob($application, $server, ['pull_request_id' => $pullRequestId]);
expect(invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables')->contains('LEGACY='))->toBeTrue();
expect(invokeDeploymentJobMethod($job, $reflection, 'generate_runtime_environment_variables')->contains('LEGACY='.escapeComposeEnvFileValue('')))->toBeTrue();
})->with([0, 42]);
@@ -209,8 +209,8 @@ YAML);
$env = composeNetworkDeploymentEnv($application);
expect($env)->toContain('SHARED_NETWORK=traefik_public')
->and($env)->toContain('OTHER_NETWORK=edge_net');
expect($env)->toContain('SHARED_NETWORK='.escapeComposeEnvFileValue('traefik_public', allowInterpolation: true))
->and($env)->toContain('OTHER_NETWORK='.escapeComposeEnvFileValue('edge_net', allowInterpolation: true));
$buildEnv = composeNetworkDeploymentEnv($application, method: 'generate_buildtime_environment_variables')->implode("\n");
@@ -231,5 +231,5 @@ YAML);
$application->parse();
expect(composeNetworkDeploymentEnv($application, 7))->toContain('SHARED_NETWORK=traefik_public');
expect(composeNetworkDeploymentEnv($application, 7))->toContain('SHARED_NETWORK='.escapeComposeEnvFileValue('traefik_public', allowInterpolation: true));
});
@@ -0,0 +1,174 @@
<?php
use App\Actions\Database\StartClickhouse;
use App\Actions\Database\StartMariadb;
use App\Actions\Database\StartMysql;
use App\Actions\Database\StartPostgresql;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::query()->firstOrCreate(['id' => 0]);
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
$this->destination = $server->standaloneDockers()->firstOrFail();
$project = Project::factory()->create(['team_id' => $team->id]);
$this->environment = Environment::factory()->create(['project_id' => $project->id]);
});
/**
* @param array<int, array{0: string, 1: ?string, 2?: bool, 3?: bool}> $variables
* @return array<int, string>
*/
function postgresComposeEnvironment(array $variables): array
{
$database = StandalonePostgresql::forceCreate([
'uuid' => 'postgres-env-test',
'name' => 'Postgres env test',
'image' => 'postgres:16-alpine',
'postgres_password' => 'password',
'environment_id' => test()->environment->id,
'destination_id' => test()->destination->id,
'destination_type' => test()->destination->getMorphClass(),
]);
foreach ($variables as $variable) {
$database->runtime_environment_variables()->create([
'key' => $variable[0],
'value' => $variable[1],
'is_literal' => $variable[2] ?? false,
'is_multiline' => $variable[3] ?? false,
]);
}
$action = new StartPostgresql;
$action->database = $database->fresh();
return (new ReflectionMethod($action, 'generate_environment_variables'))->invoke($action);
}
test('literal database values are passed without added quotes and keep dollars literal', function () {
$environment = postgresComposeEnvironment([
['CSP', "array:'self',blob:,https://*", true],
['SECRET', 'p4$$w"ord$X `id` C:\\path', true],
]);
expect($environment)
->toContain("CSP=array:'self',blob:,https://*")
->toContain('SECRET=p4$$$$w"ord$$X `id` C:\\path');
});
test('plain database values are passed without backslash escaping', function () {
$environment = postgresComposeEnvironment([
['CSP', "array:'self',blob:,https://*"],
['QUOTED', 'say "hi" C:\\path'],
]);
expect($environment)
->toContain("CSP=array:'self',blob:,https://*")
->toContain('QUOTED=say "hi" C:\\path');
});
test('plain database values keep compose interpolation', function () {
$environment = postgresComposeEnvironment([
['URL', 'postgres://$USER@db'],
]);
expect($environment)->toContain('URL=postgres://$USER@db');
});
test('multiline and json database values keep dollars literal', function () {
$environment = postgresComposeEnvironment([
['CERT', "line 'one'\nline \$two", false, true],
['CONFIG', '{"price":"$5"}'],
]);
expect($environment)
->toContain("CERT=line 'one'\nline \$\$two")
->toContain('CONFIG={"price":"$$5"}');
});
test('empty database values stay empty', function () {
expect(postgresComposeEnvironment([['EMPTY', null]]))->toContain('EMPTY=');
});
test('mariadb literal values are passed without added quotes', function () {
$database = StandaloneMariadb::forceCreate([
'uuid' => 'mariadb-env-test',
'name' => 'MariaDB env test',
'image' => 'mariadb:11',
'mariadb_root_password' => 'password',
'mariadb_password' => 'password',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$database->runtime_environment_variables()->create([
'key' => 'CSP',
'value' => "array:'self' \$HOME",
'is_literal' => true,
]);
$action = new StartMariadb;
$action->database = $database->fresh();
$environment = (new ReflectionMethod($action, 'generate_environment_variables'))->invoke($action);
expect($environment)->toContain("CSP=array:'self' \$\$HOME");
});
/**
* Compose interpolates `$` in healthchecks too, so the healthcheck must get the same value as the
* container environment.
*/
function databaseHealthcheckCredential(object $action, object $database, array $variable, string $property): string
{
$environmentVariable = $database->runtime_environment_variables()->create([
'key' => $variable[0],
'value' => $variable[1],
'is_literal' => $variable[2] ?? false,
]);
if ($variable[3] ?? false) {
$environmentVariable->uses_legacy_escaping = true;
$environmentVariable->save();
}
$action->database = $database->fresh();
(new ReflectionMethod($action, 'generate_environment_variables'))->invoke($action);
return (new ReflectionProperty($action, $property))->getValue($action);
}
test('database healthcheck credentials match the container environment', function (string $databaseType, array $variable, string $property, string $expected) {
[$modelClass, $actionClass, $attributes] = match ($databaseType) {
'postgresql' => [StandalonePostgresql::class, StartPostgresql::class, ['image' => 'postgres:16-alpine', 'postgres_password' => 'password']],
'mysql' => [StandaloneMysql::class, StartMysql::class, ['image' => 'mysql:8', 'mysql_root_password' => 'password', 'mysql_password' => 'password']],
'clickhouse' => [StandaloneClickhouse::class, StartClickhouse::class, ['image' => 'clickhouse/clickhouse-server', 'clickhouse_admin_password' => 'password']],
};
$database = $modelClass::forceCreate([
'uuid' => "{$databaseType}-healthcheck-test",
'name' => "{$databaseType} healthcheck test",
...$attributes,
'environment_id' => test()->environment->id,
'destination_id' => test()->destination->id,
'destination_type' => test()->destination->getMorphClass(),
]);
expect(databaseHealthcheckCredential(new $actionClass, $database, $variable, $property))->toBe($expected);
})->with([
'postgres literal user' => ['postgresql', ['POSTGRES_USER', 'us$er', true], 'resolvedPostgresUser', 'us$$er'],
'postgres plain user' => ['postgresql', ['POSTGRES_USER', 'us$er'], 'resolvedPostgresUser', 'us$er'],
'mysql literal root password' => ['mysql', ['MYSQL_ROOT_PASSWORD', 'p4$$w', true], 'resolvedMysqlRootPassword', 'p4$$$$w'],
'mysql legacy literal root password' => ['mysql', ['MYSQL_ROOT_PASSWORD', 'p4$$w', true, true], 'resolvedMysqlRootPassword', 'p4$$w'],
'clickhouse literal password' => ['clickhouse', ['CLICKHOUSE_PASSWORD', 'p4$w', true], 'resolvedClickhousePassword', 'p4$$w'],
'clickhouse plain password' => ['clickhouse', ['CLICKHOUSE_PASSWORD', 'p4$w'], 'resolvedClickhousePassword', 'p4$w'],
]);
@@ -505,7 +505,7 @@ it('keeps a literal vault reference as text when the application has no secret m
$buildtime = invokeSensitiveFailureJobMethod($job, $reflection, 'generate_buildtime_environment_variables');
invokeSensitiveFailureJobMethod($job, $reflection, 'generate_env_variables');
expect($runtime->first(fn (string $line): bool => str_starts_with($line, 'LITERAL_REF=')))->toBe("LITERAL_REF='{{vault.FOO}}'")
expect($runtime->first(fn (string $line): bool => str_starts_with($line, 'LITERAL_REF=')))->toBe('LITERAL_REF='.escapeComposeEnvFileValue('{{vault.FOO}}'))
->and($buildtime->first(fn (string $line): bool => str_starts_with($line, 'LITERAL_REF=')))->toBe("LITERAL_REF='{{vault.FOO}}'")
->and(readSensitiveFailureJobProperty($job, $reflection, 'env_args')->get('LITERAL_REF'))->toBe("'{{vault.FOO}}'")
->and(invokeSensitiveFailureJobMethod($job, $reflection, 'has_remote_buildtime_secret_references'))->toBeFalse();
@@ -531,5 +531,5 @@ it('resolves a literal vault reference when the application has a secret manager
$runtime = invokeSensitiveFailureJobMethod($job, $reflection, 'generate_runtime_environment_variables');
expect($runtime->first(fn (string $line): bool => str_starts_with($line, 'LITERAL_REF=')))->toBe("LITERAL_REF='resolved-remote-value'");
expect($runtime->first(fn (string $line): bool => str_starts_with($line, 'LITERAL_REF=')))->toBe('LITERAL_REF='.escapeComposeEnvFileValue('resolved-remote-value'));
});
@@ -96,6 +96,9 @@ test('copies embedded, literal and unknown references as stored', function () {
test('copies literal values without .env-style quoting', function () {
$env = createEnvironmentVariable(['value' => 'pa$$word', 'is_literal' => true]);
// Variables saved before exact escaping still show the quoted .env form.
$env->uses_legacy_escaping = true;
$env->save();
expect($env->real_value)->toBe("'pa\$\$word'");
assertCopiedValue($env, 'pa$$word');
@@ -0,0 +1,362 @@
<?php
use App\Actions\Database\StartKeydb;
use App\Actions\Database\StartPostgresql;
use App\Actions\Database\StartRedis;
use App\Livewire\Project\Shared\EnvironmentVariable\Show;
use App\Models\Application;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\SharedEnvironmentVariable;
use App\Models\StandaloneKeydb;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Models\Team;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
if (! InstanceSettings::query()->whereKey(0)->exists()) {
InstanceSettings::forceCreate(['id' => 0]);
}
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$server = Server::factory()->create(['team_id' => $this->team->id]);
$this->destination = $server->standaloneDockers()->firstOrFail();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $project->id]);
$this->resourceAttributes = [
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
];
$this->service = Service::factory()->create([...$this->resourceAttributes, 'server_id' => $server->id]);
});
function legacyVariable(Model $resource, array $attributes): EnvironmentVariable
{
$variable = $resource->environment_variables()->create($attributes);
$variable->uses_legacy_escaping = true;
$variable->save();
return $variable->fresh();
}
test('the migration marks existing variables as legacy and new variables as exact', function () {
$migration = require database_path('migrations/2026_09_27_154836_add_uses_legacy_escaping_to_environment_variables_table.php');
$migration->down();
$existing = $this->service->environment_variables()->create(['key' => 'EXISTING', 'value' => "a'b"]);
$migration->up();
expect($existing->fresh()->uses_legacy_escaping)->toBeTrue()
->and($this->service->environment_variables()->create(['key' => 'NEW', 'value' => "a'b"])->fresh()->uses_legacy_escaping)->toBeFalse();
});
test('changing the value or format of a legacy variable keeps legacy escaping', function (array $change) {
$variable = legacyVariable($this->service, ['key' => 'CSP', 'value' => "a'b"]);
$variable->update($change);
expect($variable->fresh()->uses_legacy_escaping)->toBeTrue();
})->with([
'value' => [['value' => "a'c"]],
'literal' => [['is_literal' => true]],
'multiline' => [['is_multiline' => true]],
]);
test('saving a legacy variable without a value or format change keeps legacy escaping', function () {
$variable = legacyVariable($this->service, ['key' => 'CSP', 'value' => "a'b"]);
$variable->update(['value' => "a'b", 'comment' => 'note']);
expect($variable->fresh()->uses_legacy_escaping)->toBeTrue();
});
test('legacy service variables keep the old env file format', function () {
$plain = legacyVariable($this->service, ['key' => 'PLAIN', 'value' => 'a\'b"c\\d']);
$literal = legacyVariable($this->service, ['key' => 'LITERAL', 'value' => 'x$y', 'is_literal' => true]);
$empty = legacyVariable($this->service, ['key' => 'EMPTY', 'value' => null]);
expect($this->service->composeEnvironmentFileLine($plain))->toBe('PLAIN=a\\\'b\\"c\\\\d')
->and($this->service->composeEnvironmentFileLine($literal))->toBe("LITERAL='x\$y'")
->and($this->service->composeEnvironmentFileLine($empty))->toBe('EMPTY=');
});
test('legacy database variables keep the old compose environment format', function () {
$database = StandalonePostgresql::forceCreate([
'uuid' => 'postgres-legacy-test',
'name' => 'Postgres legacy test',
'image' => 'postgres:16-alpine',
'postgres_password' => 'password',
...$this->resourceAttributes,
]);
legacyVariable($database, ['key' => 'PLAIN', 'value' => "a'b"]);
legacyVariable($database, ['key' => 'LITERAL', 'value' => 'p4ssword', 'is_literal' => true]);
$action = new StartPostgresql;
$action->database = $database->fresh();
$environment = (new ReflectionMethod($action, 'generate_environment_variables'))->invoke($action);
expect($environment)->toContain("PLAIN=a\\'b")->toContain("LITERAL='p4ssword'");
});
test('legacy redis password variables keep the old start command', function () {
$redis = StandaloneRedis::forceCreate([
'uuid' => 'redis-legacy-test',
'name' => 'Redis legacy test',
'image' => 'redis:7-alpine',
...$this->resourceAttributes,
]);
legacyVariable($redis, ['key' => 'REDIS_PASSWORD', 'value' => 'p4$$word']);
$action = new StartRedis;
$action->database = $redis->fresh();
(new ReflectionMethod($action, 'generate_environment_variables'))->invoke($action);
expect((new ReflectionMethod($action, 'buildStartCommand'))->invoke($action))
->toBe("redis-server --requirepass 'p4\$\$word' --appendonly yes");
});
test('keydb column passwords keep the old start command', function () {
$keydb = StandaloneKeydb::forceCreate([
'uuid' => 'keydb-legacy-test',
'name' => 'KeyDB legacy test',
'image' => 'eqalpha/keydb:latest',
'keydb_password' => 'p4$$word',
...$this->resourceAttributes,
]);
$action = new StartKeydb;
$action->database = $keydb;
(new ReflectionMethod($action, 'generate_environment_variables'))->invoke($action);
expect((new ReflectionMethod($action, 'buildStartCommand'))->invoke($action))
->toBe("keydb-server --requirepass 'p4\$\$word' --appendonly yes");
});
test('saving in the editor without a value change keeps legacy escaping', function () {
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$variable = legacyVariable($this->service, ['key' => 'CSP', 'value' => "a'b"]);
Livewire::test(Show::class, ['env' => $variable, 'type' => 'service'])
->call('loadValues')
->set('comment', 'only the comment changes')
->call('submit')
->assertHasNoErrors();
expect($variable->fresh())
->comment->toBe('only the comment changes')
->uses_legacy_escaping->toBeTrue();
});
test('changing the value in the editor keeps legacy escaping', function () {
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$variable = legacyVariable($this->service, ['key' => 'CSP', 'value' => "a'b"]);
Livewire::test(Show::class, ['env' => $variable, 'type' => 'service'])
->call('loadValues')
->set('value', "a'c")
->call('submit')
->assertHasNoErrors();
expect($variable->fresh()->uses_legacy_escaping)->toBeTrue();
});
test('instant saves keep legacy escaping', function () {
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$variable = legacyVariable($this->service, ['key' => 'CSP', 'value' => "a'b"]);
Livewire::test(Show::class, ['env' => $variable, 'type' => 'service'])
->call('loadValues')
->call('instantSave');
expect($variable->fresh()->uses_legacy_escaping)->toBeTrue();
});
test('a variable that is deleted and added again gets exact escaping', function () {
legacyVariable($this->service, ['key' => 'CSP', 'value' => "a'b"])->delete();
$variable = $this->service->environment_variables()->create(['key' => 'CSP', 'value' => "a'b"]);
expect($variable->fresh()->uses_legacy_escaping)->toBeFalse()
->and($this->service->composeEnvironmentFileLine($variable->fresh()))->toBe('CSP="a\'b"');
});
test('the editor shows the legacy escaping hint only when the container value differs from the saved value', function (bool $legacy, string $value, bool $expected) {
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$variable = $legacy
? legacyVariable($this->service, ['key' => 'CSP', 'value' => $value])
: $this->service->environment_variables()->create(['key' => 'CSP', 'value' => $value]);
$component = Livewire::test(Show::class, ['env' => $variable->fresh(), 'type' => 'service'])
->call('loadValues')
->assertSet('legacyEscapingChangesValue', $expected);
$expected
? $component->assertSee('delete this variable and add it again')
: $component->assertDontSee('delete this variable and add it again');
})->with([
'legacy with a quote' => [true, "a'b", true],
'legacy plain value' => [true, 'plain-value', false],
'exact with a quote' => [false, "a'b", false],
]);
/**
* Automation often sends the same values on every run, so only a real change may switch the escaping.
*
* @return array{0: array<string, string>, 1: list<EnvironmentVariable>}
*/
function apiLegacyEscapingFixture(object $test): array
{
session(['currentTeam' => $test->team]);
$headers = ['Authorization' => 'Bearer '.$test->user->createToken('test-token', ['*'])->plainTextToken];
$database = StandalonePostgresql::forceCreate([
'uuid' => 'postgres-api-legacy-test',
'name' => 'Postgres api legacy test',
'image' => 'postgres:16-alpine',
'postgres_password' => 'password',
...$test->resourceAttributes,
]);
$application = Application::factory()->create($test->resourceAttributes);
$test->routes = [
'service' => ["/api/v1/services/{$test->service->uuid}/envs", legacyVariable($test->service, ['key' => 'CSP', 'value' => "a'b"])],
'service bulk' => ["/api/v1/services/{$test->service->uuid}/envs/bulk", legacyVariable($test->service, ['key' => 'BULK', 'value' => "a'b"])],
'database' => ["/api/v1/databases/{$database->uuid}/envs", legacyVariable($database, ['key' => 'CSP', 'value' => "a'b"])],
'database bulk' => ["/api/v1/databases/{$database->uuid}/envs/bulk", legacyVariable($database, ['key' => 'BULK', 'value' => "a'b"])],
'application' => ["/api/v1/applications/{$application->uuid}/envs", legacyVariable($application, ['key' => 'CSP', 'value' => "a'b"])],
'application bulk' => ["/api/v1/applications/{$application->uuid}/envs/bulk", legacyVariable($application, ['key' => 'BULK', 'value' => "a'b"])],
];
return $headers;
}
function patchLegacyEscapingVariable(object $test, array $headers, string $route, string $value): EnvironmentVariable
{
[$url, $variable] = $test->routes[$route];
$payload = ['key' => $variable->key, 'value' => $value];
$test->withHeaders($headers)
->patchJson($url, str_ends_with($url, '/bulk') ? ['data' => [$payload]] : $payload)
->assertStatus(201);
return $variable->fresh();
}
test('api updates with the same value keep legacy escaping', function (string $route) {
$headers = apiLegacyEscapingFixture($this);
expect(patchLegacyEscapingVariable($this, $headers, $route, "a'b")->uses_legacy_escaping)->toBeTrue();
})->with(['service', 'service bulk', 'database', 'database bulk', 'application', 'application bulk']);
test('api updates with a new value keep legacy escaping', function (string $route) {
$headers = apiLegacyEscapingFixture($this);
expect(patchLegacyEscapingVariable($this, $headers, $route, "a'c")->uses_legacy_escaping)->toBeTrue();
})->with(['service', 'service bulk', 'database', 'database bulk', 'application', 'application bulk']);
test('the resolved value shows what the container gets for exact and legacy variables', function (array $attributes, string $exact, string $legacy) {
$exactVariable = $this->service->environment_variables()->create(['key' => 'EXACT', ...$attributes]);
$legacyVariable = legacyVariable($this->service, ['key' => 'LEGACY', ...$attributes]);
expect($exactVariable->fresh()->real_value)->toBe($exact)
->and($legacyVariable->real_value)->toBe($legacy);
})->with([
'plain with quotes' => [['value' => 'a\'b"c\\d'], 'a\'b"c\\d', 'a\\\'b\\"c\\\\d'],
'literal' => [['value' => 'x$y', 'is_literal' => true], 'x$y', "'x\$y'"],
'json' => [['value' => '{"a":"b"}'], '{"a":"b"}', '{"a":"b"}'],
]);
test('the resolved value of an exact shared reference has no escaping', function () {
SharedEnvironmentVariable::query()->create([
'key' => 'CSP',
'value' => "array:'self'",
'type' => 'environment',
'environment_id' => $this->environment->id,
'team_id' => $this->team->id,
]);
$variable = $this->service->environment_variables()->create(['key' => 'CSP', 'value' => '{{environment.CSP}}']);
expect($variable->fresh()->real_value)->toBe("array:'self'");
});
test('log redaction covers the exact and the old escaped forms of a value', function () {
$variable = $this->service->environment_variables()->create(['key' => 'SECRET', 'value' => "p4ss'word"]);
expect($variable->fresh()->logRedactionValues())->toContain("p4ss'word")->toContain("p4ss\\'word");
});
test('the api shows the resolved value by escaping and never shows the legacy flag', function () {
session(['currentTeam' => $this->team]);
$token = $this->user->createToken('test-token', ['*'])->plainTextToken;
$this->service->environment_variables()->create(['key' => 'EXACT', 'value' => "a'b"]);
legacyVariable($this->service, ['key' => 'LEGACY', 'value' => "a'b"]);
$response = $this->withHeaders(['Authorization' => "Bearer {$token}"])
->getJson("/api/v1/services/{$this->service->uuid}/envs")
->assertOk();
$variables = collect($response->json())->keyBy('key');
expect($variables['EXACT']['real_value'])->toBe("a'b")
->and($variables['LEGACY']['real_value'])->toBe("a\\'b")
->and($variables['EXACT'])->not->toHaveKey('uses_legacy_escaping')
->and($variables['LEGACY'])->not->toHaveKey('uses_legacy_escaping');
});
test('the editor shows the resolved value by escaping', function () {
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
SharedEnvironmentVariable::query()->create([
'key' => 'CSP',
'value' => "array:'self'",
'type' => 'environment',
'environment_id' => $this->environment->id,
'team_id' => $this->team->id,
]);
$exact = $this->service->environment_variables()->create(['key' => 'EXACT', 'value' => '{{environment.CSP}}']);
$legacy = legacyVariable($this->service, ['key' => 'LEGACY', 'value' => '{{environment.CSP}}']);
Livewire::test(Show::class, ['env' => $exact->fresh(), 'type' => 'service'])
->call('loadValues')
->assertSet('real_value', "array:'self'");
Livewire::test(Show::class, ['env' => $legacy, 'type' => 'service'])
->call('loadValues')
->assertSet('real_value', "array:\\'self\\'");
});
test('the legacy escaping hint follows how each resource type used the old escaping', function (string $type, array $attributes, bool $expected) {
$resource = match ($type) {
'application' => Application::factory()->create($this->resourceAttributes),
'database' => StandalonePostgresql::forceCreate([
'uuid' => 'postgres-hint-test',
'name' => 'Postgres hint test',
'image' => 'postgres:16-alpine',
'postgres_password' => 'password',
...$this->resourceAttributes,
]),
};
$variable = legacyVariable($resource, ['key' => 'HINT', ...$attributes]);
expect($resource->legacyEscapingChangesValue($variable))->toBe($expected);
})->with([
'application plain with a quote' => ['application', ['value' => "a'b"], true],
'application literal without a quote' => ['application', ['value' => 'x$y', 'is_literal' => true], false],
'database literal adds quotes' => ['database', ['value' => 'x', 'is_literal' => true], true],
'database plain value' => ['database', ['value' => 'plain'], false],
]);
@@ -0,0 +1,101 @@
<?php
use App\Actions\Database\StartDragonfly;
use App\Actions\Database\StartKeydb;
use App\Actions\Database\StartRedis;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneRedis;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::query()->firstOrCreate(['id' => 0]);
$team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $team->id]);
$destination = $server->standaloneDockers()->firstOrFail();
$project = Project::factory()->create(['team_id' => $team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$this->databaseAttributes = [
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
];
});
/**
* Compose interpolates `$` in `command:` before it splits the string into words,
* so the password must be shell-quoted and every `$` must be doubled.
*/
function redisFamilyStartCommand(object $action, object $database): string
{
$action->database = $database;
(new ReflectionMethod($action, 'generate_environment_variables'))->invoke($action);
return (new ReflectionMethod($action, 'buildStartCommand'))->invoke($action);
}
test('redis start command quotes the password and keeps dollars literal', function () {
$redis = StandaloneRedis::forceCreate([
'uuid' => 'redis-password-test',
'name' => 'Redis password test',
'image' => 'redis:7-alpine',
...$this->databaseAttributes,
]);
$redis->runtime_environment_variables()->create([
'key' => 'REDIS_PASSWORD',
'value' => "p4\$\$w'o rd\$X",
]);
expect(redisFamilyStartCommand(new StartRedis, $redis))
->toBe("redis-server --requirepass 'p4\$\$\$\$w'\\''o rd\$\$X' --appendonly yes");
});
test('keydb start command keeps dollars literal for exact password variables', function () {
$keydb = StandaloneKeydb::forceCreate([
'uuid' => 'keydb-password-test',
'name' => 'KeyDB password test',
'image' => 'eqalpha/keydb:latest',
'keydb_password' => 'column-password',
...$this->databaseAttributes,
]);
$keydb->runtime_environment_variables()->create([
'key' => 'REDIS_PASSWORD',
'value' => 'p4$$word',
]);
expect(redisFamilyStartCommand(new StartKeydb, $keydb))
->toBe("keydb-server --requirepass 'p4\$\$\$\$word' --appendonly yes");
});
test('dragonfly start command keeps dollars literal for exact password variables', function () {
$dragonfly = StandaloneDragonfly::forceCreate([
'uuid' => 'dragonfly-password-test',
'name' => 'Dragonfly password test',
'image' => 'docker.dragonflydb.io/dragonflydb/dragonfly',
'dragonfly_password' => 'column-password',
...$this->databaseAttributes,
]);
$dragonfly->runtime_environment_variables()->create([
'key' => 'REDIS_PASSWORD',
'value' => 'p4$$word',
]);
expect(redisFamilyStartCommand(new StartDragonfly, $dragonfly))
->toBe("dragonfly --requirepass 'p4\$\$\$\$word'");
});
test('keydb and dragonfly healthchecks use the compose-escaped password', function () {
expect(file_get_contents(__DIR__.'/../../app/Actions/Database/StartKeydb.php'))
->toContain("'CMD', 'keydb-cli', '--pass', \$this->resolvedRedisPassword, 'ping'");
expect(file_get_contents(__DIR__.'/../../app/Actions/Database/StartDragonfly.php'))
->toContain("'CMD', 'redis-cli', '-a', \$this->resolvedRedisPassword, 'ping'");
});
@@ -149,11 +149,11 @@ test('literal service variables with remote secrets are written as valid dotenv
expect(remoteSecretLifecycleWrittenEnvFile($service))->toContain('API_KEY="it\'s"');
});
test('service variables without remote secrets keep their .env format', function () {
test('service variables without remote secrets use exact .env escaping', function () {
$service = remoteSecretLifecycleService();
$service->environment_variables()->create(['key' => 'PLAIN', 'value' => 'say "hi"']);
expect(remoteSecretLifecycleWrittenEnvFile($service))->toContain('PLAIN=say \"hi\"');
expect(remoteSecretLifecycleWrittenEnvFile($service))->toContain('PLAIN="say \"hi\""');
Http::assertNothingSent();
});
@@ -220,7 +220,7 @@ test('a literal service variable keeps reference text when no secret manager sou
$service->ensureRemoteSecretsResolvable($service->environment_variables()->get());
expect(remoteSecretLifecycleWrittenEnvFile($service))->toContain("TEMPLATE='Hello {{vault.NAME}}'");
expect(remoteSecretLifecycleWrittenEnvFile($service))->toContain('TEMPLATE="Hello {{vault.NAME}}"');
Http::assertNothingSent();
});
@@ -0,0 +1,132 @@
<?php
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\IntegrationToken;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::query()->firstOrCreate(['id' => 0]);
$this->team = Team::factory()->create();
$server = Server::factory()->create(['team_id' => $this->team->id]);
$destination = $server->standaloneDockers()->firstOrFail();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$environment = Environment::factory()->create(['project_id' => $project->id]);
$this->service = Service::factory()->create([
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
});
function serviceEnvironmentFileLine(Service $service, array $attributes): string
{
$environmentVariable = $service->environment_variables()->create($attributes);
return $service->composeEnvironmentFileLine($environmentVariable);
}
test('literal service values keep quotes, dollars, and backticks exact', function () {
$line = serviceEnvironmentFileLine($this->service, [
'key' => 'CSP',
'value' => 'array:\'self\',blob:,https://* "quoted" $HOME `id` C:\\path',
'is_literal' => true,
]);
expect($line)->toBe('CSP="array:\'self\',blob:,https://* \\"quoted\\" \\$HOME `id` C:\\\\path"');
});
test('non-literal service values keep single quotes without backslashes', function () {
$line = serviceEnvironmentFileLine($this->service, [
'key' => 'CSP',
'value' => "array:'self',blob:,https://*",
]);
expect($line)->toBe('CSP="array:\'self\',blob:,https://*"');
});
test('non-literal service values keep SERVICE variable interpolation', function () {
$line = serviceEnvironmentFileLine($this->service, [
'key' => 'DATABASE_URL',
'value' => 'postgres://$SERVICE_USER_POSTGRES:${SERVICE_PASSWORD_POSTGRES}@postgres:5432/app',
]);
expect($line)->toBe('DATABASE_URL="postgres://$SERVICE_USER_POSTGRES:${SERVICE_PASSWORD_POSTGRES}@postgres:5432/app"');
});
test('multiline json service values are quoted and not interpolated', function () {
$json = "{\n \"price\": \"\$5\",\n \"name\": \"it's\"\n}";
$line = serviceEnvironmentFileLine($this->service, [
'key' => 'CONFIG',
'value' => $json,
]);
expect($line)->toBe("CONFIG=\"{\n \\\"price\\\": \\\"\\\$5\\\",\n \\\"name\\\": \\\"it's\\\"\n}\"");
});
test('json service values with leading whitespace are not interpolated', function () {
$line = serviceEnvironmentFileLine($this->service, [
'key' => 'CONFIG',
'value' => "\n {\"price\":\"\$HOME\"}",
]);
expect($line)->toBe('CONFIG="{\\"price\\":\\"\\$HOME\\"}"');
});
test('multiline service values are quoted and not interpolated', function () {
$line = serviceEnvironmentFileLine($this->service, [
'key' => 'CERT',
'value' => "line 'one'\nline \$two",
'is_multiline' => true,
]);
expect($line)->toBe("CERT=\"line 'one'\nline \\\$two\"");
});
test('remote secret service values are written literally', function () {
Http::fake([
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
'API_KEY' => 'p4$$w\'ord"',
]),
]);
$token = IntegrationToken::query()->create([
'team_id' => $this->team->id,
'provider' => 'doppler',
'name' => 'Service secrets',
'token' => 'the-secret-token',
'capabilities' => ['secrets'],
]);
$this->service->secretManagerLink()->create(['integration_token_id' => $token->id]);
$line = serviceEnvironmentFileLine($this->service, [
'key' => 'API_KEY',
'value' => '{{vault.API_KEY}}',
]);
expect($line)->toBe('API_KEY="p4$$$$w\'ord\\""');
});
test('empty service values stay empty', function () {
$line = serviceEnvironmentFileLine($this->service, [
'key' => 'EMPTY',
'value' => null,
]);
expect($line)->toBe('EMPTY=');
});
test('saveComposeConfigs writes compose env file lines', function () {
$source = file_get_contents(__DIR__.'/../../app/Models/Service.php');
expect($source)->toContain('$envs->push($this->composeEnvironmentFileLine($env));');
});
+34
View File
@@ -0,0 +1,34 @@
<?php
test('escapeComposeEnvFileValue wraps simple values in double quotes', function () {
expect(escapeComposeEnvFileValue('runtime-only-value'))->toBe('"runtime-only-value"');
});
test('escapeComposeEnvFileValue keeps mixed quotes and dollars literal when interpolation is off', function () {
$value = 'hello world; quotes" \'$`';
$escaped = escapeComposeEnvFileValue($value, allowInterpolation: false);
expect($escaped)->toBe('"hello world; quotes\\" \'\\$`"');
});
test('escapeComposeEnvFileValue blocks command substitution when interpolation is off', function () {
$escaped = escapeComposeEnvFileValue('$(touch /tmp/should-not-exist)', allowInterpolation: false);
expect($escaped)->toBe('"\\$(touch /tmp/should-not-exist)"');
});
test('escapeComposeEnvFileValue leaves $VAR intact when interpolation is on', function () {
expect(escapeComposeEnvFileValue('$BOTH_PHASES', allowInterpolation: true))->toBe('"$BOTH_PHASES"');
});
test('escapeComposeEnvFileValue preserves multiline literals', function () {
$escaped = escapeComposeEnvFileValue("line one\nline two", allowInterpolation: false);
expect($escaped)->toBe("\"line one\nline two\"");
});
test('escapeComposeEnvFileValue escapes backslashes and double quotes', function () {
expect(escapeComposeEnvFileValue('C:\\path\\"quoted"'))
->toBe('"C:\\\\path\\\\\\"quoted\\""');
});
+2 -1
View File
@@ -74,10 +74,11 @@ it('realValue accessor still escapes non-JSON values with quotes', function () {
$env->shouldReceive('getAttribute')->with('value')->andReturn('hello "world"');
$env->shouldReceive('getAttribute')->with('is_literal')->andReturn(false);
$env->shouldReceive('getAttribute')->with('is_multiline')->andReturn(false);
$env->shouldReceive('getAttribute')->with('uses_legacy_escaping')->andReturn(true);
$realValue = $env->real_value;
// Non-JSON should still be escaped by escapeEnvVariables
// Variables saved before exact escaping are still escaped by escapeEnvVariables
expect($realValue)->toContain('\\"');
expect($realValue)->toBe('hello \\"world\\"');
});
@@ -0,0 +1,84 @@
<?php
it('reuses resolved environment credentials in database startup integrations', function (string $action, array $expected, array $unexpected) {
$source = file_get_contents(__DIR__."/../../app/Actions/Database/{$action}.php");
expect($source)->toContain(...$expected)
->not->toContain(...$unexpected);
})->with([
'clickhouse' => [
'StartClickhouse',
['$this->resolvedClickhouseUser', '$this->resolvedClickhousePassword'],
['$this->database->clickhouse_admin_user, \'--password\'', '$this->database->clickhouse_admin_password, \'--query\''],
],
'dragonfly' => [
'StartDragonfly',
['$this->resolvedRedisPassword'],
['$this->database->dragonfly_password, \'ping\'', 'requirepass {$this->database->dragonfly_password}'],
],
'keydb' => [
'StartKeydb',
['$this->resolvedRedisPassword'],
['$this->database->keydb_password, \'ping\'', 'requirepass {$this->database->keydb_password}'],
],
'mongodb' => [
'StartMongodb',
['$this->resolvedMongoDatabase', '$this->resolvedMongoUsername', '$this->resolvedMongoPassword'],
['json_encode($this->database->mongo_initdb_database', 'json_encode($this->database->mongo_initdb_root_username', 'json_encode($this->database->mongo_initdb_root_password'],
],
'mysql' => [
'StartMysql',
['$this->resolvedMysqlRootPassword'],
['-p{$this->database->mysql_root_password}'],
],
'postgresql' => [
'StartPostgresql',
['$this->resolvedPostgresUser', '$this->resolvedPostgresDatabase'],
['$this->database->postgres_user, \'-d\'', '$this->database->postgres_db, \'-c\''],
],
]);
it('runs database start commands without persisting them through remote process', function (string $action) {
$source = file_get_contents(__DIR__."/../../app/Actions/Database/{$action}.php");
expect($source)
->toContain('ExecutesDatabaseStartCommands')
->toContain('executeDatabaseStartCommands(')
->not->toContain('return remote_process(');
})->with([
'StartClickhouse',
'StartDragonfly',
'StartKeydb',
'StartMariadb',
'StartMongodb',
'StartMysql',
'StartPostgresql',
'StartRedis',
'StartSqlite',
]);
it('queues database starts with identifiers instead of generated commands', function () {
$source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartDatabase.php');
expect($source)
->toContain('DatabaseStartJob::dispatch(')
->not->toContain('StartPostgresql::run(')
->not->toContain('StartRedis::run(');
});
it('keeps raw secret values separate from compose environment formatting', function (string $action, array $rawAssignments) {
$source = file_get_contents(__DIR__."/../../app/Actions/Database/{$action}.php");
expect($source)
->toContain('$rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);')
->toContain('$resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);')
->toContain('$environment_variables->push($env->key.\'=\'.$resolvedValue);')
->toContain(...$rawAssignments);
})->with([
'clickhouse' => ['StartClickhouse', ['$this->resolvedClickhouseUser = $composeFileValue;', '$this->resolvedClickhousePassword = $composeFileValue;']],
'dragonfly' => ['StartDragonfly', ['$this->resolvedRedisPassword = $this->database->formatComposeFileValue($env, $rawValue);', '$this->requirePassArgument()']],
'keydb' => ['StartKeydb', ['$this->resolvedRedisPassword = $this->database->formatComposeFileValue($env, $rawValue);', '$this->requirePassArgument()']],
'mongodb' => ['StartMongodb', ['$this->resolvedMongoUsername = $rawValue;', '$this->resolvedMongoPassword = $rawValue;', '$this->resolvedMongoDatabase = $rawValue;', 'json_encode($this->resolvedMongoPassword']],
'mysql' => ['StartMysql', ['$this->resolvedMysqlRootPassword = $composeFileValue;']],
'postgresql' => ['StartPostgresql', ['$this->resolvedPostgresUser = $composeFileValue;', '$this->resolvedPostgresDatabase = $composeFileValue;']],
]);
@@ -913,6 +913,26 @@ test('import rejects shell-like file ownership and mode metadata', function (str
'option mode' => ['chmod', '--reference=/etc/passwd'],
]);
test('transfer keeps the legacy escaping flag and treats old exports as legacy', function () {
$secret = $this->application->environment_variables()->where('key', 'APP_SECRET')->firstOrFail();
$secret->uses_legacy_escaping = true;
$secret->save();
$bundle = $this->exporter->export($this->server);
$exported = collect($bundle['projects'][0]['environments'][0]['applications'][0]['environment_variables'])
->firstWhere('key', 'APP_SECRET');
$importEnvVars = new ReflectionMethod($this->importer, 'importEnvVars');
$importEnvVars->invoke($this->importer, [
['key' => 'EXACT_VAR', 'value' => 'a', 'uses_legacy_escaping' => false],
['key' => 'OLD_EXPORT_VAR', 'value' => 'b'],
], $this->application, false);
expect($exported['uses_legacy_escaping'])->toBeTrue()
->and($this->application->environment_variables()->where('key', 'EXACT_VAR')->first()->uses_legacy_escaping)->toBeFalse()
->and($this->application->environment_variables()->where('key', 'OLD_EXPORT_VAR')->first()->uses_legacy_escaping)->toBeTrue();
});
test('transfer keeps whether a volume ignores the Compose driver options', function (?bool $exported, bool $expected) {
LocalPersistentVolume::query()->update(['ignores_compose_driver_options' => (bool) $exported]);
$bundle = $this->exporter->export($this->server);