mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
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>
354 lines
14 KiB
PHP
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 ?? [];
|
|
}
|
|
}
|