|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 $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 puts literal and multiline values in single quotes without escaping. Docker * Compose ends the value at the next quote, so a value with a quote breaks the whole .env file. */ private function legacyEnvFileLineIsUnreadable(EnvironmentVariable $environmentVariable): bool { if (! $environmentVariable->is_literal && ! $environmentVariable->is_multiline) { return false; } $value = $this->resolvedEnvironmentVariableValue($environmentVariable); return is_string($value) && str_contains($value, "'") && ! (json_validate($value) && (str_starts_with($value, '{') || str_starts_with($value, '['))); } /** * 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 */ 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 */ public function resolvedSecretManagerValuesForRedaction(): array { return $this->resolvedSecretManagerValues ?? []; } }