mirror of
https://github.com/coollabsio/coolify.git
synced 2026-08-24 10:05:47 -05:00
feat(secrets): resolve integrations across deployments and databases
Add secret manager integration links and API support, resolve referenced credentials in database startup commands, and improve environment variable handling and filtering.
This commit is contained in:
@@ -16,6 +16,10 @@ class StartClickhouse
|
||||
|
||||
public string $configuration_dir;
|
||||
|
||||
private string $resolvedClickhouseUser;
|
||||
|
||||
private string $resolvedClickhousePassword;
|
||||
|
||||
public function handle(StandaloneClickhouse $database)
|
||||
{
|
||||
$this->database = $database;
|
||||
@@ -51,7 +55,7 @@ class StartClickhouse
|
||||
],
|
||||
'labels' => defaultDatabaseLabels($this->database)->toArray(),
|
||||
'healthcheck' => $this->database->healthCheckConfiguration([
|
||||
'CMD', 'clickhouse-client', '--user', (string) $this->database->clickhouse_admin_user, '--password', (string) $this->database->clickhouse_admin_password, '--query', 'SELECT 1',
|
||||
'CMD', 'clickhouse-client', '--user', $this->resolvedClickhouseUser, '--password', $this->resolvedClickhousePassword, '--query', 'SELECT 1',
|
||||
]),
|
||||
'mem_limit' => $this->database->limits_memory,
|
||||
'memswap_limit' => $this->database->limits_memory_swap,
|
||||
@@ -147,8 +151,16 @@ class StartClickhouse
|
||||
private function generate_environment_variables()
|
||||
{
|
||||
$environment_variables = collect();
|
||||
$this->resolvedClickhouseUser = (string) $this->database->clickhouse_admin_user;
|
||||
$this->resolvedClickhousePassword = (string) $this->database->clickhouse_admin_password;
|
||||
foreach ($this->database->runtime_environment_variables as $env) {
|
||||
$environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env));
|
||||
$resolvedValue = (string) $this->database->resolveSecretManagerEnvironmentVariable($env);
|
||||
$environment_variables->push($env->key.'='.$resolvedValue);
|
||||
if ($env->key === 'CLICKHOUSE_USER') {
|
||||
$this->resolvedClickhouseUser = $resolvedValue;
|
||||
} elseif ($env->key === 'CLICKHOUSE_PASSWORD') {
|
||||
$this->resolvedClickhousePassword = $resolvedValue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($environment_variables->filter(fn ($env) => str($env)->contains('CLICKHOUSE_USER'))->isEmpty()) {
|
||||
|
||||
@@ -20,6 +20,8 @@ class StartDragonfly
|
||||
|
||||
private ?SslCertificate $ssl_certificate = null;
|
||||
|
||||
private string $resolvedRedisPassword;
|
||||
|
||||
public function handle(StandaloneDragonfly $database)
|
||||
{
|
||||
$this->database = $database;
|
||||
@@ -107,7 +109,7 @@ class StartDragonfly
|
||||
],
|
||||
'labels' => defaultDatabaseLabels($this->database)->toArray(),
|
||||
'healthcheck' => $this->database->healthCheckConfiguration([
|
||||
'CMD', 'redis-cli', '-a', (string) $this->database->dragonfly_password, 'ping',
|
||||
'CMD', 'redis-cli', '-a', $this->resolvedRedisPassword, 'ping',
|
||||
]),
|
||||
'mem_limit' => $this->database->limits_memory,
|
||||
'memswap_limit' => $this->database->limits_memory_swap,
|
||||
@@ -201,7 +203,7 @@ class StartDragonfly
|
||||
|
||||
private function buildStartCommand(): string
|
||||
{
|
||||
$command = "dragonfly --requirepass {$this->database->dragonfly_password}";
|
||||
$command = "dragonfly --requirepass {$this->resolvedRedisPassword}";
|
||||
|
||||
if ($this->database->enable_ssl) {
|
||||
$sslArgs = [
|
||||
@@ -251,8 +253,13 @@ class StartDragonfly
|
||||
private function generate_environment_variables()
|
||||
{
|
||||
$environment_variables = collect();
|
||||
$this->resolvedRedisPassword = (string) $this->database->dragonfly_password;
|
||||
foreach ($this->database->runtime_environment_variables as $env) {
|
||||
$environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env));
|
||||
$resolvedValue = (string) $this->database->resolveSecretManagerEnvironmentVariable($env);
|
||||
$environment_variables->push($env->key.'='.$resolvedValue);
|
||||
if ($env->key === 'REDIS_PASSWORD') {
|
||||
$this->resolvedRedisPassword = $resolvedValue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($environment_variables->filter(fn ($env) => str($env)->contains('REDIS_PASSWORD'))->isEmpty()) {
|
||||
|
||||
@@ -20,6 +20,8 @@ class StartKeydb
|
||||
|
||||
private ?SslCertificate $ssl_certificate = null;
|
||||
|
||||
private string $resolvedRedisPassword;
|
||||
|
||||
public function handle(StandaloneKeydb $database)
|
||||
{
|
||||
$this->database = $database;
|
||||
@@ -109,7 +111,7 @@ class StartKeydb
|
||||
],
|
||||
'labels' => defaultDatabaseLabels($this->database)->toArray(),
|
||||
'healthcheck' => $this->database->healthCheckConfiguration([
|
||||
'CMD', 'keydb-cli', '--pass', (string) $this->database->keydb_password, 'ping',
|
||||
'CMD', 'keydb-cli', '--pass', $this->resolvedRedisPassword, 'ping',
|
||||
]),
|
||||
'mem_limit' => $this->database->limits_memory,
|
||||
'memswap_limit' => $this->database->limits_memory_swap,
|
||||
@@ -252,8 +254,13 @@ class StartKeydb
|
||||
private function generate_environment_variables()
|
||||
{
|
||||
$environment_variables = collect();
|
||||
$this->resolvedRedisPassword = (string) $this->database->keydb_password;
|
||||
foreach ($this->database->runtime_environment_variables as $env) {
|
||||
$environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env));
|
||||
$resolvedValue = (string) $this->database->resolveSecretManagerEnvironmentVariable($env);
|
||||
$environment_variables->push($env->key.'='.$resolvedValue);
|
||||
if ($env->key === 'REDIS_PASSWORD') {
|
||||
$this->resolvedRedisPassword = $resolvedValue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($environment_variables->filter(fn ($env) => str($env)->contains('REDIS_PASSWORD'))->isEmpty()) {
|
||||
@@ -288,10 +295,10 @@ class StartKeydb
|
||||
if ($hasRequirePass) {
|
||||
$command = "keydb-server $keydbConfPath";
|
||||
} else {
|
||||
$command = "keydb-server $keydbConfPath --requirepass {$this->database->keydb_password}";
|
||||
$command = "keydb-server $keydbConfPath --requirepass {$this->resolvedRedisPassword}";
|
||||
}
|
||||
} else {
|
||||
$command = "keydb-server --requirepass {$this->database->keydb_password} --appendonly yes";
|
||||
$command = "keydb-server --requirepass {$this->resolvedRedisPassword} --appendonly yes";
|
||||
}
|
||||
|
||||
if ($this->database->enable_ssl) {
|
||||
|
||||
@@ -20,6 +20,12 @@ class StartMongodb
|
||||
|
||||
private ?SslCertificate $ssl_certificate = null;
|
||||
|
||||
private string $resolvedMongoUsername;
|
||||
|
||||
private string $resolvedMongoPassword;
|
||||
|
||||
private string $resolvedMongoDatabase;
|
||||
|
||||
public function handle(StandaloneMongodb $database)
|
||||
{
|
||||
$this->database = $database;
|
||||
@@ -303,8 +309,19 @@ class StartMongodb
|
||||
private function generate_environment_variables()
|
||||
{
|
||||
$environment_variables = collect();
|
||||
$this->resolvedMongoUsername = (string) $this->database->mongo_initdb_root_username;
|
||||
$this->resolvedMongoPassword = (string) $this->database->mongo_initdb_root_password;
|
||||
$this->resolvedMongoDatabase = (string) $this->database->mongo_initdb_database;
|
||||
foreach ($this->database->runtime_environment_variables as $env) {
|
||||
$environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env));
|
||||
$resolvedValue = (string) $this->database->resolveSecretManagerEnvironmentVariable($env);
|
||||
$environment_variables->push($env->key.'='.$resolvedValue);
|
||||
if ($env->key === 'MONGO_INITDB_ROOT_USERNAME') {
|
||||
$this->resolvedMongoUsername = $resolvedValue;
|
||||
} elseif ($env->key === 'MONGO_INITDB_ROOT_PASSWORD') {
|
||||
$this->resolvedMongoPassword = $resolvedValue;
|
||||
} elseif ($env->key === 'MONGO_INITDB_DATABASE') {
|
||||
$this->resolvedMongoDatabase = $resolvedValue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($environment_variables->filter(fn ($env) => str($env)->contains('MONGO_INITDB_ROOT_USERNAME'))->isEmpty()) {
|
||||
@@ -337,9 +354,9 @@ class StartMongodb
|
||||
|
||||
private function add_default_database()
|
||||
{
|
||||
$dbJson = json_encode($this->database->mongo_initdb_database, JSON_UNESCAPED_SLASHES);
|
||||
$userJson = json_encode($this->database->mongo_initdb_root_username, JSON_UNESCAPED_SLASHES);
|
||||
$pwdJson = json_encode($this->database->mongo_initdb_root_password, JSON_UNESCAPED_SLASHES);
|
||||
$dbJson = json_encode($this->resolvedMongoDatabase, JSON_UNESCAPED_SLASHES);
|
||||
$userJson = json_encode($this->resolvedMongoUsername, JSON_UNESCAPED_SLASHES);
|
||||
$pwdJson = json_encode($this->resolvedMongoPassword, JSON_UNESCAPED_SLASHES);
|
||||
$content = "db = db.getSiblingDB({$dbJson});db.createCollection('init_collection');db.createUser({user: {$userJson}, pwd: {$pwdJson}, roles: [{role:\"readWrite\",db:{$dbJson}}]});";
|
||||
$content_base64 = base64_encode($content);
|
||||
$this->commands[] = "mkdir -p $this->configuration_dir/docker-entrypoint-initdb.d";
|
||||
|
||||
@@ -20,6 +20,8 @@ class StartMysql
|
||||
|
||||
private ?SslCertificate $ssl_certificate = null;
|
||||
|
||||
private string $resolvedMysqlRootPassword;
|
||||
|
||||
public function handle(StandaloneMysql $database)
|
||||
{
|
||||
$this->database = $database;
|
||||
@@ -104,7 +106,7 @@ class StartMysql
|
||||
],
|
||||
'labels' => defaultDatabaseLabels($this->database)->toArray(),
|
||||
'healthcheck' => $this->database->healthCheckConfiguration([
|
||||
'CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'root', "-p{$this->database->mysql_root_password}",
|
||||
'CMD', 'mysqladmin', 'ping', '-h', 'localhost', '-u', 'root', "-p{$this->resolvedMysqlRootPassword}",
|
||||
]),
|
||||
'mem_limit' => $this->database->limits_memory,
|
||||
'memswap_limit' => $this->database->limits_memory_swap,
|
||||
@@ -256,8 +258,13 @@ class StartMysql
|
||||
private function generate_environment_variables()
|
||||
{
|
||||
$environment_variables = collect();
|
||||
$this->resolvedMysqlRootPassword = (string) $this->database->mysql_root_password;
|
||||
foreach ($this->database->runtime_environment_variables as $env) {
|
||||
$environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env));
|
||||
$resolvedValue = (string) $this->database->resolveSecretManagerEnvironmentVariable($env);
|
||||
$environment_variables->push($env->key.'='.$resolvedValue);
|
||||
if ($env->key === 'MYSQL_ROOT_PASSWORD') {
|
||||
$this->resolvedMysqlRootPassword = $resolvedValue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($environment_variables->filter(fn ($env) => str($env)->contains('MYSQL_ROOT_PASSWORD'))->isEmpty()) {
|
||||
|
||||
@@ -22,6 +22,10 @@ class StartPostgresql
|
||||
|
||||
private ?SslCertificate $ssl_certificate = null;
|
||||
|
||||
private string $resolvedPostgresUser;
|
||||
|
||||
private string $resolvedPostgresDatabase;
|
||||
|
||||
public function handle(StandalonePostgresql $database)
|
||||
{
|
||||
$this->database = $database;
|
||||
@@ -111,7 +115,7 @@ class StartPostgresql
|
||||
],
|
||||
'labels' => defaultDatabaseLabels($this->database)->toArray(),
|
||||
'healthcheck' => $this->database->healthCheckConfiguration([
|
||||
'CMD', 'psql', '-U', (string) $this->database->postgres_user, '-d', (string) $this->database->postgres_db, '-c', 'SELECT 1',
|
||||
'CMD', 'psql', '-U', $this->resolvedPostgresUser, '-d', $this->resolvedPostgresDatabase, '-c', 'SELECT 1',
|
||||
]),
|
||||
'mem_limit' => $this->database->limits_memory,
|
||||
'memswap_limit' => $this->database->limits_memory_swap,
|
||||
@@ -265,8 +269,16 @@ class StartPostgresql
|
||||
private function generate_environment_variables()
|
||||
{
|
||||
$environment_variables = collect();
|
||||
$this->resolvedPostgresUser = (string) $this->database->postgres_user;
|
||||
$this->resolvedPostgresDatabase = (string) $this->database->postgres_db;
|
||||
foreach ($this->database->runtime_environment_variables as $env) {
|
||||
$environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env));
|
||||
$resolvedValue = (string) $this->database->resolveSecretManagerEnvironmentVariable($env);
|
||||
$environment_variables->push($env->key.'='.$resolvedValue);
|
||||
if ($env->key === 'POSTGRES_USER') {
|
||||
$this->resolvedPostgresUser = $resolvedValue;
|
||||
} elseif ($env->key === 'POSTGRES_DB') {
|
||||
$this->resolvedPostgresDatabase = $resolvedValue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($environment_variables->filter(fn ($env) => str($env)->contains('POSTGRES_USER'))->isEmpty()) {
|
||||
|
||||
@@ -5,7 +5,6 @@ namespace App\Actions\Database;
|
||||
use App\Helpers\SslHelper;
|
||||
use App\Models\SslCertificate;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Support\RemoteSecretReferences;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
@@ -21,6 +20,10 @@ class StartRedis
|
||||
|
||||
private ?SslCertificate $ssl_certificate = null;
|
||||
|
||||
private ?string $resolvedRedisPassword = null;
|
||||
|
||||
private ?string $resolvedRedisUsername = null;
|
||||
|
||||
public function handle(StandaloneRedis $database)
|
||||
{
|
||||
$this->database = $database;
|
||||
@@ -250,22 +253,39 @@ class StartRedis
|
||||
$environment_variables = collect();
|
||||
|
||||
foreach ($this->database->runtime_environment_variables as $env) {
|
||||
$usesSecretManager = $this->database->environmentVariableUsesSecretManager($env);
|
||||
|
||||
if ($env->is_shared) {
|
||||
$environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env));
|
||||
|
||||
if ($env->key === 'REDIS_PASSWORD') {
|
||||
$this->database->update(['redis_password' => $this->database->resolveSecretManagerEnvironmentVariable($env)]);
|
||||
$this->resolvedRedisPassword = $this->database->resolveSecretManagerEnvironmentVariableValue($env);
|
||||
|
||||
if (! $usesSecretManager) {
|
||||
$this->database->update(['redis_password' => $this->resolvedRedisPassword]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($env->key === 'REDIS_USERNAME') {
|
||||
$this->database->update(['redis_username' => $this->database->resolveSecretManagerEnvironmentVariable($env)]);
|
||||
$this->resolvedRedisUsername = $this->database->resolveSecretManagerEnvironmentVariableValue($env);
|
||||
|
||||
if (! $usesSecretManager) {
|
||||
$this->database->update(['redis_username' => $this->resolvedRedisUsername]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if ($env->key === 'REDIS_PASSWORD' && ! RemoteSecretReferences::containsReference($env->value)) {
|
||||
if ($env->key === 'REDIS_PASSWORD' && ! $usesSecretManager) {
|
||||
$env->update(['value' => $this->database->redis_password]);
|
||||
} elseif ($env->key === 'REDIS_USERNAME' && ! RemoteSecretReferences::containsReference($env->value)) {
|
||||
} elseif ($env->key === 'REDIS_USERNAME' && ! $usesSecretManager) {
|
||||
$env->update(['value' => $this->database->redis_username]);
|
||||
}
|
||||
|
||||
if ($env->key === 'REDIS_PASSWORD') {
|
||||
$this->resolvedRedisPassword = $this->database->resolveSecretManagerEnvironmentVariableValue($env);
|
||||
} elseif ($env->key === 'REDIS_USERNAME') {
|
||||
$this->resolvedRedisUsername = $this->database->resolveSecretManagerEnvironmentVariableValue($env);
|
||||
}
|
||||
|
||||
$environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env));
|
||||
}
|
||||
}
|
||||
@@ -277,6 +297,7 @@ class StartRedis
|
||||
|
||||
private function buildStartCommand(): string
|
||||
{
|
||||
$redisPassword = $this->resolvedRedisPassword ?? $this->database->redis_password;
|
||||
$hasRedisConf = ! is_null($this->database->redis_conf) && ! empty($this->database->redis_conf);
|
||||
$redisConfPath = '/usr/local/etc/redis/redis.conf';
|
||||
|
||||
@@ -287,10 +308,10 @@ class StartRedis
|
||||
if ($hasRequirePass) {
|
||||
$command = "redis-server $redisConfPath";
|
||||
} else {
|
||||
$command = "redis-server $redisConfPath --requirepass {$this->database->redis_password}";
|
||||
$command = "redis-server $redisConfPath --requirepass {$redisPassword}";
|
||||
}
|
||||
} else {
|
||||
$command = "redis-server --requirepass {$this->database->redis_password} --appendonly yes";
|
||||
$command = "redis-server --requirepass {$redisPassword} --appendonly yes";
|
||||
}
|
||||
|
||||
if ($this->database->enable_ssl) {
|
||||
|
||||
@@ -62,10 +62,10 @@ class IntegrationTokensController extends Controller
|
||||
if (($body['provider'] ?? null) === 'doppler') {
|
||||
$rules['token'][] = 'regex:/^dp\.(st|sa)\./';
|
||||
} elseif (($body['provider'] ?? null) === 'infisical') {
|
||||
$rules['metadata.base_url'] = ['required', 'url'];
|
||||
$rules['metadata.base_url'] = ['required', 'url:http,https'];
|
||||
$rules['metadata.client_id'] = ['required', 'string'];
|
||||
} elseif (($body['provider'] ?? null) === 'vault') {
|
||||
$rules['metadata.base_url'] = ['required', 'url'];
|
||||
$rules['metadata.base_url'] = ['required', 'url:http,https'];
|
||||
$rules['metadata.namespace'] = ['nullable', 'string'];
|
||||
}
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
private $env_args;
|
||||
|
||||
/** @var array{runtime: array<string, string>, buildtime: array<string, string>}|null */
|
||||
/** @var array<string, string>|null */
|
||||
private ?array $remote_secrets_cache = null;
|
||||
|
||||
private $env_nixpacks_args;
|
||||
@@ -1431,11 +1431,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
*/
|
||||
private function format_remote_secret_value(string $value): string
|
||||
{
|
||||
// Keep valid JSON objects/arrays unquoted, matching EnvironmentVariable::realValue().
|
||||
if (json_validate($value) && (str_starts_with($value, '{') || str_starts_with($value, '['))) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if (! str_contains($value, "'")) {
|
||||
return "'".$value."'";
|
||||
}
|
||||
@@ -1720,6 +1715,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
"echo '$envs_base64' | base64 -d | tee $this->configuration_dir/.env > /dev/null",
|
||||
'skip_command_log' => true,
|
||||
]
|
||||
);
|
||||
$this->server = $this->build_server;
|
||||
@@ -1727,6 +1723,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
"echo '$envs_base64' | base64 -d | tee $this->configuration_dir/.env > /dev/null",
|
||||
'skip_command_log' => true,
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -4479,7 +4476,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
||||
if (data_get($env, 'is_multiline') === true) {
|
||||
$argsToInsert->push("ARG {$env->key}");
|
||||
} else {
|
||||
$argsToInsert->push("ARG {$env->key}={$this->resolve_environment_variable($env)}");
|
||||
$argsToInsert->push("ARG {$env->key}=".escapeBashEnvValue($this->resolve_environment_variable_raw($env)));
|
||||
}
|
||||
}
|
||||
// Add Coolify variables as ARGs
|
||||
@@ -4501,7 +4498,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
||||
if (data_get($env, 'is_multiline') === true) {
|
||||
$argsToInsert->push("ARG {$env->key}");
|
||||
} else {
|
||||
$argsToInsert->push("ARG {$env->key}={$this->resolve_environment_variable($env)}");
|
||||
$argsToInsert->push("ARG {$env->key}=".escapeBashEnvValue($this->resolve_environment_variable_raw($env)));
|
||||
}
|
||||
}
|
||||
// Add Coolify variables as ARGs
|
||||
@@ -4517,7 +4514,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
||||
|
||||
if ($argsToInsert->isNotEmpty()) {
|
||||
$environmentVariables = $envs->mapWithKeys(function ($environmentVariable) {
|
||||
return [$environmentVariable->key => $this->resolve_environment_variable($environmentVariable)];
|
||||
return [$environmentVariable->key => escapeBashEnvValue($this->resolve_environment_variable_raw($environmentVariable))];
|
||||
});
|
||||
$secretsHash = $this->generate_secrets_hash($environmentVariables);
|
||||
$argsToInsert->push("ARG COOLIFY_BUILD_SECRETS_HASH={$secretsHash}");
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Support\ValidationPatterns;
|
||||
use App\Traits\EnvironmentVariableAnalyzer;
|
||||
use App\Traits\HasSecretManagerAutocomplete;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
@@ -19,7 +20,7 @@ class Add extends Component
|
||||
{
|
||||
use AuthorizesRequests, EnvironmentVariableAnalyzer, HasSecretManagerAutocomplete;
|
||||
|
||||
protected function secretManagerResource()
|
||||
protected function secretManagerResource(): ?Model
|
||||
{
|
||||
if ($this->shared || ! $this->resource) {
|
||||
return null;
|
||||
|
||||
@@ -14,6 +14,7 @@ use App\Traits\EnvironmentVariableAnalyzer;
|
||||
use App\Traits\EnvironmentVariableProtection;
|
||||
use App\Traits\HasSecretManagerAutocomplete;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
@@ -24,7 +25,7 @@ class Show extends Component
|
||||
|
||||
use AuthorizesRequests, EnvironmentVariableAnalyzer, EnvironmentVariableProtection, HasSecretManagerAutocomplete;
|
||||
|
||||
protected function secretManagerResource()
|
||||
protected function secretManagerResource(): ?Model
|
||||
{
|
||||
return $this->isSharedVariable ? null : $this->env->resourceable;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Livewire\Project\Shared;
|
||||
|
||||
use App\Models\IntegrationToken;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
@@ -251,7 +252,7 @@ class SecretManagerLinks extends Component
|
||||
));
|
||||
}
|
||||
|
||||
public function render()
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.project.shared.secret-manager-links', [
|
||||
'selectedToken' => $this->selectedToken,
|
||||
|
||||
@@ -46,7 +46,7 @@ class IntegrationTokenForm extends Component
|
||||
$allowedCapability = $this->provider === 'cloudflare' ? 'dns' : 'secrets';
|
||||
|
||||
$rules = [
|
||||
'provider' => ['required', 'in:cloudflare,doppler,infisical,vault'],
|
||||
'provider' => ['required', 'in:'.implode(',', array_keys(IntegrationToken::PROVIDER_NAMES))],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'token' => ['required', 'string'],
|
||||
'capabilities' => ['required', 'array', 'min:1'],
|
||||
@@ -54,7 +54,7 @@ class IntegrationTokenForm extends Component
|
||||
];
|
||||
|
||||
if ($this->provider === 'infisical') {
|
||||
$rules['metadata.base_url'] = ['required', 'url'];
|
||||
$rules['metadata.base_url'] = ['required', 'url:http,https'];
|
||||
$rules['metadata.client_id'] = ['required', 'string'];
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ class IntegrationTokenForm extends Component
|
||||
}
|
||||
|
||||
if ($this->provider === 'vault') {
|
||||
$rules['metadata.base_url'] = ['required', 'url'];
|
||||
$rules['metadata.base_url'] = ['required', 'url:http,https'];
|
||||
$rules['metadata.namespace'] = ['nullable', 'string'];
|
||||
}
|
||||
|
||||
|
||||
@@ -249,18 +249,21 @@ class EnvironmentVariable extends BaseModel
|
||||
protected function isShared(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: function () {
|
||||
if (blank($this->value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$types = implode('|', SHARED_VARIABLE_TYPES);
|
||||
|
||||
return preg_match('/^{{\s*(?:'.$types.')\..*}}$/s', trim($this->value)) === 1;
|
||||
}
|
||||
get: fn () => $this->isSharedReference(),
|
||||
);
|
||||
}
|
||||
|
||||
private function isSharedReference(): bool
|
||||
{
|
||||
if (blank($this->value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$types = implode('|', SHARED_VARIABLE_TYPES);
|
||||
|
||||
return preg_match('/^{{\s*(?:'.$types.')\..*}}$/s', trim($this->value)) === 1;
|
||||
}
|
||||
|
||||
public function get_real_environment_variables_with_server(?string $environment_variable = null, $resource = null, $server = null)
|
||||
{
|
||||
return $this->get_real_environment_variables_internal($environment_variable, $resource, $server);
|
||||
@@ -407,8 +410,6 @@ class EnvironmentVariable extends BaseModel
|
||||
|
||||
protected function updateIsShared(): void
|
||||
{
|
||||
$type = str($this->value)->after('{{')->before('.')->value;
|
||||
$isShared = str($this->value)->startsWith('{{'.$type) && str($this->value)->endsWith('}}');
|
||||
$this->is_shared = $isShared;
|
||||
$this->is_shared = $this->isSharedReference();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,23 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Rules\SafeExternalUrl;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
class InfisicalService
|
||||
{
|
||||
private string $baseUrl;
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
private array $httpClientOptions;
|
||||
|
||||
public function __construct(string $baseUrl, private string $clientId, private string $clientSecret)
|
||||
{
|
||||
$this->baseUrl = rtrim($baseUrl, '/');
|
||||
Validator::make(['base_url' => $this->baseUrl], ['base_url' => new SafeExternalUrl])->validate();
|
||||
$this->httpClientOptions = SafeExternalUrl::httpClientOptions($this->baseUrl);
|
||||
}
|
||||
|
||||
public function validate(): bool
|
||||
@@ -75,6 +82,7 @@ class InfisicalService
|
||||
private function client(): PendingRequest
|
||||
{
|
||||
return Http::acceptJson()
|
||||
->withOptions($this->httpClientOptions)
|
||||
->connectTimeout(5)
|
||||
->timeout(10);
|
||||
}
|
||||
|
||||
@@ -2,16 +2,23 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Rules\SafeExternalUrl;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
class VaultService
|
||||
{
|
||||
private string $baseUrl;
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
private array $httpClientOptions;
|
||||
|
||||
public function __construct(string $baseUrl, private string $token, private ?string $namespace = null)
|
||||
{
|
||||
$this->baseUrl = rtrim($baseUrl, '/');
|
||||
Validator::make(['base_url' => $this->baseUrl], ['base_url' => new SafeExternalUrl])->validate();
|
||||
$this->httpClientOptions = SafeExternalUrl::httpClientOptions($this->baseUrl);
|
||||
}
|
||||
|
||||
public function validate(): bool
|
||||
@@ -48,6 +55,7 @@ class VaultService
|
||||
{
|
||||
$client = Http::withHeaders(['X-Vault-Token' => $this->token])
|
||||
->acceptJson()
|
||||
->withOptions($this->httpClientOptions)
|
||||
->connectTimeout(5)
|
||||
->timeout(10);
|
||||
|
||||
|
||||
@@ -47,7 +47,10 @@ trait ExecuteRemoteCommand
|
||||
}
|
||||
|
||||
if (isset($this->remote_secrets_cache)) {
|
||||
$lockedVars = $lockedVars->merge(array_values($this->remote_secrets_cache));
|
||||
$lockedVars = $lockedVars->merge(array_values(array_filter(
|
||||
$this->remote_secrets_cache,
|
||||
static fn (mixed $value): bool => is_string($value) && $value !== ''
|
||||
)));
|
||||
}
|
||||
|
||||
foreach ($lockedVars as $key => $value) {
|
||||
|
||||
@@ -25,11 +25,28 @@ trait HasSecretManager
|
||||
|
||||
public function resolveSecretManagerEnvironmentVariable(EnvironmentVariable $environmentVariable): ?string
|
||||
{
|
||||
$value = $environmentVariable->get_real_environment_variables_with_server(
|
||||
$environmentVariable->value,
|
||||
$this,
|
||||
data_get($this, 'server'),
|
||||
);
|
||||
$value = $this->resolveSecretManagerEnvironmentVariableValue($environmentVariable);
|
||||
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (json_validate($value) && (str_starts_with($value, '{') || str_starts_with($value, '['))) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return $environmentVariable->is_literal || $environmentVariable->is_multiline
|
||||
? "'{$value}'"
|
||||
: escapeEnvVariables($value);
|
||||
}
|
||||
|
||||
public function resolveSecretManagerEnvironmentVariableValue(EnvironmentVariable $environmentVariable): ?string
|
||||
{
|
||||
$value = $this->resolvedEnvironmentVariableValue($environmentVariable);
|
||||
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (RemoteSecretReferences::containsReference($value)) {
|
||||
$secrets = $this->secretManagerValues();
|
||||
@@ -42,13 +59,23 @@ trait HasSecretManager
|
||||
$value = RemoteSecretReferences::substitute($value, $secrets);
|
||||
}
|
||||
|
||||
if (json_validate($value) && (str_starts_with($value, '{') || str_starts_with($value, '['))) {
|
||||
return $value;
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
return $environmentVariable->is_literal || $environmentVariable->is_multiline
|
||||
? "'{$value}'"
|
||||
: escapeEnvVariables($value);
|
||||
public function environmentVariableUsesSecretManager(EnvironmentVariable $environmentVariable): bool
|
||||
{
|
||||
return RemoteSecretReferences::containsReference(
|
||||
$this->resolvedEnvironmentVariableValue($environmentVariable),
|
||||
);
|
||||
}
|
||||
|
||||
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> */
|
||||
|
||||
@@ -37,7 +37,7 @@ trait HasSecretManagerAutocomplete
|
||||
|
||||
return $keys;
|
||||
} catch (\Throwable) {
|
||||
return [];
|
||||
throw new \RuntimeException('Unable to fetch secret manager keys.');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ return new class extends Migration
|
||||
Schema::create('secret_manager_links', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('uuid')->unique();
|
||||
$table->morphs('resourceable');
|
||||
$table->string('resourceable_type');
|
||||
$table->unsignedBigInteger('resourceable_id');
|
||||
$table->foreignId('integration_token_id')->constrained()->cascadeOnDelete();
|
||||
$table->json('settings')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
@@ -44,7 +44,6 @@
|
||||
this.vaultKeysLoading = false;
|
||||
this.handleInput();
|
||||
}).catch(() => {
|
||||
this.availableVars['vault'] = [];
|
||||
this.vaultKeysLoading = false;
|
||||
});
|
||||
},
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<template x-if="!isMultiline">
|
||||
<div wire:key="env-value-input">
|
||||
<x-forms.env-var-input placeholder="production" id="value" label="Value" required
|
||||
canGate="manageEnvironment" :canResource="$resource"
|
||||
:availableVars="$shared ? [] : $this->availableSharedVariables"
|
||||
:hasVaultSource="$this->hasSecretManagerSource()"
|
||||
:projectUuid="data_get($parameters, 'project_uuid')"
|
||||
|
||||
@@ -158,6 +158,7 @@
|
||||
</div>
|
||||
@else
|
||||
<x-forms.env-var-input id="value" type="password"
|
||||
canGate="manageEnvironment" :canResource="$this->resource"
|
||||
:required="$is_redis_credential" :disabled="!$canEditValue"
|
||||
:availableVars="$isSharedVariable ? [] : $this->availableSharedVariables"
|
||||
:hasVaultSource="$this->hasSecretManagerSource()"
|
||||
|
||||
@@ -89,8 +89,8 @@
|
||||
<span class="font-mono text-[12px] text-black dark:text-fg">{{ $key }}</span>
|
||||
<span class="font-mono text-[11px] text-neutral-400 dark:text-fg-dim">{{ '{{vault.'.$key.'}'.'}' }}</span>
|
||||
</div>
|
||||
<x-forms.button wire:click="addReference('{{ $key }}')"
|
||||
wire:target="addReference('{{ $key }}')">
|
||||
<x-forms.button wire:click="addReference({{ \Illuminate\Support\Js::from($key) }})"
|
||||
wire:target="addReference({{ \Illuminate\Support\Js::from($key) }})">
|
||||
Add as variable
|
||||
</x-forms.button>
|
||||
</div>
|
||||
|
||||
@@ -15,17 +15,40 @@ use Illuminate\Support\Collection;
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('does not persist environment write commands or generated Dockerfiles in deployment logs', function () {
|
||||
$source = file_get_contents(app_path('Jobs/ApplicationDeploymentJob.php'));
|
||||
$finalDockerfileWrite = str($source)
|
||||
->after("addLogEntry('Final Dockerfile:'")
|
||||
->before('private function modify_dockerfile_for_secrets')
|
||||
->toString();
|
||||
[$application, $server] = makeDeploymentControlVarFixture();
|
||||
|
||||
expect($source)
|
||||
->and(substr_count($source, "'skip_command_log' => true"))->toBeGreaterThanOrEqual(4);
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'APP_SECRET',
|
||||
'value' => 'sensitive-value',
|
||||
]);
|
||||
|
||||
expect($finalDockerfileWrite)
|
||||
->not->toContain('executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}")');
|
||||
[$job, $reflection] = makeControlVarFilteringJob($application, $server, [
|
||||
'configuration_dir' => '/data/coolify/applications/test-app',
|
||||
'remote_secrets_cache' => [],
|
||||
'saved_outputs' => [
|
||||
'dockerfile' => "FROM php:8.4-cli\nRUN php -v",
|
||||
],
|
||||
]);
|
||||
|
||||
invokeDeploymentJobMethod($job, $reflection, 'save_runtime_environment_variables');
|
||||
invokeDeploymentJobMethod($job, $reflection, 'save_buildtime_environment_variables');
|
||||
invokeDeploymentJobMethod($job, $reflection, 'add_build_env_variables_to_dockerfile');
|
||||
|
||||
$writeCommands = collect($job->recordedCommands)
|
||||
->flatMap(fn (array $commands): array => $commands)
|
||||
->filter(function (mixed $command): bool {
|
||||
if (! is_array($command)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$commandString = $command['command'] ?? $command[0] ?? null;
|
||||
|
||||
return is_string($commandString) && str_contains($commandString, 'base64 -d | tee');
|
||||
})
|
||||
->values();
|
||||
|
||||
expect($writeCommands)->toHaveCount(4)
|
||||
->each->toHaveKey('skip_command_log', true);
|
||||
});
|
||||
|
||||
it('redacts resolved remote secrets from command output', function () {
|
||||
@@ -38,6 +61,21 @@ it('redacts resolved remote secrets from command output', function () {
|
||||
->toBe('token='.REDACTED);
|
||||
});
|
||||
|
||||
it('ignores empty and non-string remote secrets when redacting command output', function () {
|
||||
[$application, $server] = makeDeploymentControlVarFixture();
|
||||
[$job, $reflection] = makeControlVarFilteringJob($application, $server, [
|
||||
'remote_secrets_cache' => [
|
||||
'EMPTY_SECRET' => '',
|
||||
'NULL_SECRET' => null,
|
||||
'NUMERIC_SECRET' => 123,
|
||||
'API_TOKEN' => 'remote-secret-value',
|
||||
],
|
||||
]);
|
||||
|
||||
expect(invokeDeploymentJobMethod($job, $reflection, 'redact_sensitive_info', 'id=123 token=remote-secret-value'))
|
||||
->toBe('id=123 token='.REDACTED);
|
||||
});
|
||||
|
||||
class TestableControlVarFilteringDeploymentJob extends ApplicationDeploymentJob
|
||||
{
|
||||
public array $recordedCommands = [];
|
||||
@@ -432,6 +470,45 @@ it('filters buildpack control vars from dockerfile arg injection', function () {
|
||||
expect($job->writtenDockerfile)->not->toContain('ARG RAILPACK_NODE_VERSION=');
|
||||
});
|
||||
|
||||
it('injects raw escaped remote secrets into Dockerfile args and hashes the same values', function (int $pullRequestId, bool $isPreview) {
|
||||
[$application, $server] = makeDeploymentControlVarFixture();
|
||||
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'SECRET_TOKEN',
|
||||
'value' => '{{vault.API_TOKEN}}',
|
||||
'is_preview' => $isPreview,
|
||||
'is_runtime' => false,
|
||||
'is_buildtime' => true,
|
||||
]);
|
||||
|
||||
$secret = "secret\$value'quoted";
|
||||
$escapedSecret = escapeBashEnvValue($secret);
|
||||
[$job, $reflection] = makeControlVarFilteringJob($application, $server, [
|
||||
'pull_request_id' => $pullRequestId,
|
||||
'remote_secrets_cache' => ['API_TOKEN' => $secret],
|
||||
'saved_outputs' => [
|
||||
'dockerfile' => "FROM php:8.4-cli\nRUN php -v",
|
||||
],
|
||||
]);
|
||||
|
||||
invokeDeploymentJobMethod($job, $reflection, 'add_build_env_variables_to_dockerfile');
|
||||
|
||||
$expectedHash = invokeDeploymentJobMethod(
|
||||
$job,
|
||||
$reflection,
|
||||
'generate_secrets_hash',
|
||||
collect(['SECRET_TOKEN' => $escapedSecret]),
|
||||
);
|
||||
|
||||
expect($job->writtenDockerfile)
|
||||
->toContain("ARG SECRET_TOKEN={$escapedSecret}")
|
||||
->toContain("ARG COOLIFY_BUILD_SECRETS_HASH={$expectedHash}")
|
||||
->not->toContain('$$');
|
||||
})->with([
|
||||
'production' => [0, false],
|
||||
'preview' => [99, true],
|
||||
]);
|
||||
|
||||
it('builds railpack variables from generic buildtime vars railpack vars and coolify vars only', function () {
|
||||
[$application, $server] = makeDeploymentControlVarFixture([
|
||||
'build_pack' => 'railpack',
|
||||
|
||||
@@ -18,6 +18,28 @@ it('keeps the environment variable input enabled while secret manager keys load'
|
||||
expect($view)->toContain('wire:target.except="fetchSecretManagerKeys"');
|
||||
});
|
||||
|
||||
it('allows secret manager key loading to retry after a failed request', function () {
|
||||
$view = file_get_contents(resource_path('views/components/forms/env-var-input.blade.php'));
|
||||
$failureHandler = explode('});', explode('.catch(() => {', $view, 2)[1], 2)[0];
|
||||
|
||||
expect($failureHandler)
|
||||
->toContain('this.vaultKeysLoading = false;')
|
||||
->not->toContain("this.availableVars['vault'] = [];");
|
||||
});
|
||||
|
||||
it('authorizes secret-enabled environment variable inputs at the component boundary', function () {
|
||||
$addView = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/add.blade.php'));
|
||||
$showView = file_get_contents(resource_path('views/livewire/project/shared/environment-variable/show.blade.php'));
|
||||
|
||||
foreach ([$addView, $showView] as $view) {
|
||||
preg_match('/<x-forms\.env-var-input[\s\S]*?\/>/', $view, $matches);
|
||||
|
||||
expect($matches[0] ?? '')
|
||||
->toContain('canGate="manageEnvironment"')
|
||||
->toContain(':canResource="$resource"');
|
||||
}
|
||||
});
|
||||
|
||||
it('passes the remove source warning without compiling remote secret syntax as blade', function () {
|
||||
$view = file_get_contents(resource_path('views/livewire/project/shared/secret-manager-links.blade.php'));
|
||||
|
||||
|
||||
@@ -144,6 +144,23 @@ test('is_shared attribute detects variable without spaces', function () {
|
||||
expect($env->is_shared)->toBeTrue();
|
||||
});
|
||||
|
||||
test('is_shared persisted value rejects unsupported reference types', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'TEST',
|
||||
'value' => '{{vault.KEY}}',
|
||||
'resource_id' => $this->application->id,
|
||||
'resource_type' => $this->application->getMorphClass(),
|
||||
]);
|
||||
|
||||
$env->refresh();
|
||||
|
||||
expect($env->is_shared)->toBeFalse()
|
||||
->and(EnvironmentVariable::query()
|
||||
->whereKey($env->id)
|
||||
->where('is_shared', false)
|
||||
->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('non-shared variable preserves spaces', function () {
|
||||
$env = EnvironmentVariable::create([
|
||||
'key' => 'REGULAR',
|
||||
|
||||
@@ -58,6 +58,25 @@ test('a secret manager integration token can be created through the api', functi
|
||||
->and($token->capabilities)->toBe(['secrets']);
|
||||
});
|
||||
|
||||
test('secret manager provider base urls only accept http and https', function (string $provider, array $metadata) {
|
||||
Http::fake();
|
||||
|
||||
$this->withHeaders(secretManagerApiHeaders($this->bearerToken))
|
||||
->postJson('/api/v1/security/integration-tokens', [
|
||||
'provider' => $provider,
|
||||
'name' => 'Invalid base URL',
|
||||
'token' => 'token',
|
||||
'metadata' => $metadata,
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('metadata.base_url');
|
||||
|
||||
Http::assertNothingSent();
|
||||
})->with([
|
||||
'infisical' => ['infisical', ['base_url' => 'ftp://infisical.example.com', 'client_id' => 'client-1']],
|
||||
'vault' => ['vault', ['base_url' => 'ftp://vault.example.com']],
|
||||
]);
|
||||
|
||||
test('an application can be configured to use a secret manager through the api', function () {
|
||||
$token = IntegrationToken::query()->create([
|
||||
'team_id' => $this->team->id,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('resourceable columns have one composite unique index', function () {
|
||||
$resourceableIndexes = collect(Schema::getIndexes('secret_manager_links'))
|
||||
->filter(fn (array $index): bool => $index['columns'] === ['resourceable_type', 'resourceable_id'])
|
||||
->values();
|
||||
|
||||
expect($resourceableIndexes)
|
||||
->toHaveCount(1)
|
||||
->and($resourceableIndexes->first()['unique'])->toBeTrue();
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Database\StartRedis;
|
||||
use App\Exceptions\DeploymentException;
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Livewire\Security\IntegrationTokens;
|
||||
@@ -12,6 +13,7 @@ use App\Models\Project;
|
||||
use App\Models\SecretManagerLink;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use App\Models\SharedEnvironmentVariable;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Models\StandaloneKeydb;
|
||||
@@ -149,14 +151,14 @@ test('a doppler link fetches secrets with the stored token', function () {
|
||||
|
||||
test('a vault link uses the base url and namespace from the token metadata', function () {
|
||||
Http::fake([
|
||||
'https://vault.internal:8200/v1/kv/data/apps/web' => Http::response([
|
||||
'https://example.com:8200/v1/kv/data/apps/web' => Http::response([
|
||||
'data' => ['data' => ['KEY' => 'value']],
|
||||
]),
|
||||
]);
|
||||
|
||||
$link = createSecretManagerLink('vault',
|
||||
['mount' => 'kv', 'path' => 'apps/web'],
|
||||
['base_url' => 'https://vault.internal:8200', 'namespace' => 'team-a'],
|
||||
['base_url' => 'https://example.com:8200', 'namespace' => 'team-a'],
|
||||
);
|
||||
|
||||
expect($link->fetchSecrets())->toBe(['KEY' => 'value']);
|
||||
@@ -192,6 +194,58 @@ test('services resolve environment variables from their configured secret manage
|
||||
expect($service->resolveSecretManagerEnvironmentVariable($environmentVariable))->toBe('remote-service-value');
|
||||
});
|
||||
|
||||
test('redis remote credentials stay deployment-local and use raw values in the start command', function () {
|
||||
Http::fake([
|
||||
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
|
||||
'REDIS_PASSWORD' => 'p4$$word',
|
||||
'REDIS_USERNAME' => 'remote-user',
|
||||
]),
|
||||
]);
|
||||
|
||||
$redis = StandaloneRedis::forceCreate([
|
||||
'uuid' => 'redis-secret-test',
|
||||
'name' => 'Redis secret test',
|
||||
'image' => 'redis:7-alpine',
|
||||
'environment_id' => $this->application->environment_id,
|
||||
'destination_id' => $this->application->destination_id,
|
||||
'destination_type' => $this->application->destination_type,
|
||||
]);
|
||||
$token = IntegrationToken::query()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'doppler',
|
||||
'name' => 'Redis secrets',
|
||||
'token' => 'the-secret-token',
|
||||
'capabilities' => ['secrets'],
|
||||
]);
|
||||
$redis->secretManagerLink()->create(['integration_token_id' => $token->id]);
|
||||
$sharedPassword = SharedEnvironmentVariable::query()->create([
|
||||
'key' => 'REDIS_PASSWORD',
|
||||
'value' => '{{vault.REDIS_PASSWORD}}',
|
||||
'type' => 'team',
|
||||
'team_id' => $this->team->id,
|
||||
]);
|
||||
$password = $redis->runtime_environment_variables()->create([
|
||||
'key' => 'REDIS_PASSWORD',
|
||||
'value' => '{{team.REDIS_PASSWORD}}',
|
||||
]);
|
||||
$username = $redis->runtime_environment_variables()->create([
|
||||
'key' => 'REDIS_USERNAME',
|
||||
'value' => '{{vault.REDIS_USERNAME}}',
|
||||
]);
|
||||
|
||||
$action = new StartRedis;
|
||||
$action->database = $redis;
|
||||
$environmentVariables = (new ReflectionMethod($action, 'generate_environment_variables'))->invoke($action);
|
||||
$startCommand = (new ReflectionMethod($action, 'buildStartCommand'))->invoke($action);
|
||||
|
||||
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_USERNAME=remote-user')
|
||||
->and($startCommand)->toContain('--requirepass p4$$word');
|
||||
});
|
||||
|
||||
test('all deployable environment-variable resources support secret managers', function (string $resourceClass) {
|
||||
expect(class_uses_recursive($resourceClass))->toContain(HasSecretManager::class);
|
||||
})->with([
|
||||
@@ -295,6 +349,15 @@ test('variables without references never contact the secret manager', function (
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('a null environment variable value remains null', function () {
|
||||
$env = $this->application->environment_variables()->create([
|
||||
'key' => 'EMPTY',
|
||||
'value' => null,
|
||||
]);
|
||||
|
||||
expect($this->application->resolveSecretManagerEnvironmentVariable($env))->toBeNull();
|
||||
});
|
||||
|
||||
test('a missing secret key fails the deployment and names the variable', function () {
|
||||
Http::fake([
|
||||
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
|
||||
@@ -386,7 +449,7 @@ test('remote secret values are formatted as dotenv literals', function () {
|
||||
expect($format('simple'))->toBe("'simple'")
|
||||
->and($format('with $dollar and spaces'))->toBe("'with \$dollar and spaces'")
|
||||
->and($format("it's quoted"))->toBe('"it\'s quoted"')
|
||||
->and($format('{"json": true}'))->toBe('{"json": true}');
|
||||
->and($format('{"json": true}'))->toBe('\'{"json": true}\'');
|
||||
});
|
||||
|
||||
test('deleting an integration token is blocked while links exist', function () {
|
||||
|
||||
@@ -12,6 +12,7 @@ use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Js;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
@@ -167,6 +168,27 @@ test('browse keys shows key names only and search filters them', function () {
|
||||
->assertDontSee('API_KEY');
|
||||
});
|
||||
|
||||
test('browse key actions encode apostrophes and backslashes', function () {
|
||||
Http::fake([
|
||||
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
|
||||
"TEAM'S_KEY" => 'apostrophe-secret',
|
||||
'TEAM\\KEY' => 'backslash-secret',
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->application->secretManagerLink()->create(['integration_token_id' => $this->token->id]);
|
||||
|
||||
$apostropheExpression = 'addReference('.Js::from("TEAM'S_KEY").')';
|
||||
$backslashExpression = 'addReference('.Js::from('TEAM\\KEY').')';
|
||||
|
||||
Livewire::test(SecretManagerLinks::class, ['resource' => $this->application])
|
||||
->call('loadKeys')
|
||||
->assertSeeHtml('wire:click="'.$apostropheExpression.'"')
|
||||
->assertSeeHtml('wire:target="'.$apostropheExpression.'"')
|
||||
->assertSeeHtml('wire:click="'.$backslashExpression.'"')
|
||||
->assertSeeHtml('wire:target="'.$backslashExpression.'"');
|
||||
});
|
||||
|
||||
test('add reference creates a variable with a secret reference value', function () {
|
||||
Http::fake([
|
||||
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
|
||||
@@ -225,7 +247,8 @@ test('members without update permission cannot save a source', function () {
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(SecretManagerLinks::class, ['resource' => $this->application])
|
||||
->set('integration_token_uuid', $this->token->uuid);
|
||||
->set('integration_token_uuid', $this->token->uuid)
|
||||
->assertDispatched('error', 'You need at least admin or owner permissions to update this application.');
|
||||
|
||||
$this->assertDatabaseCount('secret_manager_links', 0);
|
||||
});
|
||||
@@ -245,10 +268,19 @@ test('the edit modal value autocomplete offers the vault scope with lazy key fet
|
||||
->assertSeeHtml('hasVaultSource: true');
|
||||
|
||||
expect($component->instance()->fetchSecretManagerKeys())->toBe(['DB_PASSWORD']);
|
||||
});
|
||||
|
||||
$trait = file_get_contents(app_path('Traits/HasSecretManagerAutocomplete.php'));
|
||||
test('the edit modal value autocomplete reports secret provider failures', function () {
|
||||
Http::fake([
|
||||
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([], 503),
|
||||
]);
|
||||
|
||||
expect($trait)->toContain('$this->skipRender();');
|
||||
$this->application->secretManagerLink()->create(['integration_token_id' => $this->token->id]);
|
||||
$env = $this->application->environment_variables()->create(['key' => 'MY_VAR', 'value' => 'plain']);
|
||||
$component = Livewire::test(Show::class, ['env' => $env, 'type' => 'application']);
|
||||
|
||||
expect(fn () => $component->instance()->fetchSecretManagerKeys())
|
||||
->toThrow(RuntimeException::class, 'Unable to fetch secret manager keys.');
|
||||
});
|
||||
|
||||
test('the edit modal value autocomplete has no vault scope without a source', function () {
|
||||
|
||||
@@ -4,6 +4,7 @@ use App\Services\DopplerService;
|
||||
use App\Services\InfisicalService;
|
||||
use App\Services\VaultService;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
describe('DopplerService', function () {
|
||||
test('downloads secrets as a flat key value map', function () {
|
||||
@@ -66,12 +67,21 @@ describe('DopplerService', function () {
|
||||
});
|
||||
|
||||
describe('InfisicalService', function () {
|
||||
test('rejects an unapproved endpoint before sending credentials', function () {
|
||||
Http::fake();
|
||||
|
||||
expect(fn () => new InfisicalService('http://127.0.0.1:8080', 'client-id', 'client-secret'))
|
||||
->toThrow(ValidationException::class);
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('logs in with universal auth and fetches secrets from the v4 endpoint', function () {
|
||||
Http::fake([
|
||||
'https://infisical.example.com/api/v1/auth/universal-auth/login' => Http::response([
|
||||
'https://example.com/infisical/api/v1/auth/universal-auth/login' => Http::response([
|
||||
'accessToken' => 'short-lived-token',
|
||||
]),
|
||||
'https://infisical.example.com/api/v4/secrets*' => Http::response([
|
||||
'https://example.com/infisical/api/v4/secrets*' => Http::response([
|
||||
'secrets' => [
|
||||
['secretKey' => 'DB_PASSWORD', 'secretValue' => 's3cret'],
|
||||
['secretKey' => 'API_KEY', 'secretValue' => 'abc'],
|
||||
@@ -79,7 +89,7 @@ describe('InfisicalService', function () {
|
||||
]),
|
||||
]);
|
||||
|
||||
$service = new InfisicalService('https://infisical.example.com/', 'client-id', 'client-secret');
|
||||
$service = new InfisicalService('https://example.com/infisical/', 'client-id', 'client-secret');
|
||||
$secrets = $service->fetchSecrets('project-1', 'prod', '/');
|
||||
|
||||
expect($secrets)->toBe([
|
||||
@@ -94,18 +104,18 @@ describe('InfisicalService', function () {
|
||||
|
||||
test('falls back to the v3 raw endpoint on older self-hosted instances', function () {
|
||||
Http::fake([
|
||||
'https://infisical.example.com/api/v1/auth/universal-auth/login' => Http::response([
|
||||
'https://example.com/infisical/api/v1/auth/universal-auth/login' => Http::response([
|
||||
'accessToken' => 'short-lived-token',
|
||||
]),
|
||||
'https://infisical.example.com/api/v4/secrets*' => Http::response([], 404),
|
||||
'https://infisical.example.com/api/v3/secrets/raw*' => Http::response([
|
||||
'https://example.com/infisical/api/v4/secrets*' => Http::response([], 404),
|
||||
'https://example.com/infisical/api/v3/secrets/raw*' => Http::response([
|
||||
'secrets' => [
|
||||
['secretKey' => 'LEGACY_KEY', 'secretValue' => 'legacy-value'],
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
$service = new InfisicalService('https://infisical.example.com', 'client-id', 'client-secret');
|
||||
$service = new InfisicalService('https://example.com/infisical', 'client-id', 'client-secret');
|
||||
|
||||
expect($service->fetchSecrets('project-1', 'prod'))->toBe(['LEGACY_KEY' => 'legacy-value']);
|
||||
|
||||
@@ -114,12 +124,12 @@ describe('InfisicalService', function () {
|
||||
|
||||
test('throws when the login fails', function () {
|
||||
Http::fake([
|
||||
'https://infisical.example.com/api/v1/auth/universal-auth/login' => Http::response([
|
||||
'https://example.com/infisical/api/v1/auth/universal-auth/login' => Http::response([
|
||||
'message' => 'Invalid credentials',
|
||||
], 401),
|
||||
]);
|
||||
|
||||
$service = new InfisicalService('https://infisical.example.com', 'client-id', 'wrong');
|
||||
$service = new InfisicalService('https://example.com/infisical', 'client-id', 'wrong');
|
||||
|
||||
expect($service->validate())->toBeFalse()
|
||||
->and(fn () => $service->fetchSecrets('project-1', 'prod'))
|
||||
@@ -128,9 +138,18 @@ describe('InfisicalService', function () {
|
||||
});
|
||||
|
||||
describe('VaultService', function () {
|
||||
test('rejects an unapproved endpoint before sending the token', function () {
|
||||
Http::fake();
|
||||
|
||||
expect(fn () => new VaultService('http://127.0.0.1:8200', 'hvs.token'))
|
||||
->toThrow(ValidationException::class);
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('reads a kv v2 secret and stringifies non-string values', function () {
|
||||
Http::fake([
|
||||
'https://vault.example.com:8200/v1/secret/data/my-app/production' => Http::response([
|
||||
'https://example.com:8200/vault/v1/secret/data/my-app/production' => Http::response([
|
||||
'data' => [
|
||||
'data' => [
|
||||
'DB_PASSWORD' => 's3cret',
|
||||
@@ -140,7 +159,7 @@ describe('VaultService', function () {
|
||||
]),
|
||||
]);
|
||||
|
||||
$secrets = (new VaultService('https://vault.example.com:8200/', 'hvs.token'))
|
||||
$secrets = (new VaultService('https://example.com:8200/vault/', 'hvs.token'))
|
||||
->fetchSecrets('secret', '/my-app/production/');
|
||||
|
||||
expect($secrets)->toBe([
|
||||
@@ -154,12 +173,12 @@ describe('VaultService', function () {
|
||||
|
||||
test('sends the namespace header when configured', function () {
|
||||
Http::fake([
|
||||
'https://vault.example.com:8200/v1/secret/data/my-app' => Http::response([
|
||||
'https://example.com:8200/vault/v1/secret/data/my-app' => Http::response([
|
||||
'data' => ['data' => ['KEY' => 'value']],
|
||||
]),
|
||||
]);
|
||||
|
||||
(new VaultService('https://vault.example.com:8200', 'hvs.token', 'admin/team-a'))
|
||||
(new VaultService('https://example.com:8200/vault', 'hvs.token', 'admin/team-a'))
|
||||
->fetchSecrets('secret', 'my-app');
|
||||
|
||||
Http::assertSent(fn ($request) => $request->hasHeader('X-Vault-Namespace', 'admin/team-a'));
|
||||
@@ -167,20 +186,20 @@ describe('VaultService', function () {
|
||||
|
||||
test('throws a readable error when the read fails', function () {
|
||||
Http::fake([
|
||||
'https://vault.example.com:8200/v1/secret/data/missing' => Http::response([
|
||||
'https://example.com:8200/vault/v1/secret/data/missing' => Http::response([
|
||||
'errors' => ['permission denied'],
|
||||
], 403),
|
||||
]);
|
||||
|
||||
expect(fn () => (new VaultService('https://vault.example.com:8200', 'hvs.token'))->fetchSecrets('secret', 'missing'))
|
||||
expect(fn () => (new VaultService('https://example.com:8200/vault', 'hvs.token'))->fetchSecrets('secret', 'missing'))
|
||||
->toThrow(RuntimeException::class, 'Vault API error: permission denied');
|
||||
});
|
||||
|
||||
test('validates the token with lookup-self', function () {
|
||||
Http::fake([
|
||||
'https://vault.example.com:8200/v1/auth/token/lookup-self' => Http::response(['data' => []]),
|
||||
'https://example.com:8200/vault/v1/auth/token/lookup-self' => Http::response(['data' => []]),
|
||||
]);
|
||||
|
||||
expect((new VaultService('https://vault.example.com:8200', 'hvs.token'))->validate())->toBeTrue();
|
||||
expect((new VaultService('https://example.com:8200/vault', 'hvs.token'))->validate())->toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -100,6 +100,14 @@ test('at least one capability is required when adding a cloudflare token', funct
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('provider validation uses the provider names declared by the model', function () {
|
||||
$component = file_get_contents(app_path('Livewire/Security/IntegrationTokenForm.php'));
|
||||
|
||||
expect($component)
|
||||
->toContain("implode(',', array_keys(IntegrationToken::PROVIDER_NAMES))")
|
||||
->not->toContain('in:cloudflare,doppler,infisical,vault');
|
||||
});
|
||||
|
||||
test('integration tokens page lists saved provider and capabilities', function () {
|
||||
IntegrationToken::query()->create([
|
||||
'team_id' => $this->team->id,
|
||||
|
||||
@@ -62,9 +62,10 @@ test('an invalid doppler token is not saved', function () {
|
||||
Livewire::test(IntegrationTokenForm::class)
|
||||
->set('provider', 'doppler')
|
||||
->set('name', 'Bad token')
|
||||
->set('token', 'wrong')
|
||||
->set('token', 'dp.st.rejected')
|
||||
->call('addToken')
|
||||
->assertHasErrors(['token']);
|
||||
->assertHasNoErrors()
|
||||
->assertDispatched('error');
|
||||
|
||||
$this->assertDatabaseCount('integration_tokens', 0);
|
||||
});
|
||||
@@ -113,6 +114,24 @@ test('an infisical token requires a base url and a client id', function () {
|
||||
$this->assertDatabaseCount('integration_tokens', 0);
|
||||
});
|
||||
|
||||
test('secret manager provider base urls only accept http and https', function (string $provider, array $metadata) {
|
||||
Http::fake();
|
||||
|
||||
Livewire::test(IntegrationTokenForm::class)
|
||||
->set('provider', $provider)
|
||||
->set('name', 'Invalid base URL')
|
||||
->set('token', 'token')
|
||||
->set('metadata', $metadata)
|
||||
->call('addToken')
|
||||
->assertHasErrors(['metadata.base_url']);
|
||||
|
||||
Http::assertNothingSent();
|
||||
$this->assertDatabaseCount('integration_tokens', 0);
|
||||
})->with([
|
||||
'infisical' => ['infisical', ['base_url' => 'ftp://infisical.example.com', 'client_id' => 'client-1']],
|
||||
'vault' => ['vault', ['base_url' => 'ftp://vault.example.com']],
|
||||
]);
|
||||
|
||||
test('the infisical fields put the client id before the client secret', function () {
|
||||
Livewire::test(IntegrationTokenForm::class)
|
||||
->set('provider', 'infisical')
|
||||
@@ -121,7 +140,7 @@ test('the infisical fields put the client id before the client secret', function
|
||||
|
||||
test('an infisical token stores its metadata after a successful login', function () {
|
||||
Http::fake([
|
||||
'https://infisical.example.com/api/v1/auth/universal-auth/login' => Http::response([
|
||||
'https://example.com/api/v1/auth/universal-auth/login' => Http::response([
|
||||
'accessToken' => 'token',
|
||||
]),
|
||||
]);
|
||||
@@ -130,26 +149,26 @@ test('an infisical token stores its metadata after a successful login', function
|
||||
->set('provider', 'infisical')
|
||||
->set('name', 'Infisical')
|
||||
->set('token', 'client-secret')
|
||||
->set('metadata', ['base_url' => 'https://infisical.example.com', 'client_id' => 'client-1'])
|
||||
->set('metadata', ['base_url' => 'https://example.com', 'client_id' => 'client-1'])
|
||||
->call('addToken')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$token = IntegrationToken::query()->where('provider', 'infisical')->firstOrFail();
|
||||
|
||||
expect($token->metadata)->toBe(['base_url' => 'https://infisical.example.com', 'client_id' => 'client-1'])
|
||||
expect($token->metadata)->toBe(['base_url' => 'https://example.com', 'client_id' => 'client-1'])
|
||||
->and($token->capabilities)->toBe(['secrets']);
|
||||
});
|
||||
|
||||
test('a vault token is validated with lookup-self before it is saved', function () {
|
||||
Http::fake([
|
||||
'https://vault.example.com:8200/v1/auth/token/lookup-self' => Http::response(['data' => []]),
|
||||
'https://example.com:8200/v1/auth/token/lookup-self' => Http::response(['data' => []]),
|
||||
]);
|
||||
|
||||
Livewire::test(IntegrationTokenForm::class)
|
||||
->set('provider', 'vault')
|
||||
->set('name', 'Vault')
|
||||
->set('token', 'hvs.token')
|
||||
->set('metadata', ['base_url' => 'https://vault.example.com:8200'])
|
||||
->set('metadata', ['base_url' => 'https://example.com:8200'])
|
||||
->call('addToken')
|
||||
->assertHasNoErrors();
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
|
||||
it('quotes JSON remote secrets so compose treats their contents literally', function (string $value, string $expected) {
|
||||
$job = (new ReflectionClass(ApplicationDeploymentJob::class))->newInstanceWithoutConstructor();
|
||||
$method = new ReflectionMethod(ApplicationDeploymentJob::class, 'format_remote_secret_value');
|
||||
|
||||
expect($method->invoke($job, $value))->toBe($expected);
|
||||
})->with([
|
||||
'object containing a variable reference' => ['{"password":"$ecret"}', '\'{"password":"$ecret"}\''],
|
||||
'array containing a comment marker' => ['["value # not a comment"]', '\'["value # not a comment"]\''],
|
||||
'object containing an apostrophe' => ['{"password":"it\'s $ecret"}', '"{\\"password\\":\\"it\'s $$ecret\\"}"'],
|
||||
]);
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
it('reuses resolved environment credentials in database startup integrations', function (string $action, array $expected, array $unexpected) {
|
||||
$source = file_get_contents(__DIR__."/../../app/Actions/Database/{$action}.php");
|
||||
|
||||
expect($source)->toContain(...$expected)
|
||||
->not->toContain(...$unexpected);
|
||||
})->with([
|
||||
'clickhouse' => [
|
||||
'StartClickhouse',
|
||||
['$this->resolvedClickhouseUser', '$this->resolvedClickhousePassword'],
|
||||
['$this->database->clickhouse_admin_user, \'--password\'', '$this->database->clickhouse_admin_password, \'--query\''],
|
||||
],
|
||||
'dragonfly' => [
|
||||
'StartDragonfly',
|
||||
['$this->resolvedRedisPassword'],
|
||||
['$this->database->dragonfly_password, \'ping\'', 'requirepass {$this->database->dragonfly_password}'],
|
||||
],
|
||||
'keydb' => [
|
||||
'StartKeydb',
|
||||
['$this->resolvedRedisPassword'],
|
||||
['$this->database->keydb_password, \'ping\'', 'requirepass {$this->database->keydb_password}'],
|
||||
],
|
||||
'mongodb' => [
|
||||
'StartMongodb',
|
||||
['$this->resolvedMongoDatabase', '$this->resolvedMongoUsername', '$this->resolvedMongoPassword'],
|
||||
['json_encode($this->database->mongo_initdb_database', 'json_encode($this->database->mongo_initdb_root_username', 'json_encode($this->database->mongo_initdb_root_password'],
|
||||
],
|
||||
'mysql' => [
|
||||
'StartMysql',
|
||||
['$this->resolvedMysqlRootPassword'],
|
||||
['-p{$this->database->mysql_root_password}'],
|
||||
],
|
||||
'postgresql' => [
|
||||
'StartPostgresql',
|
||||
['$this->resolvedPostgresUser', '$this->resolvedPostgresDatabase'],
|
||||
['$this->database->postgres_user, \'-d\'', '$this->database->postgres_db, \'-c\''],
|
||||
],
|
||||
]);
|
||||
@@ -26,6 +26,14 @@ test('extracts unique referenced keys in order', function () {
|
||||
expect(RemoteSecretReferences::referencedKeys($value))->toBe(['A']);
|
||||
});
|
||||
|
||||
test('handles padded reference syntax consistently', function () {
|
||||
expect(RemoteSecretReferences::referencedKeys('{{ vault.A }}'))->toBe(['A'])
|
||||
->and(RemoteSecretReferences::substitute('{{ vault.A }} {{ vault.MISSING }}', ['A' => 'value-a']))
|
||||
->toBe('value-a {{ vault.MISSING }}')
|
||||
->and(RemoteSecretReferences::missingKeys('{{ vault.A }} {{ vault.MISSING }}', ['A' => 'value-a']))
|
||||
->toBe(['MISSING']);
|
||||
});
|
||||
|
||||
test('substitutes references and leaves unknown keys untouched', function () {
|
||||
$secrets = ['A' => 'value-a'];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user