Files
coolify/app/Traits/HasSecretManager.php
Andras BacsaiandClaude Opus 5.5 0b45d8dd6c fix(env): quote old multi-line JSON values in the .env file
Variables saved before exact escaping wrote JSON objects and arrays
without quotes. A multi-line JSON value broke the .env file, so Docker
Compose stopped the deployment. These values now use exact escaping,
which gives the container the same value as before beta.463.

Closes #8279

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 22:34:42 +01:00

354 lines
14 KiB
PHP

<?php
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;
use Illuminate\Database\Eloquent\Relations\MorphOne;
use Throwable;
trait HasSecretManager
{
/** @var array<string, string>|null */
private ?array $resolvedSecretManagerValues = null;
public static function bootHasSecretManager(): void
{
// Return nothing: a non-null result halts the "deleting" event and skips later listeners.
static::deleting(function ($resource): void {
$resource->secretManagerLink()->delete();
});
}
public function secretManagerLink(): MorphOne
{
return $this->morphOne(SecretManagerLink::class, 'resourceable');
}
/**
* Give a cloned resource the same secret manager source. The token is team-scoped, so a clone in another team gets no link.
*/
public function cloneSecretManagerLinkTo(Model $target): void
{
$link = $this->secretManagerLink()->with('integrationToken')->first();
// fresh(): a replicated model keeps the relations of its source, such as the old environment.
$targetTeamId = $target->fresh()?->team()?->id;
if (! $link || $targetTeamId === null || (int) $link->integrationToken?->team_id !== (int) $targetTeamId) {
return;
}
$target->secretManagerLink()->create([
'integration_token_id' => $link->integration_token_id,
'settings' => $link->settings,
]);
}
/**
* The value for an `environment:` entry of a generated compose file (standalone databases).
*/
public function resolveSecretManagerEnvironmentVariable(EnvironmentVariable $environmentVariable): ?string
{
$value = $this->resolveSecretManagerEnvironmentVariableValue($environmentVariable);
return $this->formatEnvironmentVariableValue($environmentVariable, $value);
}
/**
* The value for a line of a dotenv file that Docker Compose reads (the service .env file).
*/
public function resolveSecretManagerDotenvValue(EnvironmentVariable $environmentVariable): ?string
{
$value = $this->resolveSecretManagerEnvironmentVariableValue($environmentVariable);
if ($value !== null && $this->environmentVariableUsesSecretManager($environmentVariable)) {
return RemoteSecretValueFormatter::dotenv($value);
}
return $value === null ? null : ($this->useExactEscaping($environmentVariable)
? escapeComposeEnvFileValue($value, $this->environmentVariableAllowsInterpolation($environmentVariable, $value))
: $this->legacyFormatEnvironmentVariableValue($environmentVariable, $value));
}
/**
* Formats a resolved value for an `environment:` entry of a generated compose file. Remote secret
* values are used exactly as they are; other values keep their existing format.
*/
public function formatEnvironmentVariableValue(EnvironmentVariable $environmentVariable, ?string $value): ?string
{
if ($value !== null && $this->environmentVariableUsesSecretManager($environmentVariable)) {
return RemoteSecretValueFormatter::composeFile($value);
}
return $this->formatLocalEnvironmentVariableValue($environmentVariable, $value);
}
/**
* Formats a resolved value that is placed directly in a generated compose file, such as a
* password in a command or healthcheck. Remote secret values are escaped for compose
* interpolation; other values stay unchanged, as before.
*/
public function formatComposeFileValue(EnvironmentVariable $environmentVariable, string $value): string
{
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);
}
/**
* Resolves every remote secret reference of the given variables now, so that an unreachable
* secret manager or a missing key stops an operation before it stops the running resource.
* The fetched secrets stay cached on this model for the start that follows.
*
* @param iterable<EnvironmentVariable> $environmentVariables
*
* @throws RemoteSecretException
*/
public function ensureRemoteSecretsResolvable(iterable $environmentVariables): void
{
foreach ($environmentVariables as $environmentVariable) {
$this->resolveSecretManagerEnvironmentVariableValue($environmentVariable);
}
}
private function formatLocalEnvironmentVariableValue(EnvironmentVariable $environmentVariable, ?string $value): ?string
{
if ($value === null) {
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. Compose references that got the stored value before the upgrade
* (such as `FOO=${FOO:-}`) use exact escaping, so they keep getting that value. So do old
* .env lines that Docker Compose cannot read, because they stop the deployment.
*/
public function useExactEscaping(EnvironmentVariable $environmentVariable): bool
{
if (! $environmentVariable->uses_legacy_escaping) {
return true;
}
if (! $this instanceof Service && ! $this instanceof Application) {
return false;
}
if ($this->legacyEnvFileLineIsUnreadable($environmentVariable)) {
return true;
}
$parsesCompose = $this instanceof Service
|| ($this->build_pack === 'dockercompose' && ! $this->settings?->is_raw_compose_deployment_enabled);
return $parsesCompose
&& dockerComposeVariablesWithStoredValueBeforeExactEscaping($this->docker_compose_raw)->contains($environmentVariable->key);
}
/**
* The old escaping writes JSON objects and arrays without quotes, so a JSON value with a line
* break breaks the whole .env file (#8279). It puts other literal and multiline values in single
* quotes without escaping, and Docker Compose ends the value at the next quote.
*/
private function legacyEnvFileLineIsUnreadable(EnvironmentVariable $environmentVariable): bool
{
$value = $this->resolvedEnvironmentVariableValue($environmentVariable);
if (! is_string($value)) {
return false;
}
if (json_validate($value) && (str_starts_with($value, '{') || str_starts_with($value, '['))) {
return str_contains($value, "\n");
}
return ($environmentVariable->is_literal || $environmentVariable->is_multiline) && str_contains($value, "'");
}
/**
* 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;
}
return $environmentVariable->is_literal || $environmentVariable->is_multiline
? "'{$value}'"
: 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 (values with a quote use exact
// escaping); database environment lists keep them.
return ! ($this instanceof Service || $this instanceof Application);
}
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);
if ($value === null) {
return null;
}
if ($this->resolvesRemoteSecretReferences($environmentVariable, $value)) {
$secrets = $this->secretManagerValues();
$missing = RemoteSecretReferences::missingKeys($value, $secrets);
if ($missing !== []) {
throw new RemoteSecretException('Missing secret keys: '.implode(', ', $missing)." (referenced by {$environmentVariable->key}).");
}
$value = RemoteSecretReferences::substitute($value, $secrets);
}
return $value;
}
public function environmentVariableUsesSecretManager(EnvironmentVariable $environmentVariable): bool
{
return $this->resolvesRemoteSecretReferences(
$environmentVariable,
$this->resolvedEnvironmentVariableValue($environmentVariable),
);
}
/**
* Without a source, a literal keeps "{{vault.KEY}}" as text; other references fail closed.
*/
private function resolvesRemoteSecretReferences(EnvironmentVariable $environmentVariable, ?string $value): bool
{
if (! RemoteSecretReferences::containsReference($value)) {
return false;
}
return ! $environmentVariable->is_literal
|| $this->resolvedSecretManagerValues !== null
|| $this->secretManagerLink()->exists();
}
private function resolvedEnvironmentVariableValue(EnvironmentVariable $environmentVariable): ?string
{
return $environmentVariable->get_real_environment_variables_with_server(
$environmentVariable->value,
$this,
data_get($this, 'server'),
);
}
/** @return array<string, string> */
private function secretManagerValues(): array
{
if ($this->resolvedSecretManagerValues !== null) {
return $this->resolvedSecretManagerValues;
}
$link = $this->secretManagerLink()->with('integrationToken')->first();
if (! $link) {
throw new RemoteSecretException('Environment variables reference remote secrets, but no secret manager source is configured.');
}
try {
return $this->resolvedSecretManagerValues = $link->fetchSecrets();
} catch (RemoteSecretException $e) {
throw $e;
} catch (Throwable $e) {
throw new RemoteSecretException('Could not fetch remote secrets: '.$e->getMessage(), previous: $e);
}
}
/** @return array<string, string> */
public function resolvedSecretManagerValuesForRedaction(): array
{
return $this->resolvedSecretManagerValues ?? [];
}
}