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:
Andras BacsaiandClaude Opus 5.5 committed 2026-10-02 16:00:41 +02:00
1 parent 25a4076bd0
commit 9bdbbeef7a
15 files changed
+484 -26

No files matched your search

+7
View File
@@ -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);
+4 -2
View File
@@ -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;
}
}
+3 -1
View File
@@ -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;
}
}
+4 -2
View File
@@ -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;
}
}
+14 -2
View File
@@ -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;
+3
View File
@@ -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);
}
+12
View File
@@ -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 {}
+2 -6
View File
@@ -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()
+4 -1
View File
@@ -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));
+1 -1
View File
@@ -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);
}
}
+70 -4
View File
@@ -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> */
+32 -5
View File
@@ -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) {