mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
fix(secret-manager): use remote secret values exactly and check them before a restart
- Write remote secret values as exact dotenv literals in the service .env file and escape "$" where they go directly into a generated compose file (environment, start commands, healthchecks). - Resolve remote secrets before a service or database restart stops the running resource, and show the secret manager error instead of a generic start failure (new RemoteSecretException). - Cache fetched key names for the autocomplete and rate limit how often a user can contact the secret manager. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
25a4076bd0
commit
9bdbbeef7a
15 files changed
+484
-26
No files matched your search
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Actions\Database;
|
||||
|
||||
use App\Exceptions\RemoteSecretException;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Models\StandaloneKeydb;
|
||||
@@ -49,6 +50,12 @@ class RestartDatabase
|
||||
if ($prerequisiteError !== null) {
|
||||
return $prerequisiteError;
|
||||
}
|
||||
// Check remote secrets before stopping: if the secret manager fails, the database keeps running.
|
||||
try {
|
||||
$database->ensureRemoteSecretsResolvable($database->runtime_environment_variables()->get());
|
||||
} catch (RemoteSecretException $e) {
|
||||
return $e->getMessage();
|
||||
}
|
||||
StopDatabase::run($database, dockerCleanup: false);
|
||||
// The stop can take a while; keep the reservation for the start that follows.
|
||||
DatabaseOperationReservation::renew($database->uuid, $reservation);
|
||||
|
||||
@@ -158,11 +158,13 @@ class StartClickhouse
|
||||
foreach ($this->database->runtime_environment_variables as $env) {
|
||||
$rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);
|
||||
$resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);
|
||||
// Credentials below are placed directly in the compose file (healthcheck, command).
|
||||
$composeFileValue = $this->database->formatComposeFileValue($env, $rawValue);
|
||||
$environment_variables->push($env->key.'='.$resolvedValue);
|
||||
if ($env->key === 'CLICKHOUSE_USER') {
|
||||
$this->resolvedClickhouseUser = $rawValue;
|
||||
$this->resolvedClickhouseUser = $composeFileValue;
|
||||
} elseif ($env->key === 'CLICKHOUSE_PASSWORD') {
|
||||
$this->resolvedClickhousePassword = $rawValue;
|
||||
$this->resolvedClickhousePassword = $composeFileValue;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -255,9 +255,11 @@ class StartMysql
|
||||
foreach ($this->database->runtime_environment_variables as $env) {
|
||||
$rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);
|
||||
$resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);
|
||||
// Credentials below are placed directly in the compose file (healthcheck, command).
|
||||
$composeFileValue = $this->database->formatComposeFileValue($env, $rawValue);
|
||||
$environment_variables->push($env->key.'='.$resolvedValue);
|
||||
if ($env->key === 'MYSQL_ROOT_PASSWORD') {
|
||||
$this->resolvedMysqlRootPassword = $rawValue;
|
||||
$this->resolvedMysqlRootPassword = $composeFileValue;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -264,11 +264,13 @@ class StartPostgresql
|
||||
foreach ($this->database->runtime_environment_variables as $env) {
|
||||
$rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);
|
||||
$resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);
|
||||
// Credentials below are placed directly in the compose file (healthcheck, command).
|
||||
$composeFileValue = $this->database->formatComposeFileValue($env, $rawValue);
|
||||
$environment_variables->push($env->key.'='.$resolvedValue);
|
||||
if ($env->key === 'POSTGRES_USER') {
|
||||
$this->resolvedPostgresUser = $rawValue;
|
||||
$this->resolvedPostgresUser = $composeFileValue;
|
||||
} elseif ($env->key === 'POSTGRES_DB') {
|
||||
$this->resolvedPostgresDatabase = $rawValue;
|
||||
$this->resolvedPostgresDatabase = $composeFileValue;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Actions\Database;
|
||||
|
||||
use App\Exceptions\DatabaseStartException;
|
||||
use App\Helpers\SslHelper;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\SslCertificate;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Traits\ExecutesDatabaseStartCommands;
|
||||
@@ -252,7 +253,7 @@ class StartRedis
|
||||
$environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env));
|
||||
|
||||
if ($env->key === 'REDIS_PASSWORD') {
|
||||
$this->resolvedRedisPassword = $this->database->resolveSecretManagerEnvironmentVariableValue($env);
|
||||
$this->resolvedRedisPassword = $this->redisPasswordForComposeFile($env);
|
||||
|
||||
if (! $usesSecretManager) {
|
||||
$this->database->update(['redis_password' => $this->resolvedRedisPassword]);
|
||||
@@ -274,7 +275,7 @@ class StartRedis
|
||||
}
|
||||
|
||||
if ($env->key === 'REDIS_PASSWORD') {
|
||||
$this->resolvedRedisPassword = $this->database->resolveSecretManagerEnvironmentVariableValue($env);
|
||||
$this->resolvedRedisPassword = $this->redisPasswordForComposeFile($env);
|
||||
} elseif ($env->key === 'REDIS_USERNAME') {
|
||||
$this->resolvedRedisUsername = $this->database->resolveSecretManagerEnvironmentVariableValue($env);
|
||||
}
|
||||
@@ -288,6 +289,17 @@ class StartRedis
|
||||
return $environment_variables->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* The password is placed directly in the compose command, so a remote secret value is escaped
|
||||
* the same way as its environment entry. Other values stay unchanged.
|
||||
*/
|
||||
private function redisPasswordForComposeFile(EnvironmentVariable $env): ?string
|
||||
{
|
||||
$value = $this->database->resolveSecretManagerEnvironmentVariableValue($env);
|
||||
|
||||
return $value === null ? null : $this->database->formatComposeFileValue($env, $value);
|
||||
}
|
||||
|
||||
private function buildStartCommand(): string
|
||||
{
|
||||
$redisPassword = $this->resolvedRedisPassword ?? $this->database->redis_password;
|
||||
|
||||
@@ -24,6 +24,9 @@ class StartService
|
||||
public function handle(Service $service, bool $pullLatestImages = false, bool $stopBeforeStart = false)
|
||||
{
|
||||
$service->parse();
|
||||
// Fetch remote secrets before stopping: if the secret manager fails, the service keeps running.
|
||||
// saveComposeConfigs() below reuses the fetched secrets.
|
||||
$service->ensureRemoteSecretsResolvable($service->environment_variables()->get());
|
||||
if ($this->shouldStopBeforeStarting($pullLatestImages, $stopBeforeStart)) {
|
||||
StopService::run(service: $service, dockerCleanup: false);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Remote secrets of a resource could not be resolved, for example because the secret manager is
|
||||
* unreachable or a referenced key does not exist. The message names no secret values, so it can be
|
||||
* shown to the user.
|
||||
*/
|
||||
class RemoteSecretException extends RuntimeException {}
|
||||
@@ -22,6 +22,7 @@ use App\Models\SwarmDocker;
|
||||
use App\Notifications\Application\DeploymentFailed;
|
||||
use App\Notifications\Application\DeploymentSuccess;
|
||||
use App\Support\RemoteSecretReferences;
|
||||
use App\Support\RemoteSecretValueFormatter;
|
||||
use App\Support\ValidationPatterns;
|
||||
use App\Traits\EnvironmentVariableAnalyzer;
|
||||
use App\Traits\ExecuteRemoteCommand;
|
||||
@@ -1632,12 +1633,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
*/
|
||||
private function format_remote_secret_value(string $value): string
|
||||
{
|
||||
if (! str_contains($value, "'")) {
|
||||
return "'".$value."'";
|
||||
}
|
||||
|
||||
// Fall back to double quotes; $$ escapes compose interpolation.
|
||||
return '"'.str_replace(['\\', '"', '$'], ['\\\\', '\\"', '$$'], $value).'"';
|
||||
return RemoteSecretValueFormatter::dotenv($value);
|
||||
}
|
||||
|
||||
private function generate_runtime_environment_variables()
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Actions\Database\StartSqlite;
|
||||
use App\Enums\ProcessStatus;
|
||||
use App\Events\DatabaseStatusChanged;
|
||||
use App\Exceptions\DatabaseStartException;
|
||||
use App\Exceptions\RemoteSecretException;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Models\StandaloneKeydb;
|
||||
@@ -142,7 +143,9 @@ class DatabaseStartJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
ResourceStartActivity::markFailed(
|
||||
$activity,
|
||||
$exception instanceof DatabaseStartException ? $exception->getMessage() : 'Database start failed.',
|
||||
$exception instanceof DatabaseStartException || $exception instanceof RemoteSecretException
|
||||
? $exception->getMessage()
|
||||
: 'Database start failed.',
|
||||
);
|
||||
} finally {
|
||||
event(new DatabaseStatusChanged($this->userId));
|
||||
|
||||
@@ -1605,7 +1605,7 @@ class Service extends BaseModel
|
||||
return 3;
|
||||
});
|
||||
foreach ($sorted as $env) {
|
||||
$envs->push("{$env->key}={$this->resolveSecretManagerEnvironmentVariable($env)}");
|
||||
$envs->push("{$env->key}={$this->resolveSecretManagerDotenvValue($env)}");
|
||||
}
|
||||
if ($envs->count() === 0) {
|
||||
$commands[] = "touch {$environmentFile}";
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* Formats values fetched from a secret manager so that Docker Compose uses them exactly as they are:
|
||||
* no interpolation, no comments, and no extra lines.
|
||||
*/
|
||||
class RemoteSecretValueFormatter
|
||||
{
|
||||
/**
|
||||
* A value for a dotenv file that Docker Compose reads (a project .env or an env_file). Single
|
||||
* quotes keep the value literal, also newlines and "#". A value with a single quote, or one that
|
||||
* ends with a backslash (which would escape the closing quote), uses double quotes instead,
|
||||
* where \, " and $ must be escaped ($$ stops interpolation).
|
||||
*/
|
||||
public static function dotenv(string $value): string
|
||||
{
|
||||
if (! str_contains($value, "'") && ! str_ends_with($value, '\\')) {
|
||||
return "'".$value."'";
|
||||
}
|
||||
|
||||
return '"'.str_replace(['\\', '"', '$'], ['\\\\', '\\"', '$$'], $value).'"';
|
||||
}
|
||||
|
||||
/**
|
||||
* A value written directly into a generated compose file, such as an `environment:` entry,
|
||||
* a command, or a healthcheck. Compose does not parse quotes there and only interpolates "$".
|
||||
*/
|
||||
public static function composeFile(string $value): string
|
||||
{
|
||||
return str_replace('$', '$$', $value);
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use App\Exceptions\RemoteSecretException;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\SecretManagerLink;
|
||||
use App\Support\RemoteSecretReferences;
|
||||
use App\Support\RemoteSecretValueFormatter;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphOne;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
trait HasSecretManager
|
||||
{
|
||||
@@ -46,6 +48,9 @@ trait HasSecretManager
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The value for an `environment:` entry of a generated compose file (standalone databases).
|
||||
*/
|
||||
public function resolveSecretManagerEnvironmentVariable(EnvironmentVariable $environmentVariable): ?string
|
||||
{
|
||||
$value = $this->resolveSecretManagerEnvironmentVariableValue($environmentVariable);
|
||||
@@ -53,7 +58,62 @@ trait HasSecretManager
|
||||
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 $this->formatLocalEnvironmentVariableValue($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
|
||||
{
|
||||
return $this->environmentVariableUsesSecretManager($environmentVariable)
|
||||
? RemoteSecretValueFormatter::composeFile($value)
|
||||
: $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;
|
||||
@@ -81,7 +141,7 @@ trait HasSecretManager
|
||||
$missing = RemoteSecretReferences::missingKeys($value, $secrets);
|
||||
|
||||
if ($missing !== []) {
|
||||
throw new RuntimeException('Missing secret keys: '.implode(', ', $missing)." (referenced by {$environmentVariable->key}).");
|
||||
throw new RemoteSecretException('Missing secret keys: '.implode(', ', $missing)." (referenced by {$environmentVariable->key}).");
|
||||
}
|
||||
|
||||
$value = RemoteSecretReferences::substitute($value, $secrets);
|
||||
@@ -116,10 +176,16 @@ trait HasSecretManager
|
||||
$link = $this->secretManagerLink()->with('integrationToken')->first();
|
||||
|
||||
if (! $link) {
|
||||
throw new RuntimeException('Environment variables reference remote secrets, but no secret manager source is configured.');
|
||||
throw new RemoteSecretException('Environment variables reference remote secrets, but no secret manager source is configured.');
|
||||
}
|
||||
|
||||
return $this->resolvedSecretManagerValues = $link->fetchSecrets();
|
||||
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> */
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace App\Traits;
|
||||
|
||||
use App\Models\SecretManagerLink;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
||||
/**
|
||||
* Exposes the resource's secret manager source to the env-var-input
|
||||
@@ -17,6 +19,12 @@ trait HasSecretManagerAutocomplete
|
||||
return $this->secretManagerLinkForAutocomplete() !== null;
|
||||
}
|
||||
|
||||
/** Seconds that fetched key names are reused for the same secret manager source. */
|
||||
private const SECRET_MANAGER_KEYS_CACHE_SECONDS = 60;
|
||||
|
||||
/** Secret manager requests that one user can start from the autocomplete per minute. */
|
||||
private const SECRET_MANAGER_KEYS_REQUESTS_PER_MINUTE = 10;
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
@@ -30,15 +38,34 @@ trait HasSecretManagerAutocomplete
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$this->authorize('view', $link->resourceable);
|
||||
$keys = array_keys($link->fetchSecrets());
|
||||
sort($keys);
|
||||
$this->authorize('view', $link->resourceable);
|
||||
|
||||
return $keys;
|
||||
// Only key names are cached. Another token or other settings use another cache entry.
|
||||
$cacheKey = 'secret-manager-keys:'.$link->id.':'.hash('sha256', json_encode([
|
||||
$link->integration_token_id,
|
||||
$link->settings,
|
||||
$link->integrationToken?->updated_at?->getTimestamp(),
|
||||
]));
|
||||
$cachedKeys = Cache::get($cacheKey);
|
||||
if (is_array($cachedKeys)) {
|
||||
return $cachedKeys;
|
||||
}
|
||||
|
||||
$rateLimitKey = 'secret-manager-keys:user:'.auth()->id();
|
||||
if (RateLimiter::tooManyAttempts($rateLimitKey, self::SECRET_MANAGER_KEYS_REQUESTS_PER_MINUTE)) {
|
||||
throw new \RuntimeException('Too many secret manager key requests. Try again in '.RateLimiter::availableIn($rateLimitKey).' seconds.');
|
||||
}
|
||||
RateLimiter::hit($rateLimitKey, 60);
|
||||
|
||||
try {
|
||||
$keys = array_keys($link->fetchSecrets());
|
||||
} catch (\Throwable) {
|
||||
throw new \RuntimeException('Unable to fetch secret manager keys.');
|
||||
}
|
||||
sort($keys);
|
||||
Cache::put($cacheKey, $keys, self::SECRET_MANAGER_KEYS_CACHE_SECONDS);
|
||||
|
||||
return $keys;
|
||||
}
|
||||
|
||||
private function secretManagerLinkForAutocomplete(): ?SecretManagerLink
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Database\RestartDatabase;
|
||||
use App\Actions\Database\StartPostgresql;
|
||||
use App\Actions\Database\StartRedis;
|
||||
use App\Actions\Database\StopDatabase;
|
||||
use App\Actions\Service\StartService;
|
||||
use App\Actions\Service\StopService;
|
||||
use App\Enums\ActivityTypes;
|
||||
use App\Enums\ProcessStatus;
|
||||
use App\Events\DatabaseStatusChanged;
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Jobs\DatabaseStartJob;
|
||||
use App\Livewire\Project\Shared\EnvironmentVariable\Show;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\IntegrationToken;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
const REMOTE_SECRET_LIFECYCLE_DOPPLER_URL = 'https://api.doppler.com/*';
|
||||
|
||||
beforeEach(function () {
|
||||
Server::flushIdentityMap();
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0]));
|
||||
Storage::fake('ssh-keys');
|
||||
Storage::fake('ssh-mux');
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->server->update(['private_key_id' => $privateKey->id]);
|
||||
$this->server->settings()->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]);
|
||||
$this->destination = $this->server->standaloneDockers()->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
$this->token = IntegrationToken::query()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'doppler',
|
||||
'name' => 'Doppler',
|
||||
'token' => 'the-secret-token',
|
||||
'capabilities' => ['secrets'],
|
||||
]);
|
||||
});
|
||||
|
||||
function remoteSecretLifecycleService(): Service
|
||||
{
|
||||
$service = Service::factory()->create([
|
||||
'environment_id' => test()->environment->id,
|
||||
'server_id' => test()->server->id,
|
||||
'destination_id' => test()->destination->id,
|
||||
'destination_type' => test()->destination->getMorphClass(),
|
||||
'docker_compose' => "services:\n app:\n image: alpine\n",
|
||||
]);
|
||||
$service->secretManagerLink()->create(['integration_token_id' => test()->token->id]);
|
||||
|
||||
return $service;
|
||||
}
|
||||
|
||||
function remoteSecretLifecyclePostgres(): StandalonePostgresql
|
||||
{
|
||||
$database = StandalonePostgresql::create([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'name' => 'db',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_password' => 'password',
|
||||
'postgres_db' => 'db',
|
||||
'image' => 'postgres:17-alpine',
|
||||
'status' => 'running:healthy',
|
||||
'environment_id' => test()->environment->id,
|
||||
'destination_id' => test()->destination->id,
|
||||
'destination_type' => test()->destination->getMorphClass(),
|
||||
]);
|
||||
$database->secretManagerLink()->create(['integration_token_id' => test()->token->id]);
|
||||
|
||||
return $database;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the .env file content that saveComposeConfigs() writes to the server.
|
||||
*/
|
||||
function remoteSecretLifecycleWrittenEnvFile(Service $service): string
|
||||
{
|
||||
$commands = collect();
|
||||
Process::fake(function ($process) use ($commands) {
|
||||
$commands->push($process->command);
|
||||
|
||||
return Process::result(output: '');
|
||||
});
|
||||
|
||||
$service->saveComposeConfigs();
|
||||
|
||||
preg_match("/echo '([A-Za-z0-9+\/=]+)' \| base64 -d \| tee [^ ]+\.env\.tmp/", $commands->implode("\n"), $matches);
|
||||
|
||||
return base64_decode($matches[1] ?? '');
|
||||
}
|
||||
|
||||
/*
|
||||
* Expected values were checked with `docker compose config` (Compose v2 dotenv parser): single quotes
|
||||
* keep everything literal, including newlines and "#"; a backslash before the closing single quote
|
||||
* escapes it; double quotes need \\, \" and $$.
|
||||
*/
|
||||
dataset('remote secret dotenv values', [
|
||||
'newline that would add a variable' => ["line1\nSERVICE_PASSWORD_APP=injected", "'line1\nSERVICE_PASSWORD_APP=injected'"],
|
||||
'single quote and dollar' => ["it's \$HOME", '"it\'s $$HOME"'],
|
||||
'double quote and backslash' => ['say "hi" \\o/', "'say \"hi\" \\o/'"],
|
||||
'trailing backslash' => ['ends with \\', '"ends with \\\\"'],
|
||||
'comment marker' => ['value #not-a-comment', "'value #not-a-comment'"],
|
||||
'dollar' => ['p4$$word', "'p4$\$word'"],
|
||||
]);
|
||||
|
||||
test('remote secret values are written to the service .env file as exact dotenv literals', function (string $secret, string $expected) {
|
||||
Http::fake([REMOTE_SECRET_LIFECYCLE_DOPPLER_URL => Http::response(['API_KEY' => $secret])]);
|
||||
$service = remoteSecretLifecycleService();
|
||||
$service->environment_variables()->create(['key' => 'API_KEY', 'value' => '{{vault.API_KEY}}']);
|
||||
|
||||
expect(remoteSecretLifecycleWrittenEnvFile($service))->toContain("API_KEY={$expected}");
|
||||
})->with('remote secret dotenv values');
|
||||
|
||||
test('literal service variables with remote secrets are written as valid dotenv literals', function () {
|
||||
Http::fake([REMOTE_SECRET_LIFECYCLE_DOPPLER_URL => Http::response(['API_KEY' => "it's"])]);
|
||||
$service = remoteSecretLifecycleService();
|
||||
$service->environment_variables()->create(['key' => 'API_KEY', 'value' => '{{vault.API_KEY}}', 'is_literal' => true]);
|
||||
|
||||
expect(remoteSecretLifecycleWrittenEnvFile($service))->toContain('API_KEY="it\'s"');
|
||||
});
|
||||
|
||||
test('service variables without remote secrets keep their .env format', function () {
|
||||
$service = remoteSecretLifecycleService();
|
||||
$service->environment_variables()->create(['key' => 'PLAIN', 'value' => 'say "hi"']);
|
||||
|
||||
expect(remoteSecretLifecycleWrittenEnvFile($service))->toContain('PLAIN=say \"hi\"');
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('the application deployment formats remote secrets with the same dotenv rules', function (string $secret, string $expected) {
|
||||
$job = (new ReflectionClass(ApplicationDeploymentJob::class))->newInstanceWithoutConstructor();
|
||||
|
||||
expect((new ReflectionMethod($job, 'format_remote_secret_value'))->invoke($job, $secret))->toBe($expected);
|
||||
})->with('remote secret dotenv values');
|
||||
|
||||
test('remote secret values reach the database container environment unchanged', function () {
|
||||
$secret = "pa\$word \"q\" 'q' \\ #x\nNEXT=1";
|
||||
Http::fake([REMOTE_SECRET_LIFECYCLE_DOPPLER_URL => Http::response(['PG_PASSWORD' => $secret])]);
|
||||
$database = remoteSecretLifecyclePostgres();
|
||||
$database->runtime_environment_variables()->create(['key' => 'POSTGRES_PASSWORD', 'value' => '{{vault.PG_PASSWORD}}']);
|
||||
$database->runtime_environment_variables()->create(['key' => 'LITERAL_PASSWORD', 'value' => '{{vault.PG_PASSWORD}}', 'is_literal' => true]);
|
||||
|
||||
$action = new StartPostgresql;
|
||||
$action->database = $database;
|
||||
$environmentVariables = (new ReflectionMethod($action, 'generate_environment_variables'))->invoke($action);
|
||||
|
||||
// Compose environment entries are not dotenv: quotes stay part of the value and only "$" is interpolated.
|
||||
$composeValue = str_replace('$', '$$', $secret);
|
||||
expect($environmentVariables)->toContain("POSTGRES_PASSWORD={$composeValue}")
|
||||
->and($environmentVariables)->toContain("LITERAL_PASSWORD={$composeValue}");
|
||||
});
|
||||
|
||||
test('redis remote passwords are escaped the same way in the environment and the start command', function () {
|
||||
Http::fake([REMOTE_SECRET_LIFECYCLE_DOPPLER_URL => Http::response(['REDIS_PASSWORD' => 'p4$word'])]);
|
||||
$redis = StandaloneRedis::forceCreate([
|
||||
'uuid' => (string) Str::uuid(),
|
||||
'name' => 'redis',
|
||||
'image' => 'redis:7-alpine',
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
$redis->secretManagerLink()->create(['integration_token_id' => $this->token->id]);
|
||||
$redis->runtime_environment_variables()->create(['key' => 'REDIS_PASSWORD', 'value' => '{{vault.REDIS_PASSWORD}}']);
|
||||
|
||||
$action = new StartRedis;
|
||||
$action->database = $redis;
|
||||
$environmentVariables = (new ReflectionMethod($action, 'generate_environment_variables'))->invoke($action);
|
||||
$startCommand = (new ReflectionMethod($action, 'buildStartCommand'))->invoke($action);
|
||||
|
||||
expect($environmentVariables)->toContain('REDIS_PASSWORD=p4$$word')
|
||||
->and($startCommand)->toContain('--requirepass p4$$word');
|
||||
});
|
||||
|
||||
test('a service restart keeps the service running when remote secrets cannot be fetched', function () {
|
||||
Http::fake([REMOTE_SECRET_LIFECYCLE_DOPPLER_URL => Http::response(['messages' => ['Doppler is down']], 503)]);
|
||||
StopService::shouldRun()->never();
|
||||
$service = remoteSecretLifecycleService();
|
||||
$service->environment_variables()->create(['key' => 'API_KEY', 'value' => '{{vault.API_KEY}}']);
|
||||
|
||||
expect(fn () => StartService::run($service, stopBeforeStart: true))
|
||||
->toThrow(RuntimeException::class, 'Doppler is down');
|
||||
});
|
||||
|
||||
test('a service restart fetches remote secrets only once', function () {
|
||||
Http::fake([REMOTE_SECRET_LIFECYCLE_DOPPLER_URL => Http::response(['API_KEY' => 'value'])]);
|
||||
Process::fake(['*' => Process::result(output: '')]);
|
||||
StopService::shouldRun();
|
||||
$service = remoteSecretLifecycleService();
|
||||
$service->environment_variables()->create(['key' => 'API_KEY', 'value' => '{{vault.API_KEY}}']);
|
||||
|
||||
StartService::run($service, stopBeforeStart: true);
|
||||
|
||||
Http::assertSentCount(1);
|
||||
});
|
||||
|
||||
test('a database restart keeps the database running and reports the secret manager error', function () {
|
||||
Http::fake([REMOTE_SECRET_LIFECYCLE_DOPPLER_URL => Http::response(['messages' => ['Doppler is down']], 503)]);
|
||||
StopDatabase::shouldRun()->never();
|
||||
$database = remoteSecretLifecyclePostgres();
|
||||
$database->runtime_environment_variables()->create(['key' => 'POSTGRES_PASSWORD', 'value' => '{{vault.PG_PASSWORD}}']);
|
||||
|
||||
expect(RestartDatabase::run($database))->toBeString()->toContain('Doppler is down');
|
||||
});
|
||||
|
||||
test('a database start job shows the secret manager error instead of a generic failure', function () {
|
||||
Event::fake([DatabaseStatusChanged::class]);
|
||||
Http::fake([REMOTE_SECRET_LIFECYCLE_DOPPLER_URL => Http::response(['messages' => ['Doppler is down']], 503)]);
|
||||
$database = remoteSecretLifecyclePostgres();
|
||||
$database->runtime_environment_variables()->create(['key' => 'POSTGRES_PASSWORD', 'value' => '{{vault.PG_PASSWORD}}']);
|
||||
$activity = activity()
|
||||
->withProperties([
|
||||
'type' => ActivityTypes::INLINE->value,
|
||||
'type_uuid' => $database->uuid,
|
||||
'status' => ProcessStatus::QUEUED->value,
|
||||
'team_id' => $this->team->id,
|
||||
'operation' => 'database-start',
|
||||
])
|
||||
->event(ActivityTypes::INLINE->value)
|
||||
->log('[]');
|
||||
|
||||
expect(fn () => DatabaseStartJob::dispatchSync(StandalonePostgresql::class, $database->id, $this->team->id, $activity->id, null))
|
||||
->toThrow(RuntimeException::class);
|
||||
|
||||
expect(data_get($activity->refresh(), 'properties.error'))->toContain('Doppler is down');
|
||||
});
|
||||
|
||||
test('the secret key autocomplete reuses recently fetched key names', function () {
|
||||
Http::fake([REMOTE_SECRET_LIFECYCLE_DOPPLER_URL => Http::response(['B_KEY' => 'b', 'A_KEY' => 'a'])]);
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
$application->secretManagerLink()->create(['integration_token_id' => $this->token->id]);
|
||||
$env = $application->environment_variables()->create(['key' => 'MY_VAR', 'value' => 'plain']);
|
||||
$component = Livewire::test(Show::class, ['env' => $env, 'type' => 'application'])->instance();
|
||||
|
||||
expect($component->fetchSecretManagerKeys())->toBe(['A_KEY', 'B_KEY'])
|
||||
->and($component->fetchSecretManagerKeys())->toBe(['A_KEY', 'B_KEY']);
|
||||
Http::assertSentCount(1);
|
||||
});
|
||||
|
||||
test('the secret key autocomplete limits how often a user can contact the secret manager', function () {
|
||||
Http::fake([REMOTE_SECRET_LIFECYCLE_DOPPLER_URL => Http::response(['A_KEY' => 'a'])]);
|
||||
$application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
$link = $application->secretManagerLink()->create(['integration_token_id' => $this->token->id]);
|
||||
$env = $application->environment_variables()->create(['key' => 'MY_VAR', 'value' => 'plain']);
|
||||
$component = Livewire::test(Show::class, ['env' => $env, 'type' => 'application']);
|
||||
|
||||
// A changed source is fetched again, so each request reaches the secret manager.
|
||||
foreach (range(1, 10) as $attempt) {
|
||||
$link->update(['settings' => ['project' => "project-{$attempt}", 'config' => 'prd']]);
|
||||
$component->call('fetchSecretManagerKeys');
|
||||
}
|
||||
$link->update(['settings' => ['project' => 'project-11', 'config' => 'prd']]);
|
||||
|
||||
expect(fn () => $component->call('fetchSecretManagerKeys'))->toThrow(RuntimeException::class, 'Too many');
|
||||
Http::assertSentCount(10);
|
||||
});
|
||||
@@ -262,9 +262,9 @@ test('redis remote credentials stay deployment-local and use raw values in the s
|
||||
expect($password->fresh()->value)->toBe('{{team.REDIS_PASSWORD}}')
|
||||
->and($sharedPassword->fresh()->value)->toBe('{{vault.REDIS_PASSWORD}}')
|
||||
->and($username->fresh()->value)->toBe('{{vault.REDIS_USERNAME}}')
|
||||
->and($environmentVariables)->toContain('REDIS_PASSWORD=p4$$word')
|
||||
->and($environmentVariables)->toContain('REDIS_PASSWORD=p4$$$$word')
|
||||
->and($environmentVariables)->toContain('REDIS_USERNAME=remote-user')
|
||||
->and($startCommand)->toContain('--requirepass p4$$word');
|
||||
->and($startCommand)->toContain('--requirepass p4$$$$word');
|
||||
});
|
||||
|
||||
test('all deployable environment-variable resources support secret managers', function (string $resourceClass) {
|
||||
|
||||
Reference in new issue
Block a user