mirror of
https://github.com/coollabsio/coolify.git
synced 2026-08-24 02:24:11 -05:00
Merge remote-tracking branch 'origin/next' into team-resource-audit-logging
This commit is contained in:
@@ -1,7 +0,0 @@
|
||||
# Lessons
|
||||
|
||||
## Alpine x-transition + tw-animate-css exit animations flash at the end
|
||||
- Symptom: a modal/overlay fades out, then flashes fully visible for 1-2 frames before it disappears.
|
||||
- Cause: `animate-out` keyframes default to `animation-fill-mode: none`. The element snaps back to its natural state when the keyframe ends. Alpine hides the element (display: none) only after its own timer (read from `transition-duration`), which starts ~2 rAF later than the animation. The gap shows the element at full opacity.
|
||||
- Rule: every `x-transition:leave` that uses tw-animate-css `animate-out` MUST also include `fill-mode-forwards`.
|
||||
- Rule: when a user reports UI flicker, check ALL layers of the animation stack (state reset timing, spinner flash, keyframe fill mode, focus restore) before you report the fix as complete. My first fix covered state reset and spinner only; the fill-mode snap was the visible one.
|
||||
@@ -3,12 +3,14 @@
|
||||
namespace App\Actions\Database;
|
||||
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Traits\ExecutesDatabaseStartCommands;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class StartClickhouse
|
||||
{
|
||||
use AsAction;
|
||||
use AsAction, ExecutesDatabaseStartCommands;
|
||||
|
||||
public StandaloneClickhouse $database;
|
||||
|
||||
@@ -16,7 +18,11 @@ class StartClickhouse
|
||||
|
||||
public string $configuration_dir;
|
||||
|
||||
public function handle(StandaloneClickhouse $database)
|
||||
private string $resolvedClickhouseUser;
|
||||
|
||||
private string $resolvedClickhousePassword;
|
||||
|
||||
public function handle(StandaloneClickhouse $database, ?Activity $activity = null)
|
||||
{
|
||||
$this->database = $database;
|
||||
|
||||
@@ -51,7 +57,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,
|
||||
@@ -109,7 +115,7 @@ class StartClickhouse
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
|
||||
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
|
||||
return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
|
||||
}
|
||||
|
||||
private function generate_local_persistent_volumes()
|
||||
@@ -147,8 +153,17 @@ 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=$env->real_value");
|
||||
$rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);
|
||||
$resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);
|
||||
$environment_variables->push($env->key.'='.$resolvedValue);
|
||||
if ($env->key === 'CLICKHOUSE_USER') {
|
||||
$this->resolvedClickhouseUser = $rawValue;
|
||||
} elseif ($env->key === 'CLICKHOUSE_PASSWORD') {
|
||||
$this->resolvedClickhousePassword = $rawValue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($environment_variables->filter(fn ($env) => str($env)->contains('CLICKHOUSE_USER'))->isEmpty()) {
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
namespace App\Actions\Database;
|
||||
|
||||
use App\Enums\ActivityTypes;
|
||||
use App\Enums\ProcessStatus;
|
||||
use App\Jobs\DatabaseStartJob;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Models\StandaloneKeydb;
|
||||
@@ -12,6 +15,7 @@ use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Lorisleiva\Actions\Decorators\JobDecorator;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
class StartDatabase
|
||||
{
|
||||
@@ -22,38 +26,38 @@ class StartDatabase
|
||||
$job->onQueue(deployment_queue());
|
||||
}
|
||||
|
||||
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database)
|
||||
public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database): Activity|string
|
||||
{
|
||||
$server = $database->destination->server;
|
||||
if (! $server->isFunctional()) {
|
||||
return 'Server is not functional';
|
||||
}
|
||||
switch ($database->getMorphClass()) {
|
||||
case StandalonePostgresql::class:
|
||||
$activity = StartPostgresql::run($database);
|
||||
break;
|
||||
case StandaloneRedis::class:
|
||||
$activity = StartRedis::run($database);
|
||||
break;
|
||||
case StandaloneMongodb::class:
|
||||
$activity = StartMongodb::run($database);
|
||||
break;
|
||||
case StandaloneMysql::class:
|
||||
$activity = StartMysql::run($database);
|
||||
break;
|
||||
case StandaloneMariadb::class:
|
||||
$activity = StartMariadb::run($database);
|
||||
break;
|
||||
case StandaloneKeydb::class:
|
||||
$activity = StartKeydb::run($database);
|
||||
break;
|
||||
case StandaloneDragonfly::class:
|
||||
$activity = StartDragonfly::run($database);
|
||||
break;
|
||||
case StandaloneClickhouse::class:
|
||||
$activity = StartClickhouse::run($database);
|
||||
break;
|
||||
|
||||
$activity = activity()
|
||||
->withProperties([
|
||||
'server_uuid' => $server->uuid,
|
||||
'type' => ActivityTypes::INLINE->value,
|
||||
'type_uuid' => $database->uuid,
|
||||
'status' => ProcessStatus::QUEUED->value,
|
||||
'team_id' => $server->team_id,
|
||||
'operation' => 'database-start',
|
||||
])
|
||||
->performedOn($database)
|
||||
->event(ActivityTypes::INLINE->value)
|
||||
->log('[]');
|
||||
|
||||
if ($activity === null) {
|
||||
return 'Database start could not be queued because activity logging is disabled.';
|
||||
}
|
||||
|
||||
DatabaseStartJob::dispatch(
|
||||
$database->getMorphClass(),
|
||||
(int) $database->getKey(),
|
||||
(int) $database->team()->id,
|
||||
(int) $activity->getKey(),
|
||||
auth()->id(),
|
||||
);
|
||||
|
||||
if ($database->is_public && $database->public_port) {
|
||||
StartDatabaseProxy::dispatch($database);
|
||||
}
|
||||
|
||||
@@ -5,12 +5,14 @@ namespace App\Actions\Database;
|
||||
use App\Helpers\SslHelper;
|
||||
use App\Models\SslCertificate;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Traits\ExecutesDatabaseStartCommands;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class StartDragonfly
|
||||
{
|
||||
use AsAction;
|
||||
use AsAction, ExecutesDatabaseStartCommands;
|
||||
|
||||
public StandaloneDragonfly $database;
|
||||
|
||||
@@ -20,7 +22,9 @@ class StartDragonfly
|
||||
|
||||
private ?SslCertificate $ssl_certificate = null;
|
||||
|
||||
public function handle(StandaloneDragonfly $database)
|
||||
private string $resolvedRedisPassword;
|
||||
|
||||
public function handle(StandaloneDragonfly $database, ?Activity $activity = null)
|
||||
{
|
||||
$this->database = $database;
|
||||
|
||||
@@ -107,7 +111,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,
|
||||
@@ -196,12 +200,13 @@ class StartDragonfly
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
|
||||
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
|
||||
return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
|
||||
}
|
||||
|
||||
private function buildStartCommand(): string
|
||||
{
|
||||
$command = "dragonfly --requirepass {$this->database->dragonfly_password}";
|
||||
$escapedRedisPassword = escapeshellarg($this->resolvedRedisPassword);
|
||||
$command = "dragonfly --requirepass {$escapedRedisPassword}";
|
||||
|
||||
if ($this->database->enable_ssl) {
|
||||
$sslArgs = [
|
||||
@@ -251,8 +256,14 @@ 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=$env->real_value");
|
||||
$rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);
|
||||
$resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);
|
||||
$environment_variables->push($env->key.'='.$resolvedValue);
|
||||
if ($env->key === 'REDIS_PASSWORD') {
|
||||
$this->resolvedRedisPassword = $rawValue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($environment_variables->filter(fn ($env) => str($env)->contains('REDIS_PASSWORD'))->isEmpty()) {
|
||||
|
||||
@@ -5,12 +5,14 @@ namespace App\Actions\Database;
|
||||
use App\Helpers\SslHelper;
|
||||
use App\Models\SslCertificate;
|
||||
use App\Models\StandaloneKeydb;
|
||||
use App\Traits\ExecutesDatabaseStartCommands;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class StartKeydb
|
||||
{
|
||||
use AsAction;
|
||||
use AsAction, ExecutesDatabaseStartCommands;
|
||||
|
||||
public StandaloneKeydb $database;
|
||||
|
||||
@@ -20,7 +22,9 @@ class StartKeydb
|
||||
|
||||
private ?SslCertificate $ssl_certificate = null;
|
||||
|
||||
public function handle(StandaloneKeydb $database)
|
||||
private string $resolvedRedisPassword;
|
||||
|
||||
public function handle(StandaloneKeydb $database, ?Activity $activity = null)
|
||||
{
|
||||
$this->database = $database;
|
||||
|
||||
@@ -109,7 +113,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,
|
||||
@@ -214,7 +218,7 @@ class StartKeydb
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
|
||||
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
|
||||
return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
|
||||
}
|
||||
|
||||
private function generate_local_persistent_volumes()
|
||||
@@ -252,8 +256,14 @@ 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=$env->real_value");
|
||||
$rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);
|
||||
$resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);
|
||||
$environment_variables->push($env->key.'='.$resolvedValue);
|
||||
if ($env->key === 'REDIS_PASSWORD') {
|
||||
$this->resolvedRedisPassword = $rawValue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($environment_variables->filter(fn ($env) => str($env)->contains('REDIS_PASSWORD'))->isEmpty()) {
|
||||
@@ -280,6 +290,7 @@ class StartKeydb
|
||||
{
|
||||
$hasKeydbConf = ! is_null($this->database->keydb_conf) && ! empty($this->database->keydb_conf);
|
||||
$keydbConfPath = '/etc/keydb/keydb.conf';
|
||||
$escapedRedisPassword = escapeshellarg($this->resolvedRedisPassword);
|
||||
|
||||
if ($hasKeydbConf) {
|
||||
$confContent = $this->database->keydb_conf;
|
||||
@@ -288,10 +299,10 @@ class StartKeydb
|
||||
if ($hasRequirePass) {
|
||||
$command = "keydb-server $keydbConfPath";
|
||||
} else {
|
||||
$command = "keydb-server $keydbConfPath --requirepass {$this->database->keydb_password}";
|
||||
$command = "keydb-server $keydbConfPath --requirepass {$escapedRedisPassword}";
|
||||
}
|
||||
} else {
|
||||
$command = "keydb-server --requirepass {$this->database->keydb_password} --appendonly yes";
|
||||
$command = "keydb-server --requirepass {$escapedRedisPassword} --appendonly yes";
|
||||
}
|
||||
|
||||
if ($this->database->enable_ssl) {
|
||||
|
||||
@@ -5,12 +5,14 @@ namespace App\Actions\Database;
|
||||
use App\Helpers\SslHelper;
|
||||
use App\Models\SslCertificate;
|
||||
use App\Models\StandaloneMariadb;
|
||||
use App\Traits\ExecutesDatabaseStartCommands;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class StartMariadb
|
||||
{
|
||||
use AsAction;
|
||||
use AsAction, ExecutesDatabaseStartCommands;
|
||||
|
||||
public StandaloneMariadb $database;
|
||||
|
||||
@@ -20,7 +22,7 @@ class StartMariadb
|
||||
|
||||
private ?SslCertificate $ssl_certificate = null;
|
||||
|
||||
public function handle(StandaloneMariadb $database)
|
||||
public function handle(StandaloneMariadb $database, ?Activity $activity = null)
|
||||
{
|
||||
$this->database = $database;
|
||||
|
||||
@@ -216,7 +218,7 @@ class StartMariadb
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
|
||||
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
|
||||
return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
|
||||
}
|
||||
|
||||
private function generate_local_persistent_volumes()
|
||||
@@ -255,7 +257,7 @@ class StartMariadb
|
||||
{
|
||||
$environment_variables = collect();
|
||||
foreach ($this->database->runtime_environment_variables as $env) {
|
||||
$environment_variables->push("$env->key=$env->real_value");
|
||||
$environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env));
|
||||
}
|
||||
|
||||
if ($environment_variables->filter(fn ($env) => str($env)->contains('MARIADB_ROOT_PASSWORD'))->isEmpty()) {
|
||||
|
||||
@@ -5,12 +5,14 @@ namespace App\Actions\Database;
|
||||
use App\Helpers\SslHelper;
|
||||
use App\Models\SslCertificate;
|
||||
use App\Models\StandaloneMongodb;
|
||||
use App\Traits\ExecutesDatabaseStartCommands;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class StartMongodb
|
||||
{
|
||||
use AsAction;
|
||||
use AsAction, ExecutesDatabaseStartCommands;
|
||||
|
||||
public StandaloneMongodb $database;
|
||||
|
||||
@@ -20,7 +22,13 @@ class StartMongodb
|
||||
|
||||
private ?SslCertificate $ssl_certificate = null;
|
||||
|
||||
public function handle(StandaloneMongodb $database)
|
||||
private string $resolvedMongoUsername;
|
||||
|
||||
private string $resolvedMongoPassword;
|
||||
|
||||
private string $resolvedMongoDatabase;
|
||||
|
||||
public function handle(StandaloneMongodb $database, ?Activity $activity = null)
|
||||
{
|
||||
$this->database = $database;
|
||||
|
||||
@@ -265,7 +273,7 @@ class StartMongodb
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
|
||||
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
|
||||
return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
|
||||
}
|
||||
|
||||
private function generate_local_persistent_volumes()
|
||||
@@ -303,8 +311,20 @@ 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=$env->real_value");
|
||||
$rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);
|
||||
$resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);
|
||||
$environment_variables->push($env->key.'='.$resolvedValue);
|
||||
if ($env->key === 'MONGO_INITDB_ROOT_USERNAME') {
|
||||
$this->resolvedMongoUsername = $rawValue;
|
||||
} elseif ($env->key === 'MONGO_INITDB_ROOT_PASSWORD') {
|
||||
$this->resolvedMongoPassword = $rawValue;
|
||||
} elseif ($env->key === 'MONGO_INITDB_DATABASE') {
|
||||
$this->resolvedMongoDatabase = $rawValue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($environment_variables->filter(fn ($env) => str($env)->contains('MONGO_INITDB_ROOT_USERNAME'))->isEmpty()) {
|
||||
@@ -337,9 +357,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";
|
||||
|
||||
@@ -5,12 +5,14 @@ namespace App\Actions\Database;
|
||||
use App\Helpers\SslHelper;
|
||||
use App\Models\SslCertificate;
|
||||
use App\Models\StandaloneMysql;
|
||||
use App\Traits\ExecutesDatabaseStartCommands;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class StartMysql
|
||||
{
|
||||
use AsAction;
|
||||
use AsAction, ExecutesDatabaseStartCommands;
|
||||
|
||||
public StandaloneMysql $database;
|
||||
|
||||
@@ -20,7 +22,9 @@ class StartMysql
|
||||
|
||||
private ?SslCertificate $ssl_certificate = null;
|
||||
|
||||
public function handle(StandaloneMysql $database)
|
||||
private string $resolvedMysqlRootPassword;
|
||||
|
||||
public function handle(StandaloneMysql $database, ?Activity $activity = null)
|
||||
{
|
||||
$this->database = $database;
|
||||
|
||||
@@ -104,7 +108,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,
|
||||
@@ -218,7 +222,7 @@ class StartMysql
|
||||
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
|
||||
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
|
||||
return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
|
||||
}
|
||||
|
||||
private function generate_local_persistent_volumes()
|
||||
@@ -256,8 +260,14 @@ 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=$env->real_value");
|
||||
$rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);
|
||||
$resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);
|
||||
$environment_variables->push($env->key.'='.$resolvedValue);
|
||||
if ($env->key === 'MYSQL_ROOT_PASSWORD') {
|
||||
$this->resolvedMysqlRootPassword = $rawValue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($environment_variables->filter(fn ($env) => str($env)->contains('MYSQL_ROOT_PASSWORD'))->isEmpty()) {
|
||||
|
||||
@@ -5,12 +5,14 @@ namespace App\Actions\Database;
|
||||
use App\Helpers\SslHelper;
|
||||
use App\Models\SslCertificate;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Traits\ExecutesDatabaseStartCommands;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class StartPostgresql
|
||||
{
|
||||
use AsAction;
|
||||
use AsAction, ExecutesDatabaseStartCommands;
|
||||
|
||||
public StandalonePostgresql $database;
|
||||
|
||||
@@ -22,7 +24,11 @@ class StartPostgresql
|
||||
|
||||
private ?SslCertificate $ssl_certificate = null;
|
||||
|
||||
public function handle(StandalonePostgresql $database)
|
||||
private string $resolvedPostgresUser;
|
||||
|
||||
private string $resolvedPostgresDatabase;
|
||||
|
||||
public function handle(StandalonePostgresql $database, ?Activity $activity = null)
|
||||
{
|
||||
$this->database = $database;
|
||||
$container_name = $this->database->uuid;
|
||||
@@ -111,7 +117,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,
|
||||
@@ -227,7 +233,7 @@ class StartPostgresql
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
|
||||
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
|
||||
return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
|
||||
}
|
||||
|
||||
private function generate_local_persistent_volumes()
|
||||
@@ -265,8 +271,17 @@ 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=$env->real_value");
|
||||
$rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);
|
||||
$resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);
|
||||
$environment_variables->push($env->key.'='.$resolvedValue);
|
||||
if ($env->key === 'POSTGRES_USER') {
|
||||
$this->resolvedPostgresUser = $rawValue;
|
||||
} elseif ($env->key === 'POSTGRES_DB') {
|
||||
$this->resolvedPostgresDatabase = $rawValue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($environment_variables->filter(fn ($env) => str($env)->contains('POSTGRES_USER'))->isEmpty()) {
|
||||
|
||||
@@ -5,12 +5,14 @@ namespace App\Actions\Database;
|
||||
use App\Helpers\SslHelper;
|
||||
use App\Models\SslCertificate;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Traits\ExecutesDatabaseStartCommands;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
|
||||
class StartRedis
|
||||
{
|
||||
use AsAction;
|
||||
use AsAction, ExecutesDatabaseStartCommands;
|
||||
|
||||
public StandaloneRedis $database;
|
||||
|
||||
@@ -20,7 +22,11 @@ class StartRedis
|
||||
|
||||
private ?SslCertificate $ssl_certificate = null;
|
||||
|
||||
public function handle(StandaloneRedis $database)
|
||||
private ?string $resolvedRedisPassword = null;
|
||||
|
||||
private ?string $resolvedRedisUsername = null;
|
||||
|
||||
public function handle(StandaloneRedis $database, ?Activity $activity = null)
|
||||
{
|
||||
$this->database = $database;
|
||||
|
||||
@@ -209,7 +215,7 @@ class StartRedis
|
||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
|
||||
return remote_process($this->commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
|
||||
return $this->executeDatabaseStartCommands($this->commands, $database, $activity);
|
||||
}
|
||||
|
||||
private function generate_local_persistent_volumes()
|
||||
@@ -249,23 +255,40 @@ 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=$env->real_value");
|
||||
$environment_variables->push($env->key.'='.$this->database->resolveSecretManagerEnvironmentVariable($env));
|
||||
|
||||
if ($env->key === 'REDIS_PASSWORD') {
|
||||
$this->database->update(['redis_password' => $env->real_value]);
|
||||
$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' => $env->real_value]);
|
||||
$this->resolvedRedisUsername = $this->database->resolveSecretManagerEnvironmentVariableValue($env);
|
||||
|
||||
if (! $usesSecretManager) {
|
||||
$this->database->update(['redis_username' => $this->resolvedRedisUsername]);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if ($env->key === 'REDIS_PASSWORD') {
|
||||
if ($env->key === 'REDIS_PASSWORD' && ! $usesSecretManager) {
|
||||
$env->update(['value' => $this->database->redis_password]);
|
||||
} elseif ($env->key === 'REDIS_USERNAME') {
|
||||
} elseif ($env->key === 'REDIS_USERNAME' && ! $usesSecretManager) {
|
||||
$env->update(['value' => $this->database->redis_username]);
|
||||
}
|
||||
$environment_variables->push("$env->key=$env->real_value");
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,6 +299,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';
|
||||
|
||||
@@ -286,10 +310,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) {
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Application;
|
||||
use App\Models\IntegrationToken;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class ApplicationSecretManagerController extends Controller
|
||||
{
|
||||
#[OA\Patch(
|
||||
summary: 'Configure Application Secret Manager',
|
||||
description: 'Configure the secret manager source used by an application.',
|
||||
path: '/applications/{uuid}/secret-manager',
|
||||
operationId: 'configure-application-secret-manager',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Secret Managers'],
|
||||
parameters: [new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string'))],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['integration_token_uuid'],
|
||||
properties: [
|
||||
new OA\Property(property: 'integration_token_uuid', type: 'string'),
|
||||
new OA\Property(property: 'settings', type: 'object'),
|
||||
],
|
||||
),
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Secret manager configured.'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
],
|
||||
)]
|
||||
public function update(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$application = Application::ownedByCurrentTeamAPI($teamId)
|
||||
->where('uuid', $request->route('uuid'))
|
||||
->first();
|
||||
|
||||
if (! $application) {
|
||||
return response()->json(['message' => 'Application not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $application);
|
||||
|
||||
$body = $request->json()->all();
|
||||
$token = IntegrationToken::query()
|
||||
->where('team_id', $teamId)
|
||||
->where('uuid', $body['integration_token_uuid'] ?? '')
|
||||
->whereIn('provider', IntegrationToken::SECRET_MANAGER_PROVIDERS)
|
||||
->first();
|
||||
|
||||
if (! $token || ! in_array('secrets', $token->capabilities ?? [], true)) {
|
||||
return response()->json(['message' => 'Secret manager integration token not found.'], 404);
|
||||
}
|
||||
|
||||
$rules = [
|
||||
'integration_token_uuid' => ['required', 'string'],
|
||||
'settings' => ['sometimes', 'array'],
|
||||
];
|
||||
$rules += match ($token->provider) {
|
||||
'doppler' => $token->dopplerTokenType() === 'service_account' ? [
|
||||
'settings.project' => ['required', 'string'],
|
||||
'settings.config' => ['required', 'string'],
|
||||
] : [],
|
||||
'infisical' => [
|
||||
'settings.project_id' => ['required', 'string'],
|
||||
'settings.environment' => ['required', 'string'],
|
||||
'settings.secret_path' => ['nullable', 'string'],
|
||||
],
|
||||
'vault' => [
|
||||
'settings.mount' => ['required', 'string'],
|
||||
'settings.path' => ['required', 'string'],
|
||||
],
|
||||
default => [],
|
||||
};
|
||||
|
||||
$validator = customApiValidator($body, $rules);
|
||||
$extraFields = array_diff(array_keys($body), ['integration_token_uuid', 'settings']);
|
||||
|
||||
if ($validator->fails() || $extraFields !== []) {
|
||||
$errors = $validator->errors();
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422);
|
||||
}
|
||||
|
||||
$settings = array_filter($validator->validated()['settings'] ?? [], fn ($value) => filled($value));
|
||||
$application->secretManagerLink()->updateOrCreate([], [
|
||||
'integration_token_id' => $token->id,
|
||||
'settings' => $settings ?: null,
|
||||
]);
|
||||
|
||||
auditLog('api.application.secret_manager.updated', [
|
||||
'team_id' => $teamId,
|
||||
'application_uuid' => $application->uuid,
|
||||
'integration_token_uuid' => $token->uuid,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'integration_token_uuid' => $token->uuid,
|
||||
'provider' => $token->provider,
|
||||
'settings' => $settings ?: null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\IntegrationToken;
|
||||
use App\Services\IntegrationTokenValidator;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use OpenApi\Attributes as OA;
|
||||
|
||||
class IntegrationTokensController extends Controller
|
||||
{
|
||||
#[OA\Post(
|
||||
summary: 'Create Secret Manager Token',
|
||||
description: 'Create and validate a Doppler, Infisical, or Vault integration token.',
|
||||
path: '/security/integration-tokens',
|
||||
operationId: 'create-secret-manager-integration-token',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Secret Managers'],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['provider', 'name', 'token'],
|
||||
properties: [
|
||||
new OA\Property(property: 'provider', type: 'string', enum: ['doppler', 'infisical', 'vault']),
|
||||
new OA\Property(property: 'name', type: 'string'),
|
||||
new OA\Property(property: 'token', type: 'string'),
|
||||
new OA\Property(property: 'metadata', type: 'object'),
|
||||
],
|
||||
),
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 201, description: 'Integration token created.'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
],
|
||||
)]
|
||||
public function store(Request $request, IntegrationTokenValidator $tokenValidator): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$this->authorize('create', IntegrationToken::class);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$body = $request->json()->all();
|
||||
$rules = [
|
||||
'provider' => ['required', 'string', 'in:'.implode(',', IntegrationToken::SECRET_MANAGER_PROVIDERS)],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'token' => ['required', 'string'],
|
||||
'metadata' => ['sometimes', 'array'],
|
||||
];
|
||||
|
||||
if (($body['provider'] ?? null) === 'doppler') {
|
||||
$rules['token'][] = 'regex:/^dp\.(st|sa)\./';
|
||||
} elseif (($body['provider'] ?? null) === 'infisical') {
|
||||
$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:http,https'];
|
||||
$rules['metadata.namespace'] = ['nullable', 'string'];
|
||||
}
|
||||
|
||||
$validator = customApiValidator($body, $rules);
|
||||
$extraFields = array_diff(array_keys($body), ['provider', 'name', 'token', 'metadata']);
|
||||
|
||||
if ($validator->fails() || $extraFields !== []) {
|
||||
$errors = $validator->errors();
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
|
||||
return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422);
|
||||
}
|
||||
|
||||
$validated = $validator->validated();
|
||||
$metadata = array_filter($validated['metadata'] ?? [], fn ($value) => filled($value));
|
||||
|
||||
if (! $tokenValidator->validate($validated['provider'], $validated['token'], ['secrets'], $metadata)) {
|
||||
return response()->json(['message' => $tokenValidator->errorMessage($validated['provider'])], 400);
|
||||
}
|
||||
|
||||
$integrationToken = IntegrationToken::query()->create([
|
||||
'team_id' => $teamId,
|
||||
'provider' => $validated['provider'],
|
||||
'name' => $validated['name'],
|
||||
'token' => $validated['token'],
|
||||
'capabilities' => ['secrets'],
|
||||
'metadata' => $metadata ?: null,
|
||||
]);
|
||||
|
||||
auditLog('api.integration_token.created', [
|
||||
'team_id' => $teamId,
|
||||
'integration_token_uuid' => $integrationToken->uuid,
|
||||
'provider' => $integrationToken->provider,
|
||||
]);
|
||||
|
||||
return response()->json(['uuid' => $integrationToken->uuid], 201);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ use App\Models\StandaloneDocker;
|
||||
use App\Models\SwarmDocker;
|
||||
use App\Notifications\Application\DeploymentFailed;
|
||||
use App\Notifications\Application\DeploymentSuccess;
|
||||
use App\Support\RemoteSecretReferences;
|
||||
use App\Support\ValidationPatterns;
|
||||
use App\Traits\EnvironmentVariableAnalyzer;
|
||||
use App\Traits\ExecuteRemoteCommand;
|
||||
@@ -143,6 +144,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
private $env_args;
|
||||
|
||||
/** @var array<string, string>|null */
|
||||
private ?array $remote_secrets_cache = null;
|
||||
|
||||
private $env_nixpacks_args;
|
||||
|
||||
private $env_railpack_args;
|
||||
@@ -1275,6 +1279,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
return true;
|
||||
}
|
||||
if ($this->has_remote_buildtime_secret_references()) {
|
||||
$this->application_deployment_queue->addLogEntry('Remote build-time secrets are configured. Running the build to check for updated values.');
|
||||
|
||||
return false;
|
||||
}
|
||||
$configurationDiff = $this->application->pendingDeploymentConfigurationDiff();
|
||||
if (! $configurationDiff->requiresBuild()) {
|
||||
$this->application_deployment_queue->addLogEntry("No build configuration changed & image found ({$this->production_image_name}) with the same Git Commit SHA. Build step skipped.");
|
||||
@@ -1302,6 +1311,18 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
return false;
|
||||
}
|
||||
|
||||
private function has_remote_buildtime_secret_references(): bool
|
||||
{
|
||||
$environmentVariables = $this->pull_request_id === 0
|
||||
? $this->application->environment_variables()
|
||||
: $this->application->environment_variables_preview();
|
||||
|
||||
return $environmentVariables
|
||||
->where('is_buildtime', true)
|
||||
->get(['value'])
|
||||
->contains(fn (EnvironmentVariable $environmentVariable) => RemoteSecretReferences::containsReference($environmentVariable->value));
|
||||
}
|
||||
|
||||
private function check_image_locally_or_remotely()
|
||||
{
|
||||
$this->execute_remote_command([
|
||||
@@ -1323,6 +1344,101 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the secrets from the application's secret manager source. Values
|
||||
* live only in memory during the deployment and in the generated .env on
|
||||
* the server — they are never persisted in the Coolify database. Fetched
|
||||
* lazily (only when a variable references a secret), once per deployment.
|
||||
* A fetch failure fails the deployment.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function remote_secrets(): array
|
||||
{
|
||||
if ($this->remote_secrets_cache !== null) {
|
||||
return $this->remote_secrets_cache;
|
||||
}
|
||||
|
||||
$link = $this->application->secretManagerLink()->with('integrationToken')->first();
|
||||
|
||||
if (! $link) {
|
||||
throw new DeploymentException('Environment variables reference remote secrets ({{vault.KEY}}), but no secret manager source is configured for this application.');
|
||||
}
|
||||
|
||||
$provider = $link->integrationToken->providerName();
|
||||
$tokenName = $link->integrationToken->name;
|
||||
|
||||
try {
|
||||
$secrets = $link->fetchSecrets();
|
||||
} catch (Throwable $e) {
|
||||
$this->application_deployment_queue->addLogEntry("Failed to fetch secrets from {$provider} ({$tokenName}, {$link->sourceSummary()}): {$e->getMessage()}", 'stderr');
|
||||
|
||||
throw new DeploymentException("Could not fetch secrets from {$provider}. The deployment was stopped so the application does not start with missing secrets.");
|
||||
}
|
||||
|
||||
$this->application_deployment_queue->addLogEntry('Fetched '.count($secrets)." secrets from {$provider} ({$tokenName}, {$link->sourceSummary()}).");
|
||||
|
||||
return $this->remote_secrets_cache = $secrets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace {{vault.KEY}} references with values from the configured secret
|
||||
* manager source. Missing keys fail the deployment with a
|
||||
* list — changing the source never re-checks references, so this is the
|
||||
* moment problems surface.
|
||||
*/
|
||||
private function substitute_remote_secrets(string $value, string $envKey): string
|
||||
{
|
||||
$secrets = $this->remote_secrets();
|
||||
$missing = RemoteSecretReferences::missingKeys($value, $secrets);
|
||||
|
||||
if ($missing !== []) {
|
||||
$message = 'Missing secret keys: '.implode(', ', $missing)." (referenced by {$envKey}).";
|
||||
$this->application_deployment_queue->addLogEntry($message, 'stderr');
|
||||
|
||||
throw new DeploymentException($message.' Check the secret manager source of this application.');
|
||||
}
|
||||
|
||||
return RemoteSecretReferences::substitute($value, $secrets);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve shared variables, then secret references, in a raw variable value.
|
||||
*/
|
||||
private function resolve_environment_variable_raw(EnvironmentVariable $env): string
|
||||
{
|
||||
$value = $env->get_real_environment_variables_with_server($env->value, $this->application, $this->mainServer);
|
||||
|
||||
return $this->substitute_remote_secrets($value ?? '', $env->key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a runtime variable to its dotenv representation. Values with
|
||||
* secret references are substituted and written as literals.
|
||||
*/
|
||||
private function resolve_environment_variable(EnvironmentVariable $env): ?string
|
||||
{
|
||||
if (! RemoteSecretReferences::containsReference($env->value)) {
|
||||
return $env->getResolvedValueWithServer($this->mainServer);
|
||||
}
|
||||
|
||||
return $this->format_remote_secret_value($this->resolve_environment_variable_raw($env));
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a remote secret value for the runtime .env file (dotenv syntax read
|
||||
* by docker compose). Values are treated as literals — no interpolation.
|
||||
*/
|
||||
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).'"';
|
||||
}
|
||||
|
||||
private function generate_runtime_environment_variables()
|
||||
{
|
||||
$envs = collect([]);
|
||||
@@ -1391,7 +1507,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
});
|
||||
|
||||
foreach ($runtime_environment_variables as $env) {
|
||||
$envs->push($env->key.'='.$env->getResolvedValueWithServer($this->mainServer));
|
||||
$envs->push($env->key.'='.$this->resolve_environment_variable($env));
|
||||
}
|
||||
|
||||
// Check for PORT environment variable mismatch with ports_exposes
|
||||
@@ -1458,7 +1574,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
});
|
||||
|
||||
foreach ($runtime_environment_variables_preview as $env) {
|
||||
$envs->push($env->key.'='.$env->getResolvedValueWithServer($this->mainServer));
|
||||
$envs->push($env->key.'='.$this->resolve_environment_variable($env));
|
||||
}
|
||||
|
||||
// Fall back to production env vars for keys not overridden by preview vars,
|
||||
@@ -1472,7 +1588,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
return $env->is_runtime && ! in_array($env->key, $previewKeys);
|
||||
});
|
||||
foreach ($fallback_production_vars as $env) {
|
||||
$envs->push($env->key.'='.$env->getResolvedValueWithServer($this->mainServer));
|
||||
$envs->push($env->key.'='.$this->resolve_environment_variable($env));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1580,6 +1696,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/.env > /dev/null"),
|
||||
'skip_command_log' => true,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -1598,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;
|
||||
@@ -1605,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,
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -1728,6 +1847,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
continue;
|
||||
}
|
||||
|
||||
if (RemoteSecretReferences::containsReference($env->value)) {
|
||||
$envs_dict[$env->key] = escapeBashEnvValue($this->resolve_environment_variable_raw($env));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
|
||||
// For literal/multiline vars, real_value includes quotes that we need to remove
|
||||
if ($env->is_literal || $env->is_multiline) {
|
||||
@@ -1783,6 +1908,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
continue;
|
||||
}
|
||||
|
||||
if (RemoteSecretReferences::containsReference($env->value)) {
|
||||
$envs_dict[$env->key] = escapeBashEnvValue($this->resolve_environment_variable_raw($env));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
|
||||
// For literal/multiline vars, real_value includes quotes that we need to remove
|
||||
if ($env->is_literal || $env->is_multiline) {
|
||||
@@ -1853,6 +1984,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee ".self::BUILD_TIME_ENV_PATH.' > /dev/null'),
|
||||
'skip_command_log' => true,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -2651,6 +2783,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
private function normalize_resolved_build_variable_value(EnvironmentVariable $environmentVariable): ?string
|
||||
{
|
||||
if (RemoteSecretReferences::containsReference($environmentVariable->value)) {
|
||||
$resolved = $this->resolve_environment_variable_raw($environmentVariable);
|
||||
|
||||
return $resolved === '' ? null : $resolved;
|
||||
}
|
||||
|
||||
$resolvedValue = $environmentVariable->getResolvedValueWithServer($this->mainServer);
|
||||
if (is_null($resolvedValue) || $resolvedValue === '') {
|
||||
return null;
|
||||
@@ -3194,7 +3332,9 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
||||
}
|
||||
|
||||
foreach ($envs as $env) {
|
||||
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
|
||||
$resolvedValue = RemoteSecretReferences::containsReference($env->value)
|
||||
? $this->resolve_environment_variable_raw($env)
|
||||
: $env->getResolvedValueWithServer($this->mainServer);
|
||||
if (! is_null($resolvedValue)) {
|
||||
$this->env_args->put($env->key, $resolvedValue);
|
||||
}
|
||||
@@ -3210,7 +3350,9 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
||||
}
|
||||
|
||||
foreach ($envs as $env) {
|
||||
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
|
||||
$resolvedValue = RemoteSecretReferences::containsReference($env->value)
|
||||
? $this->resolve_environment_variable_raw($env)
|
||||
: $env->getResolvedValueWithServer($this->mainServer);
|
||||
if (! is_null($resolvedValue)) {
|
||||
$this->env_args->put($env->key, $resolvedValue);
|
||||
}
|
||||
@@ -4268,7 +4410,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
||||
} else {
|
||||
$secrets_string = $variables
|
||||
->map(function ($env) {
|
||||
return "{$env->key}={$env->getResolvedValueWithServer($this->mainServer)}";
|
||||
return "{$env->key}={$this->resolve_environment_variable($env)}";
|
||||
})
|
||||
->sort()
|
||||
->implode('|');
|
||||
@@ -4334,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}={$env->getResolvedValueWithServer($this->mainServer)}");
|
||||
$argsToInsert->push("ARG {$env->key}=".escapeBashEnvValue($this->resolve_environment_variable_raw($env)));
|
||||
}
|
||||
}
|
||||
// Add Coolify variables as ARGs
|
||||
@@ -4356,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}={$env->getResolvedValueWithServer($this->mainServer)}");
|
||||
$argsToInsert->push("ARG {$env->key}=".escapeBashEnvValue($this->resolve_environment_variable_raw($env)));
|
||||
}
|
||||
}
|
||||
// Add Coolify variables as ARGs
|
||||
@@ -4370,6 +4512,14 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
||||
}
|
||||
}
|
||||
|
||||
if ($argsToInsert->isNotEmpty()) {
|
||||
$environmentVariables = $envs->mapWithKeys(function ($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}");
|
||||
}
|
||||
|
||||
// Development logging to show what ARGs are being injected
|
||||
if (isDev()) {
|
||||
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
|
||||
@@ -4391,11 +4541,6 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
||||
$dockerfile->splice($fromLineIndex + 1, 0, [$arg]);
|
||||
}
|
||||
}
|
||||
$envs_mapped = $envs->mapWithKeys(function ($env) {
|
||||
return [$env->key => $env->getResolvedValueWithServer($this->mainServer)];
|
||||
});
|
||||
$secrets_hash = $this->generate_secrets_hash($envs_mapped);
|
||||
$argsToInsert->push("ARG COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}");
|
||||
}
|
||||
|
||||
$dockerfile_base64 = base64_encode($dockerfile->implode("\n"));
|
||||
@@ -4404,11 +4549,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"),
|
||||
'hidden' => true,
|
||||
],
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"),
|
||||
'hidden' => true,
|
||||
'ignore_errors' => true,
|
||||
'skip_command_log' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Actions\Database\StartClickhouse;
|
||||
use App\Actions\Database\StartDragonfly;
|
||||
use App\Actions\Database\StartKeydb;
|
||||
use App\Actions\Database\StartMariadb;
|
||||
use App\Actions\Database\StartMongodb;
|
||||
use App\Actions\Database\StartMysql;
|
||||
use App\Actions\Database\StartPostgresql;
|
||||
use App\Actions\Database\StartRedis;
|
||||
use App\Enums\ProcessStatus;
|
||||
use App\Events\DatabaseStatusChanged;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Models\StandaloneKeydb;
|
||||
use App\Models\StandaloneMariadb;
|
||||
use App\Models\StandaloneMongodb;
|
||||
use App\Models\StandaloneMysql;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
use Throwable;
|
||||
|
||||
class DatabaseStartJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
public int $timeout = 600;
|
||||
|
||||
public function __construct(
|
||||
public string $databaseClass,
|
||||
public int $databaseId,
|
||||
public int $teamId,
|
||||
public int $activityId,
|
||||
public ?int $userId,
|
||||
) {
|
||||
$this->onQueue(deployment_queue());
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$database = $this->databaseClass::query()->findOrFail($this->databaseId);
|
||||
abort_unless((int) $database->team()->id === $this->teamId, 403);
|
||||
$activity = Activity::query()->findOrFail($this->activityId);
|
||||
|
||||
match ($database->getMorphClass()) {
|
||||
StandalonePostgresql::class => StartPostgresql::run($database, $activity),
|
||||
StandaloneRedis::class => StartRedis::run($database, $activity),
|
||||
StandaloneMongodb::class => StartMongodb::run($database, $activity),
|
||||
StandaloneMysql::class => StartMysql::run($database, $activity),
|
||||
StandaloneMariadb::class => StartMariadb::run($database, $activity),
|
||||
StandaloneKeydb::class => StartKeydb::run($database, $activity),
|
||||
StandaloneDragonfly::class => StartDragonfly::run($database, $activity),
|
||||
StandaloneClickhouse::class => StartClickhouse::run($database, $activity),
|
||||
};
|
||||
|
||||
event(new DatabaseStatusChanged($this->userId));
|
||||
}
|
||||
|
||||
public function failed(?Throwable $exception): void
|
||||
{
|
||||
try {
|
||||
$activity = Activity::query()->find($this->activityId);
|
||||
if (! $activity) {
|
||||
return;
|
||||
}
|
||||
|
||||
$activity->properties = $activity->properties->merge([
|
||||
'status' => ProcessStatus::ERROR->value,
|
||||
'error' => 'Database start failed.',
|
||||
'failed_at' => now()->toIso8601String(),
|
||||
]);
|
||||
$activity->save();
|
||||
} finally {
|
||||
event(new DatabaseStatusChanged($this->userId));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,14 +9,27 @@ use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
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;
|
||||
|
||||
class Add extends Component
|
||||
{
|
||||
use AuthorizesRequests, EnvironmentVariableAnalyzer;
|
||||
use AuthorizesRequests, EnvironmentVariableAnalyzer, HasSecretManagerAutocomplete;
|
||||
|
||||
protected function secretManagerResource(): ?Model
|
||||
{
|
||||
if ($this->shared || ! $this->resource) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->resource;
|
||||
}
|
||||
|
||||
public $resource;
|
||||
|
||||
public $parameters;
|
||||
|
||||
|
||||
@@ -12,7 +12,9 @@ use App\Models\SharedEnvironmentVariable;
|
||||
use App\Support\ValidationPatterns;
|
||||
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;
|
||||
@@ -21,7 +23,12 @@ class Show extends Component
|
||||
{
|
||||
public bool $showEnvironmentType = true;
|
||||
|
||||
use AuthorizesRequests, EnvironmentVariableAnalyzer, EnvironmentVariableProtection;
|
||||
use AuthorizesRequests, EnvironmentVariableAnalyzer, EnvironmentVariableProtection, HasSecretManagerAutocomplete;
|
||||
|
||||
protected function secretManagerResource(): ?Model
|
||||
{
|
||||
return $this->isSharedVariable ? null : $this->env->resourceable;
|
||||
}
|
||||
|
||||
public $parameters;
|
||||
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Project\Shared;
|
||||
|
||||
use App\Models\IntegrationToken;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
/**
|
||||
* Manages a resource's single secret manager source and lets the user
|
||||
* browse remote key names, add {{vault.KEY}} reference variables, and import
|
||||
* all missing keys. Secret values never enter the component state or the DB.
|
||||
*/
|
||||
class SecretManagerLinks extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public $resource;
|
||||
|
||||
public $link;
|
||||
|
||||
public $availableTokens;
|
||||
|
||||
public string $integration_token_uuid = '';
|
||||
|
||||
public array $settings = [];
|
||||
|
||||
/** @var list<string> Remote key names only — values are never stored. */
|
||||
public array $keys = [];
|
||||
|
||||
public bool $keysLoaded = false;
|
||||
|
||||
public string $search = '';
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->loadData();
|
||||
}
|
||||
|
||||
private function loadData(): void
|
||||
{
|
||||
$this->link = $this->resource->secretManagerLink()->with('integrationToken')->first();
|
||||
$this->availableTokens = IntegrationToken::ownedByCurrentTeam()
|
||||
->whereIn('provider', IntegrationToken::SECRET_MANAGER_PROVIDERS)
|
||||
->get()
|
||||
->filter(fn (IntegrationToken $token) => in_array('secrets', $token->capabilities ?? [], true))
|
||||
->values();
|
||||
|
||||
if ($this->link) {
|
||||
$this->integration_token_uuid = $this->link->integrationToken->uuid;
|
||||
$this->settings = $this->link->settings ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
public function getSelectedTokenProperty(): ?IntegrationToken
|
||||
{
|
||||
if (blank($this->integration_token_uuid)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->availableTokens->firstWhere('uuid', $this->integration_token_uuid);
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
$rules = [
|
||||
'integration_token_uuid' => ['required', 'string'],
|
||||
];
|
||||
|
||||
$rules += match ($this->selectedToken?->provider) {
|
||||
'doppler' => $this->selectedToken->dopplerTokenType() === 'service_account'
|
||||
? [
|
||||
'settings.project' => ['required', 'string'],
|
||||
'settings.config' => ['required', 'string'],
|
||||
]
|
||||
: [],
|
||||
'infisical' => [
|
||||
'settings.project_id' => ['required', 'string'],
|
||||
'settings.environment' => ['required', 'string'],
|
||||
'settings.secret_path' => ['nullable', 'string'],
|
||||
],
|
||||
'vault' => [
|
||||
'settings.mount' => ['required', 'string'],
|
||||
'settings.path' => ['required', 'string'],
|
||||
],
|
||||
default => [],
|
||||
};
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-save when a token is selected in the dropdown. Existing {{vault.*}}
|
||||
* references are intentionally NOT re-checked — missing keys surface at
|
||||
* the next deployment.
|
||||
*/
|
||||
public function updatedIntegrationTokenUuid(): void
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
$token = $this->selectedToken;
|
||||
|
||||
if (! $token) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->link?->integrationToken?->provider !== $token->provider
|
||||
|| $this->link?->integrationToken?->dopplerTokenType() !== $token->dopplerTokenType()) {
|
||||
$this->settings = [];
|
||||
}
|
||||
|
||||
$settings = array_filter($this->settings, fn ($value) => filled($value));
|
||||
|
||||
$this->resource->secretManagerLink()->updateOrCreate([], [
|
||||
'integration_token_id' => $token->id,
|
||||
'settings' => $settings ?: null,
|
||||
]);
|
||||
|
||||
$this->resetKeys();
|
||||
$this->loadData();
|
||||
$this->dispatch('success', 'Secret manager source saved. References resolve at the next deployment.');
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-save of the provider-specific settings fields (called on blur).
|
||||
*/
|
||||
public function saveSettings(): void
|
||||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
if (! $this->link) {
|
||||
return;
|
||||
}
|
||||
|
||||
$validated = $this->validate();
|
||||
|
||||
try {
|
||||
|
||||
$settings = array_filter(data_get($validated, 'settings', []), fn ($value) => filled($value));
|
||||
|
||||
$this->link->update(['settings' => $settings ?: null]);
|
||||
$this->resetKeys();
|
||||
$this->loadData();
|
||||
$this->dispatch('success', 'Secret manager settings saved.');
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function removeSource(): void
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
$this->resource->secretManagerLink()->delete();
|
||||
$this->link = null;
|
||||
$this->integration_token_uuid = '';
|
||||
$this->settings = [];
|
||||
$this->resetKeys();
|
||||
$this->loadData();
|
||||
$this->dispatch('success', 'Secret manager source removed. Existing {{vault.*}} references will fail the next deployment until they are removed too.');
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function loadKeys(): void
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
if (! $this->link) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Values are fetched into memory, reduced to key names, and discarded.
|
||||
$keys = array_keys($this->link->fetchSecrets());
|
||||
sort($keys);
|
||||
$this->keys = $keys;
|
||||
$this->keysLoaded = true;
|
||||
} catch (\Throwable $e) {
|
||||
$this->dispatch('error', 'Could not fetch keys: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public function addReference(string $key): void
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
if (! in_array($key, $this->keys, true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->resource->environment_variables()->where('key', $key)->exists()) {
|
||||
$this->dispatch('error', "A variable with the key {$key} already exists.");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->resource->environment_variables()->create([
|
||||
'key' => $key,
|
||||
'value' => '{{vault.'.$key.'}}',
|
||||
]);
|
||||
|
||||
$this->dispatch('refreshEnvs');
|
||||
$this->dispatch('success', "Added {$key} as {{vault.{$key}}}.");
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function importAll(): void
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
if (! $this->link) {
|
||||
return;
|
||||
}
|
||||
|
||||
$imported = $this->link->importMissingReferences();
|
||||
|
||||
$this->dispatch('refreshEnvs');
|
||||
$this->dispatch('success', $imported === []
|
||||
? 'All remote keys already exist as variables.'
|
||||
: 'Imported '.count($imported).' keys as {{vault.KEY}} references.');
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
private function resetKeys(): void
|
||||
{
|
||||
$this->keys = [];
|
||||
$this->keysLoaded = false;
|
||||
$this->search = '';
|
||||
}
|
||||
|
||||
public function getFilteredKeysProperty(): array
|
||||
{
|
||||
if (blank($this->search)) {
|
||||
return $this->keys;
|
||||
}
|
||||
|
||||
return array_values(array_filter(
|
||||
$this->keys,
|
||||
fn (string $key) => stripos($key, $this->search) !== false,
|
||||
));
|
||||
}
|
||||
|
||||
public function render(): View
|
||||
{
|
||||
return view('livewire.project.shared.secret-manager-links', [
|
||||
'selectedToken' => $this->selectedToken,
|
||||
'filteredKeys' => $this->filteredKeys,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
namespace App\Livewire\Security;
|
||||
|
||||
use App\Models\IntegrationToken;
|
||||
use App\Services\CloudflareTokenValidator;
|
||||
use App\Services\IntegrationTokenValidator;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
@@ -19,6 +19,8 @@ class IntegrationTokenEditor extends Component
|
||||
|
||||
public array $capabilities = [];
|
||||
|
||||
public array $metadata = [];
|
||||
|
||||
public function mount(string $integration_token_uuid): void
|
||||
{
|
||||
$this->integrationToken = IntegrationToken::ownedByCurrentTeam()
|
||||
@@ -29,16 +31,31 @@ class IntegrationTokenEditor extends Component
|
||||
|
||||
$this->name = $this->integrationToken->name;
|
||||
$this->capabilities = $this->integrationToken->capabilities;
|
||||
$this->metadata = $this->integrationToken->metadata ?? [];
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
$allowedCapability = $this->integrationToken->provider === 'cloudflare' ? 'dns' : 'secrets';
|
||||
|
||||
$rules = [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'newToken' => ['nullable', 'string'],
|
||||
'capabilities' => ['required', 'array', 'min:1'],
|
||||
'capabilities.*' => ['required', 'in:dns'],
|
||||
'capabilities.*' => ['required', 'in:'.$allowedCapability],
|
||||
];
|
||||
|
||||
if ($this->integrationToken->provider === 'infisical') {
|
||||
$rules['metadata.base_url'] = ['required', 'url'];
|
||||
$rules['metadata.client_id'] = ['required', 'string'];
|
||||
}
|
||||
|
||||
if ($this->integrationToken->provider === 'vault') {
|
||||
$rules['metadata.base_url'] = ['required', 'url'];
|
||||
$rules['metadata.namespace'] = ['nullable', 'string'];
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
protected function messages(): array
|
||||
@@ -49,18 +66,21 @@ class IntegrationTokenEditor extends Component
|
||||
];
|
||||
}
|
||||
|
||||
public function save(CloudflareTokenValidator $validator): void
|
||||
public function save(IntegrationTokenValidator $validator): void
|
||||
{
|
||||
$this->authorize('update', $this->integrationToken);
|
||||
$validated = $this->validate();
|
||||
$provider = $this->integrationToken->provider;
|
||||
$token = filled($validated['newToken']) ? $validated['newToken'] : $this->integrationToken->token;
|
||||
$metadata = array_filter(data_get($validated, 'metadata', []), fn ($value) => filled($value));
|
||||
$capabilitiesChanged = collect($validated['capabilities'])->sort()->values()->all()
|
||||
!== collect($this->integrationToken->capabilities)->sort()->values()->all();
|
||||
$metadataChanged = $metadata != ($this->integrationToken->metadata ?? []);
|
||||
|
||||
try {
|
||||
if ((filled($validated['newToken']) || $capabilitiesChanged)
|
||||
&& ! $validator->validate($token, $validated['capabilities'])) {
|
||||
$this->dispatch('error', 'The token could not access the selected Cloudflare capabilities. Check its permissions and zone resources.');
|
||||
if ((filled($validated['newToken']) || $capabilitiesChanged || $metadataChanged)
|
||||
&& ! $validator->validate($provider, $token, $validated['capabilities'], $metadata)) {
|
||||
$this->dispatch('error', $validator->errorMessage($provider));
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -68,6 +88,7 @@ class IntegrationTokenEditor extends Component
|
||||
$updates = [
|
||||
'name' => $validated['name'],
|
||||
'capabilities' => $validated['capabilities'],
|
||||
'metadata' => $metadata ?: null,
|
||||
];
|
||||
|
||||
if (filled($validated['newToken'])) {
|
||||
@@ -100,6 +121,13 @@ class IntegrationTokenEditor extends Component
|
||||
public function delete(string $password = ''): void
|
||||
{
|
||||
$this->authorize('delete', $this->integrationToken);
|
||||
|
||||
if ($this->integrationToken->secretManagerLinks()->exists()) {
|
||||
$this->dispatch('error', 'This token is used by one or more resources as a secret manager source. Remove those links first.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->integrationToken->delete();
|
||||
|
||||
$this->dispatch('integration-token-deleted', uuid: $this->integrationToken->uuid);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
namespace App\Livewire\Security;
|
||||
|
||||
use App\Models\IntegrationToken;
|
||||
use App\Services\CloudflareTokenValidator;
|
||||
use App\Services\IntegrationTokenValidator;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
|
||||
@@ -21,20 +21,53 @@ class IntegrationTokenForm extends Component
|
||||
|
||||
public array $capabilities = ['dns'];
|
||||
|
||||
public array $metadata = [];
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->authorize('create', IntegrationToken::class);
|
||||
}
|
||||
|
||||
public function updatedProvider(): void
|
||||
{
|
||||
if ($this->provider === 'cloudflare') {
|
||||
$this->capabilities = ['dns'];
|
||||
$this->metadata = [];
|
||||
} else {
|
||||
$this->capabilities = ['secrets'];
|
||||
$this->metadata = $this->provider === 'infisical'
|
||||
? ['base_url' => 'https://app.infisical.com']
|
||||
: [];
|
||||
}
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'provider' => ['required', 'in:cloudflare'],
|
||||
$allowedCapability = $this->provider === 'cloudflare' ? 'dns' : 'secrets';
|
||||
|
||||
$rules = [
|
||||
'provider' => ['required', 'in:'.implode(',', array_keys(IntegrationToken::PROVIDER_NAMES))],
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'token' => ['required', 'string'],
|
||||
'capabilities' => ['required', 'array', 'min:1'],
|
||||
'capabilities.*' => ['required', 'in:dns'],
|
||||
'capabilities.*' => ['required', 'in:'.$allowedCapability],
|
||||
];
|
||||
|
||||
if ($this->provider === 'infisical') {
|
||||
$rules['metadata.base_url'] = ['required', 'url:http,https'];
|
||||
$rules['metadata.client_id'] = ['required', 'string'];
|
||||
}
|
||||
|
||||
if ($this->provider === 'doppler') {
|
||||
$rules['token'][] = 'regex:/^dp\.(st|sa)\./';
|
||||
}
|
||||
|
||||
if ($this->provider === 'vault') {
|
||||
$rules['metadata.base_url'] = ['required', 'url:http,https'];
|
||||
$rules['metadata.namespace'] = ['nullable', 'string'];
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
protected function messages(): array
|
||||
@@ -42,22 +75,28 @@ class IntegrationTokenForm extends Component
|
||||
return [
|
||||
'capabilities.required' => 'Select at least one capability.',
|
||||
'capabilities.min' => 'Select at least one capability.',
|
||||
'token.regex' => 'Use a Doppler service token (dp.st.*) or service account token (dp.sa.*).',
|
||||
];
|
||||
}
|
||||
|
||||
public function addToken(CloudflareTokenValidator $validator): void
|
||||
public function addToken(IntegrationTokenValidator $validator): void
|
||||
{
|
||||
$validated = $this->validate();
|
||||
$metadata = array_filter(data_get($validated, 'metadata', []), fn ($value) => filled($value));
|
||||
|
||||
try {
|
||||
if (! $validator->validate($validated['token'], $validated['capabilities'])) {
|
||||
$this->dispatch('error', 'The token could not access the selected Cloudflare capabilities. Check its permissions and zone resources.');
|
||||
if (! $validator->validate($validated['provider'], $validated['token'], $validated['capabilities'], $metadata)) {
|
||||
$this->dispatch('error', $validator->errorMessage($validated['provider']));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
IntegrationToken::query()->create([
|
||||
...$validated,
|
||||
'provider' => $validated['provider'],
|
||||
'name' => $validated['name'],
|
||||
'token' => $validated['token'],
|
||||
'capabilities' => $validated['capabilities'],
|
||||
'metadata' => $metadata ?: null,
|
||||
'team_id' => currentTeam()->id,
|
||||
]);
|
||||
|
||||
|
||||
@@ -29,6 +29,13 @@ class IntegrationTokens extends Component
|
||||
{
|
||||
$token = IntegrationToken::ownedByCurrentTeam()->findOrFail($tokenId);
|
||||
$this->authorize('delete', $token);
|
||||
|
||||
if ($token->secretManagerLinks()->exists()) {
|
||||
$this->dispatch('error', 'This token is used by one or more resources as a secret manager source. Remove those links first.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$token->delete();
|
||||
$this->loadTokens();
|
||||
$this->dispatch('success', 'Integration token deleted successfully.');
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Traits\HasConfiguration;
|
||||
use App\Traits\HasMetrics;
|
||||
use App\Traits\HasNoindexDomains;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use App\Traits\HasSecretManager;
|
||||
use Database\Factories\ApplicationFactory;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@@ -123,9 +124,7 @@ use Symfony\Component\Yaml\Yaml;
|
||||
class Application extends BaseModel
|
||||
{
|
||||
/** @use HasFactory<ApplicationFactory> */
|
||||
use Auditable, HasFactory;
|
||||
|
||||
use ClearsGlobalSearchCache, HasConfiguration, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, SoftDeletes;
|
||||
use Auditable, ClearsGlobalSearchCache, HasConfiguration, HasFactory, HasMetrics, HasNoindexDomains, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
|
||||
|
||||
public const MAX_DOCKER_COMPOSE_SIZE_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
@@ -383,6 +382,7 @@ class Application extends BaseModel
|
||||
$application->persistentStorages()->delete();
|
||||
$application->environment_variables()->delete();
|
||||
$application->environment_variables_preview()->delete();
|
||||
$application->secretManagerLink()->delete();
|
||||
foreach ($application->scheduled_tasks as $task) {
|
||||
$task->delete();
|
||||
}
|
||||
|
||||
@@ -252,17 +252,21 @@ class EnvironmentVariable extends BaseModel
|
||||
protected function isShared(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: function () {
|
||||
$type = str($this->value)->after('{{')->before('.')->value;
|
||||
if (str($this->value)->startsWith('{{'.$type) && str($this->value)->endsWith('}}')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
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);
|
||||
@@ -409,8 +413,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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,15 +3,26 @@
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class IntegrationToken extends BaseModel
|
||||
{
|
||||
public const SECRET_MANAGER_PROVIDERS = ['doppler', 'infisical', 'vault'];
|
||||
|
||||
public const PROVIDER_NAMES = [
|
||||
'cloudflare' => 'Cloudflare',
|
||||
'doppler' => 'Doppler',
|
||||
'infisical' => 'Infisical',
|
||||
'vault' => 'HashiCorp Vault',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'team_id',
|
||||
'provider',
|
||||
'name',
|
||||
'token',
|
||||
'capabilities',
|
||||
'metadata',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
@@ -23,6 +34,7 @@ class IntegrationToken extends BaseModel
|
||||
return [
|
||||
'token' => 'encrypted',
|
||||
'capabilities' => 'array',
|
||||
'metadata' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -31,6 +43,34 @@ class IntegrationToken extends BaseModel
|
||||
return $this->belongsTo(Team::class);
|
||||
}
|
||||
|
||||
public function secretManagerLinks(): HasMany
|
||||
{
|
||||
return $this->hasMany(SecretManagerLink::class);
|
||||
}
|
||||
|
||||
public function isSecretManager(): bool
|
||||
{
|
||||
return in_array($this->provider, self::SECRET_MANAGER_PROVIDERS, true);
|
||||
}
|
||||
|
||||
public function providerName(): string
|
||||
{
|
||||
return self::PROVIDER_NAMES[$this->provider] ?? ucfirst($this->provider);
|
||||
}
|
||||
|
||||
public function dopplerTokenType(): ?string
|
||||
{
|
||||
if ($this->provider !== 'doppler') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return match (true) {
|
||||
str_starts_with($this->token, 'dp.st.') => 'service',
|
||||
str_starts_with($this->token, 'dp.sa.') => 'service_account',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
public static function ownedByCurrentTeam()
|
||||
{
|
||||
return self::query()->where('team_id', currentTeam()->id);
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Services\DopplerService;
|
||||
use App\Services\InfisicalService;
|
||||
use App\Services\VaultService;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
|
||||
/**
|
||||
* Links a resource (currently Application) to a remote secret manager source.
|
||||
* Holds only the source coordinates — secret values are never persisted.
|
||||
*/
|
||||
class SecretManagerLink extends BaseModel
|
||||
{
|
||||
protected $fillable = [
|
||||
'resourceable_type',
|
||||
'resourceable_id',
|
||||
'integration_token_id',
|
||||
'settings',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'settings' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
public function resourceable(): MorphTo
|
||||
{
|
||||
return $this->morphTo();
|
||||
}
|
||||
|
||||
public function integrationToken(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(IntegrationToken::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the secrets from the remote manager. Values live only in memory.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function fetchSecrets(): array
|
||||
{
|
||||
$token = $this->integrationToken;
|
||||
$settings = $this->settings ?? [];
|
||||
$metadata = $token->metadata ?? [];
|
||||
|
||||
return match ($token->provider) {
|
||||
'doppler' => (new DopplerService($token->token))->fetchSecrets(
|
||||
data_get($settings, 'project'),
|
||||
data_get($settings, 'config'),
|
||||
),
|
||||
'infisical' => (new InfisicalService(
|
||||
data_get($metadata, 'base_url', 'https://app.infisical.com'),
|
||||
(string) data_get($metadata, 'client_id'),
|
||||
$token->token,
|
||||
))->fetchSecrets(
|
||||
(string) data_get($settings, 'project_id'),
|
||||
(string) data_get($settings, 'environment'),
|
||||
(string) data_get($settings, 'secret_path', '/'),
|
||||
),
|
||||
'vault' => (new VaultService(
|
||||
(string) data_get($metadata, 'base_url'),
|
||||
$token->token,
|
||||
data_get($metadata, 'namespace'),
|
||||
))->fetchSecrets(
|
||||
(string) data_get($settings, 'mount', 'secret'),
|
||||
(string) data_get($settings, 'path'),
|
||||
),
|
||||
default => throw new \RuntimeException("Unsupported secret manager provider [{$token->provider}]."),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create one {{vault.KEY}} reference variable per remote key that has no
|
||||
* variable with that key yet. Only key names touch the database.
|
||||
*
|
||||
* @return list<string> The keys that were imported
|
||||
*/
|
||||
public function importMissingReferences(): array
|
||||
{
|
||||
$keys = array_keys($this->fetchSecrets());
|
||||
sort($keys);
|
||||
|
||||
$existing = $this->resourceable->environment_variables()->pluck('key')->flip();
|
||||
$imported = [];
|
||||
|
||||
foreach ($keys as $key) {
|
||||
if (isset($existing[$key])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->resourceable->environment_variables()->create([
|
||||
'key' => $key,
|
||||
'value' => '{{vault.'.$key.'}}',
|
||||
]);
|
||||
$imported[] = $key;
|
||||
}
|
||||
|
||||
return $imported;
|
||||
}
|
||||
|
||||
/** Short human-readable description of the remote source for the UI. */
|
||||
public function sourceSummary(): string
|
||||
{
|
||||
$settings = $this->settings ?? [];
|
||||
|
||||
return match ($this->integrationToken->provider) {
|
||||
'doppler' => trim(implode('/', array_filter([
|
||||
data_get($settings, 'project'),
|
||||
data_get($settings, 'config'),
|
||||
])), '/') ?: 'token scope',
|
||||
'infisical' => data_get($settings, 'project_id').'/'.data_get($settings, 'environment').data_get($settings, 'secret_path', '/'),
|
||||
'vault' => data_get($settings, 'mount', 'secret').'/'.data_get($settings, 'path'),
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use App\Services\ContainerStatusAggregator;
|
||||
use App\Traits\Auditable;
|
||||
use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use App\Traits\HasSecretManager;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
@@ -44,7 +45,7 @@ use Symfony\Component\Yaml\Yaml;
|
||||
)]
|
||||
class Service extends BaseModel
|
||||
{
|
||||
use Auditable, ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
|
||||
use Auditable, ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
|
||||
|
||||
private static $parserVersion = '5';
|
||||
|
||||
@@ -1632,7 +1633,7 @@ class Service extends BaseModel
|
||||
return 3;
|
||||
});
|
||||
foreach ($sorted as $env) {
|
||||
$envs->push("{$env->key}={$env->real_value}");
|
||||
$envs->push("{$env->key}={$this->resolveSecretManagerEnvironmentVariable($env)}");
|
||||
}
|
||||
if ($envs->count() === 0) {
|
||||
$commands[] = 'touch .env';
|
||||
|
||||
@@ -7,13 +7,14 @@ use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasDatabaseHealthCheck;
|
||||
use App\Traits\HasMetrics;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use App\Traits\HasSecretManager;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class StandaloneClickhouse extends BaseModel
|
||||
{
|
||||
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
|
||||
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
|
||||
@@ -7,13 +7,14 @@ use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasDatabaseHealthCheck;
|
||||
use App\Traits\HasMetrics;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use App\Traits\HasSecretManager;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class StandaloneDragonfly extends BaseModel
|
||||
{
|
||||
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
|
||||
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
|
||||
@@ -7,13 +7,14 @@ use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasDatabaseHealthCheck;
|
||||
use App\Traits\HasMetrics;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use App\Traits\HasSecretManager;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class StandaloneKeydb extends BaseModel
|
||||
{
|
||||
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
|
||||
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasDatabaseHealthCheck;
|
||||
use App\Traits\HasMetrics;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use App\Traits\HasSecretManager;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
@@ -14,7 +15,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class StandaloneMariadb extends BaseModel
|
||||
{
|
||||
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
|
||||
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
|
||||
@@ -7,13 +7,14 @@ use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasDatabaseHealthCheck;
|
||||
use App\Traits\HasMetrics;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use App\Traits\HasSecretManager;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class StandaloneMongodb extends BaseModel
|
||||
{
|
||||
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
|
||||
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
|
||||
@@ -7,13 +7,14 @@ use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasDatabaseHealthCheck;
|
||||
use App\Traits\HasMetrics;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use App\Traits\HasSecretManager;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class StandaloneMysql extends BaseModel
|
||||
{
|
||||
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
|
||||
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
|
||||
@@ -7,13 +7,14 @@ use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasDatabaseHealthCheck;
|
||||
use App\Traits\HasMetrics;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use App\Traits\HasSecretManager;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class StandalonePostgresql extends BaseModel
|
||||
{
|
||||
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
|
||||
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
|
||||
@@ -7,13 +7,14 @@ use App\Traits\ClearsGlobalSearchCache;
|
||||
use App\Traits\HasDatabaseHealthCheck;
|
||||
use App\Traits\HasMetrics;
|
||||
use App\Traits\HasSafeStringAttribute;
|
||||
use App\Traits\HasSecretManager;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class StandaloneRedis extends BaseModel
|
||||
{
|
||||
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, SoftDeletes;
|
||||
use Auditable, ClearsGlobalSearchCache, HasDatabaseHealthCheck, HasFactory, HasMetrics, HasSafeStringAttribute, HasSecretManager, SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'uuid',
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\ProcessStatus;
|
||||
use App\Helpers\SshMultiplexingHelper;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
class DatabaseStartCommandExecutor
|
||||
{
|
||||
public function execute(array $commands, Model $database, Activity $activity): Activity
|
||||
{
|
||||
$server = $database->destination->server;
|
||||
if ($server->isNonRoot()) {
|
||||
$commands = parseCommandsByLineForSudo(collect($commands), $server)->all();
|
||||
}
|
||||
|
||||
$secrets = method_exists($database, 'resolvedSecretManagerValuesForRedaction')
|
||||
? $database->resolvedSecretManagerValuesForRedaction()
|
||||
: [];
|
||||
$remoteCommand = SshMultiplexingHelper::generateSshCommand($server, implode("\n", $commands));
|
||||
|
||||
$activity->properties = $activity->properties->merge(['status' => ProcessStatus::IN_PROGRESS->value]);
|
||||
$activity->save();
|
||||
|
||||
$process = Process::timeout(config('constants.ssh.command_timeout'))
|
||||
->idleTimeout(3600)
|
||||
->start($remoteCommand, function (string $type, string $output) use ($activity, $secrets): void {
|
||||
$this->appendOutput($activity, $type, $this->redact($output, $secrets));
|
||||
});
|
||||
|
||||
$result = $process->wait();
|
||||
$status = $result->successful() ? ProcessStatus::FINISHED : ProcessStatus::ERROR;
|
||||
$activity->properties = $activity->properties->merge([
|
||||
'status' => $status->value,
|
||||
'exitCode' => $result->exitCode(),
|
||||
]);
|
||||
$activity->save();
|
||||
|
||||
if (! $result->successful()) {
|
||||
throw new \RuntimeException($this->redact($result->errorOutput(), $secrets), $result->exitCode());
|
||||
}
|
||||
|
||||
return $activity;
|
||||
}
|
||||
|
||||
private function redact(string $value, array $secrets): string
|
||||
{
|
||||
foreach ($secrets as $secret) {
|
||||
if (is_string($secret) && $secret !== '') {
|
||||
$value = str_replace($secret, REDACTED, $value);
|
||||
}
|
||||
}
|
||||
|
||||
return sanitize_utf8_text(remove_iip($value));
|
||||
}
|
||||
|
||||
private function appendOutput(Activity $activity, string $type, string $output): void
|
||||
{
|
||||
if ($output === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$entries = json_decode($activity->description ?: '[]', true, flags: JSON_THROW_ON_ERROR);
|
||||
$entries[] = [
|
||||
'type' => $type,
|
||||
'output' => $output,
|
||||
'timestamp' => hrtime(true),
|
||||
'batch' => 1,
|
||||
'order' => count($entries) + 1,
|
||||
];
|
||||
$activity->description = json_encode($entries, flags: JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
|
||||
$activity->save();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class DopplerService
|
||||
{
|
||||
private string $baseUrl = 'https://api.doppler.com';
|
||||
|
||||
public function __construct(private string $token) {}
|
||||
|
||||
public function validate(): bool
|
||||
{
|
||||
try {
|
||||
return $this->client()->get($this->baseUrl.'/v3/me')->successful();
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download all secrets for a config. Project and config are not needed for
|
||||
* service tokens (the token itself is pinned to one config).
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function fetchSecrets(?string $project = null, ?string $config = null): array
|
||||
{
|
||||
$query = ['format' => 'json'];
|
||||
if (filled($project)) {
|
||||
$query['project'] = $project;
|
||||
}
|
||||
if (filled($config)) {
|
||||
$query['config'] = $config;
|
||||
}
|
||||
|
||||
$response = $this->client()->get($this->baseUrl.'/v3/configs/config/secrets/download', $query);
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new \RuntimeException('Doppler API error: '.($response->json('messages.0') ?? 'HTTP '.$response->status()));
|
||||
}
|
||||
|
||||
return collect($response->json())
|
||||
->map(fn ($value) => is_string($value) ? $value : json_encode($value))
|
||||
->all();
|
||||
}
|
||||
|
||||
private function client(): PendingRequest
|
||||
{
|
||||
return Http::withToken($this->token)
|
||||
->acceptJson()
|
||||
->connectTimeout(5)
|
||||
->timeout(10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
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
|
||||
{
|
||||
try {
|
||||
$this->login();
|
||||
|
||||
return true;
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function fetchSecrets(string $projectId, string $environment, string $secretPath = '/'): array
|
||||
{
|
||||
$client = $this->client()->withToken($this->login());
|
||||
$secretPath = $secretPath ?: '/';
|
||||
|
||||
$response = $client->get($this->baseUrl.'/api/v4/secrets', [
|
||||
'projectId' => $projectId,
|
||||
'environment' => $environment,
|
||||
'secretPath' => $secretPath,
|
||||
]);
|
||||
|
||||
// Older self-hosted instances only expose the v3 endpoint.
|
||||
if ($response->status() === 404) {
|
||||
$response = $client->get($this->baseUrl.'/api/v3/secrets/raw', [
|
||||
'workspaceId' => $projectId,
|
||||
'environment' => $environment,
|
||||
'secretPath' => $secretPath,
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new \RuntimeException('Infisical API error: '.($response->json('message') ?? 'HTTP '.$response->status()));
|
||||
}
|
||||
|
||||
return collect($response->json('secrets', []))
|
||||
->mapWithKeys(fn ($secret) => [(string) data_get($secret, 'secretKey') => (string) data_get($secret, 'secretValue', '')])
|
||||
->all();
|
||||
}
|
||||
|
||||
private function login(): string
|
||||
{
|
||||
$response = $this->client()->post($this->baseUrl.'/api/v1/auth/universal-auth/login', [
|
||||
'clientId' => $this->clientId,
|
||||
'clientSecret' => $this->clientSecret,
|
||||
]);
|
||||
|
||||
$accessToken = $response->json('accessToken');
|
||||
if (! $response->successful() || blank($accessToken)) {
|
||||
throw new \RuntimeException('Infisical login failed: '.($response->json('message') ?? 'HTTP '.$response->status()));
|
||||
}
|
||||
|
||||
return $accessToken;
|
||||
}
|
||||
|
||||
private function client(): PendingRequest
|
||||
{
|
||||
return Http::acceptJson()
|
||||
->withOptions($this->httpClientOptions)
|
||||
->connectTimeout(5)
|
||||
->timeout(10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
/**
|
||||
* Validates an integration token against its provider API before it is saved.
|
||||
*/
|
||||
class IntegrationTokenValidator
|
||||
{
|
||||
public function validate(string $provider, string $token, array $capabilities, array $metadata = []): bool
|
||||
{
|
||||
return match ($provider) {
|
||||
'cloudflare' => app(CloudflareTokenValidator::class)->validate($token, $capabilities),
|
||||
'doppler' => (new DopplerService($token))->validate(),
|
||||
'infisical' => (new InfisicalService(
|
||||
(string) data_get($metadata, 'base_url', 'https://app.infisical.com'),
|
||||
(string) data_get($metadata, 'client_id'),
|
||||
$token,
|
||||
))->validate(),
|
||||
'vault' => (new VaultService(
|
||||
(string) data_get($metadata, 'base_url'),
|
||||
$token,
|
||||
data_get($metadata, 'namespace'),
|
||||
))->validate(),
|
||||
default => false,
|
||||
};
|
||||
}
|
||||
|
||||
public function errorMessage(string $provider): string
|
||||
{
|
||||
return match ($provider) {
|
||||
'cloudflare' => 'The token could not access the selected Cloudflare capabilities. Check its permissions and zone resources.',
|
||||
'doppler' => 'The Doppler token could not be verified. Check the token and its access.',
|
||||
'infisical' => 'Infisical login failed. Check the base URL, the client ID, and the client secret.',
|
||||
'vault' => 'The Vault token could not be verified. Check the base URL, the namespace, and the token.',
|
||||
default => 'The token could not be verified.',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
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
|
||||
{
|
||||
try {
|
||||
return $this->client()->get($this->baseUrl.'/v1/auth/token/lookup-self')->successful();
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a KV v2 secret. Non-string values are stored as JSON strings.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function fetchSecrets(string $mount, string $path): array
|
||||
{
|
||||
$mount = trim($mount, '/');
|
||||
$path = trim($path, '/');
|
||||
|
||||
$response = $this->client()->get($this->baseUrl."/v1/{$mount}/data/{$path}");
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new \RuntimeException('Vault API error: '.($response->json('errors.0') ?? 'HTTP '.$response->status()));
|
||||
}
|
||||
|
||||
return collect($response->json('data.data', []))
|
||||
->map(fn ($value) => is_string($value) ? $value : json_encode($value))
|
||||
->all();
|
||||
}
|
||||
|
||||
private function client(): PendingRequest
|
||||
{
|
||||
$client = Http::withHeaders(['X-Vault-Token' => $this->token])
|
||||
->acceptJson()
|
||||
->withOptions($this->httpClientOptions)
|
||||
->connectTimeout(5)
|
||||
->timeout(10);
|
||||
|
||||
if (filled($this->namespace)) {
|
||||
$client = $client->withHeaders(['X-Vault-Namespace' => $this->namespace]);
|
||||
}
|
||||
|
||||
return $client;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
/**
|
||||
* Parses {{vault.KEY}} style references to remote secret manager values.
|
||||
* The namespace is provider-neutral and resolves against the resource's
|
||||
* configured secret source. It is intentionally
|
||||
* intentionally NOT "secret" so references stay visually distinct from the
|
||||
* shared variable syntax ({{team.KEY}}, {{project.KEY}}, ...). References are
|
||||
* only resolved inside the deployment job — never in the UI or in realValue —
|
||||
* so secret values stay out of the database and the interface.
|
||||
*/
|
||||
class RemoteSecretReferences
|
||||
{
|
||||
public const PATTERN = '/{{\s*vault\.([A-Za-z0-9_]+)\s*}}/';
|
||||
|
||||
public static function containsReference(?string $value): bool
|
||||
{
|
||||
return filled($value) && preg_match(self::PATTERN, $value) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string> Referenced secret key names (unique, in order of appearance)
|
||||
*/
|
||||
public static function referencedKeys(?string $value): array
|
||||
{
|
||||
if (blank($value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
preg_match_all(self::PATTERN, $value, $matches);
|
||||
|
||||
return array_values(array_unique($matches[1]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace every reference with its value from the secrets map.
|
||||
* Keys missing from the map are left as-is — collect them first with
|
||||
* missingKeys() and fail before calling substitute().
|
||||
*
|
||||
* @param array<string, string> $secrets
|
||||
*/
|
||||
public static function substitute(string $value, array $secrets): string
|
||||
{
|
||||
return preg_replace_callback(
|
||||
self::PATTERN,
|
||||
fn (array $matches) => array_key_exists($matches[1], $secrets) ? $secrets[$matches[1]] : $matches[0],
|
||||
$value,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $secrets
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function missingKeys(?string $value, array $secrets): array
|
||||
{
|
||||
return array_values(array_filter(
|
||||
self::referencedKeys($value),
|
||||
fn (string $key) => ! array_key_exists($key, $secrets),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,13 @@ trait ExecuteRemoteCommand
|
||||
);
|
||||
}
|
||||
|
||||
if (isset($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) {
|
||||
$escapedValue = preg_quote($value, '/');
|
||||
$text = preg_replace(
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use App\Services\DatabaseStartCommandExecutor;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
trait ExecutesDatabaseStartCommands
|
||||
{
|
||||
private function executeDatabaseStartCommands(array $commands, Model $database, ?Activity $activity = null): Activity
|
||||
{
|
||||
if ($activity) {
|
||||
return app(DatabaseStartCommandExecutor::class)->execute($commands, $database, $activity);
|
||||
}
|
||||
|
||||
return remote_process($commands, $database->destination->server, callEventOnFinish: 'DatabaseStatusChanged');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\SecretManagerLink;
|
||||
use App\Support\RemoteSecretReferences;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphOne;
|
||||
use RuntimeException;
|
||||
|
||||
trait HasSecretManager
|
||||
{
|
||||
/** @var array<string, string>|null */
|
||||
private ?array $resolvedSecretManagerValues = null;
|
||||
|
||||
public static function bootHasSecretManager(): void
|
||||
{
|
||||
static::deleting(fn ($resource) => $resource->secretManagerLink()->delete());
|
||||
}
|
||||
|
||||
public function secretManagerLink(): MorphOne
|
||||
{
|
||||
return $this->morphOne(SecretManagerLink::class, 'resourceable');
|
||||
}
|
||||
|
||||
public function resolveSecretManagerEnvironmentVariable(EnvironmentVariable $environmentVariable): ?string
|
||||
{
|
||||
$value = $this->resolveSecretManagerEnvironmentVariableValue($environmentVariable);
|
||||
|
||||
return $this->formatEnvironmentVariableValue($environmentVariable, $value);
|
||||
}
|
||||
|
||||
public function formatEnvironmentVariableValue(EnvironmentVariable $environmentVariable, ?string $value): ?string
|
||||
{
|
||||
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();
|
||||
$missing = RemoteSecretReferences::missingKeys($value, $secrets);
|
||||
|
||||
if ($missing !== []) {
|
||||
throw new RuntimeException('Missing secret keys: '.implode(', ', $missing)." (referenced by {$environmentVariable->key}).");
|
||||
}
|
||||
|
||||
$value = RemoteSecretReferences::substitute($value, $secrets);
|
||||
}
|
||||
|
||||
return $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> */
|
||||
private function secretManagerValues(): array
|
||||
{
|
||||
if ($this->resolvedSecretManagerValues !== null) {
|
||||
return $this->resolvedSecretManagerValues;
|
||||
}
|
||||
|
||||
$link = $this->secretManagerLink()->with('integrationToken')->first();
|
||||
|
||||
if (! $link) {
|
||||
throw new RuntimeException('Environment variables reference remote secrets, but no secret manager source is configured.');
|
||||
}
|
||||
|
||||
return $this->resolvedSecretManagerValues = $link->fetchSecrets();
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
public function resolvedSecretManagerValuesForRedaction(): array
|
||||
{
|
||||
return $this->resolvedSecretManagerValues ?? [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use App\Models\SecretManagerLink;
|
||||
|
||||
/**
|
||||
* Exposes the resource's secret manager source to the env-var-input
|
||||
* autocomplete: a boolean for the "vault" scope, and a lazy key-name fetch
|
||||
* (called from the frontend only when the user types a vault reference).
|
||||
* Secret values never reach the component state — key names only.
|
||||
*/
|
||||
trait HasSecretManagerAutocomplete
|
||||
{
|
||||
public function hasSecretManagerSource(): bool
|
||||
{
|
||||
return $this->secretManagerLinkForAutocomplete() !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function fetchSecretManagerKeys(): array
|
||||
{
|
||||
$this->skipRender();
|
||||
|
||||
$link = $this->secretManagerLinkForAutocomplete();
|
||||
|
||||
if (! $link) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$this->authorize('view', $link->resourceable);
|
||||
$keys = array_keys($link->fetchSecrets());
|
||||
sort($keys);
|
||||
|
||||
return $keys;
|
||||
} catch (\Throwable) {
|
||||
throw new \RuntimeException('Unable to fetch secret manager keys.');
|
||||
}
|
||||
}
|
||||
|
||||
private function secretManagerLinkForAutocomplete(): ?SecretManagerLink
|
||||
{
|
||||
$resource = $this->secretManagerResource();
|
||||
|
||||
if (! $resource || ! method_exists($resource, 'secretManagerLink')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! $resource->relationLoaded('secretManagerLink')) {
|
||||
$resource->load('secretManagerLink.integrationToken');
|
||||
}
|
||||
|
||||
return $resource->secretManagerLink;
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ class EnvVarInput extends Component
|
||||
public mixed $canResource = null,
|
||||
public bool $autoDisable = true,
|
||||
public array $availableVars = [],
|
||||
public bool $hasVaultSource = false,
|
||||
public ?string $projectUuid = null,
|
||||
public ?string $environmentUuid = null,
|
||||
public ?string $serverUuid = null,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('integration_tokens', function (Blueprint $table) {
|
||||
$table->json('metadata')->nullable()->after('capabilities');
|
||||
});
|
||||
|
||||
Schema::create('secret_manager_links', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('uuid')->unique();
|
||||
$table->string('resourceable_type');
|
||||
$table->unsignedBigInteger('resourceable_id');
|
||||
$table->foreignId('integration_token_id')->constrained()->cascadeOnDelete();
|
||||
$table->json('settings')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['resourceable_type', 'resourceable_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('secret_manager_links');
|
||||
|
||||
Schema::table('integration_tokens', function (Blueprint $table) {
|
||||
$table->dropColumn('metadata');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -20,7 +20,7 @@ function normalizeShellArgument(argument) {
|
||||
}
|
||||
|
||||
export function extractSshArgs(commandString) {
|
||||
const sshCommandMatch = commandString.match(/ssh (.+?) 'bash -se'/);
|
||||
const sshCommandMatch = commandString.match(/ssh (.+?) '[^']+' << /);
|
||||
if (!sshCommandMatch) return [];
|
||||
|
||||
const argsString = sshCommandMatch[1];
|
||||
|
||||
@@ -34,6 +34,14 @@ test('extractSshArgs preserves proxy command as a single normalized ssh option v
|
||||
assert.equal(sshArgs[4], 'root@example.com');
|
||||
});
|
||||
|
||||
test('extractSshArgs supports the generated bash or sh fallback command', () => {
|
||||
const sshArgs = extractSshArgs(
|
||||
"timeout 3600 ssh -o StrictHostKeyChecking=no 'root'@'10.0.0.5' 'if command -v bash >/dev/null 2>&1; then exec bash -se; else exec sh -se; fi' << \\\\$abc\necho hi\nabc"
|
||||
);
|
||||
|
||||
assert.equal(extractTargetHost(sshArgs), '10.0.0.5');
|
||||
});
|
||||
|
||||
test('isAuthorizedTargetHost matches normalized hosts against plain allowlist values', () => {
|
||||
assert.equal(isAuthorizedTargetHost("'10.0.0.5'", ['10.0.0.5']), true);
|
||||
assert.equal(isAuthorizedTargetHost('"host.docker.internal"', ['host.docker.internal']), true);
|
||||
|
||||
@@ -20,13 +20,32 @@
|
||||
cursorPosition: 0,
|
||||
currentScope: null,
|
||||
availableVars: @js($availableVars),
|
||||
hasVaultSource: @js($hasVaultSource),
|
||||
vaultKeysLoading: false,
|
||||
get availableScopes() {
|
||||
// Only include scopes that have at least one variable
|
||||
const allScopes = ['team', 'project', 'environment', 'server'];
|
||||
return allScopes.filter(scope => {
|
||||
const scopes = allScopes.filter(scope => {
|
||||
const vars = this.availableVars[scope];
|
||||
return vars && vars.length > 0;
|
||||
});
|
||||
// The vault scope is offered whenever a secret manager source is
|
||||
// configured; its keys are fetched lazily on first use.
|
||||
if (this.hasVaultSource) {
|
||||
scopes.push('vault');
|
||||
}
|
||||
return scopes;
|
||||
},
|
||||
loadVaultKeys() {
|
||||
if (this.vaultKeysLoading) return;
|
||||
this.vaultKeysLoading = true;
|
||||
this.$wire.fetchSecretManagerKeys().then(keys => {
|
||||
this.availableVars['vault'] = keys || [];
|
||||
this.vaultKeysLoading = false;
|
||||
this.handleInput();
|
||||
}).catch(() => {
|
||||
this.vaultKeysLoading = false;
|
||||
});
|
||||
},
|
||||
scopeUrls: @js($scopeUrls),
|
||||
|
||||
@@ -84,6 +103,15 @@
|
||||
}
|
||||
|
||||
this.currentScope = scope;
|
||||
|
||||
// Vault keys are fetched from the secret manager on first use.
|
||||
if (scope === 'vault' && this.availableVars['vault'] === undefined) {
|
||||
this.loadVaultKeys();
|
||||
this.suggestions = [];
|
||||
this.showDropdown = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const scopeVars = this.availableVars[scope] || [];
|
||||
const filtered = scopeVars.filter(v =>
|
||||
v.toLowerCase().includes((partial || '').toLowerCase())
|
||||
@@ -214,6 +242,7 @@
|
||||
wire:dirty.class="[box-shadow:inset_4px_0_0_#6b16ed,inset_0_0_0_2px_#e5e5e5] dark:[box-shadow:inset_4px_0_0_#fcd452,inset_0_0_0_2px_#242424]"
|
||||
@endif
|
||||
wire:loading.attr="disabled"
|
||||
wire:target.except="fetchSecretManagerKeys"
|
||||
@disabled($disabled)
|
||||
@if ($type !== 'password')
|
||||
type="{{ $type }}"
|
||||
@@ -236,7 +265,14 @@
|
||||
<div x-show="showDropdown" x-cloak x-transition.origin.top
|
||||
class="listbox-panel top-full! z-[60]! mt-1! w-full! min-w-0! max-w-full!" role="listbox">
|
||||
|
||||
<template x-if="suggestions.length === 0 && currentScope">
|
||||
<template x-if="suggestions.length === 0 && currentScope === 'vault'">
|
||||
<div class="px-2 py-2 text-sm text-neutral-500 dark:text-fg-dim">
|
||||
<span x-show="vaultKeysLoading">Loading keys from the secret manager…</span>
|
||||
<span x-show="!vaultKeysLoading">No matching keys in the secret manager.</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="suggestions.length === 0 && currentScope && currentScope !== 'vault'">
|
||||
<div class="px-2 py-2 text-sm text-neutral-500 dark:text-fg-dim">
|
||||
<div>No shared variables found in <span class="font-semibold" x-text="currentScope"></span> scope.</div>
|
||||
<a :href="getScopeUrl(currentScope)"
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<livewire:project.application.advanced :application="$application" />
|
||||
@elseif ($currentRoute === 'project.application.environment-variables')
|
||||
<livewire:project.shared.environment-variable.all :resource="$application" />
|
||||
<livewire:project.shared.secret-manager-links :resource="$application" />
|
||||
@elseif ($currentRoute === 'project.application.persistent-storage')
|
||||
<livewire:project.service.storage :resource="$application" />
|
||||
@elseif ($currentRoute === 'project.application.source' && $application->git_based())
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
@endif
|
||||
@elseif ($currentRoute === 'project.database.environment-variables')
|
||||
<livewire:project.shared.environment-variable.all :resource="$database" />
|
||||
<livewire:project.shared.secret-manager-links :resource="$database" />
|
||||
@elseif ($currentRoute === 'project.database.servers')
|
||||
<livewire:project.shared.destination :resource="$database" />
|
||||
@elseif ($currentRoute === 'project.database.persistent-storage')
|
||||
|
||||
@@ -179,6 +179,7 @@
|
||||
<livewire:project.service.domains :service="$service" />
|
||||
@elseif ($currentRoute === 'project.service.environment-variables')
|
||||
<livewire:project.shared.environment-variable.all :resource="$service" />
|
||||
<livewire:project.shared.secret-manager-links :resource="$service" />
|
||||
@elseif ($currentRoute === 'project.service.storages')
|
||||
<div class="space-y-6">
|
||||
<div
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
<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')"
|
||||
:environmentUuid="data_get($parameters, 'environment_uuid')"
|
||||
:serverUuid="data_get($parameters, 'server_uuid')" />
|
||||
|
||||
@@ -168,7 +168,7 @@
|
||||
Add
|
||||
</button>
|
||||
</x-slot:content>
|
||||
<livewire:project.shared.environment-variable.add />
|
||||
<livewire:project.shared.environment-variable.add :resource="$resource" />
|
||||
</x-modal-input>
|
||||
@endcan
|
||||
</x-table.toolbar>
|
||||
|
||||
@@ -158,8 +158,10 @@
|
||||
</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()"
|
||||
:projectUuid="data_get($parameters, 'project_uuid')"
|
||||
:environmentUuid="data_get($parameters, 'environment_uuid')"
|
||||
:serverUuid="data_get($parameters, 'server_uuid')" />
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
<div class="mt-8">
|
||||
@php
|
||||
$secretManagerDescription = 'Reference remote secrets in your environment variables with {{vault.KEY}}. Values are fetched at deployment time and are never stored in the Coolify database. Changing the source does not re-check existing references — missing keys fail the next deployment.';
|
||||
$removeSourceWarning = 'Existing {{vault.*}} reference variables will fail the next deployment until they are removed too.';
|
||||
@endphp
|
||||
<x-application.settings-section title="Secret manager" :description="$secretManagerDescription">
|
||||
|
||||
@if (! $link && $availableTokens->isEmpty())
|
||||
<x-empty title="No secret manager tokens"
|
||||
description="Add a Doppler, Infisical, or HashiCorp Vault token under Keys & Tokens > Integration Tokens first."
|
||||
icon-name="keys" size="sm" />
|
||||
@else
|
||||
@can('update', $resource)
|
||||
<div class="application-settings-form flex w-full flex-col gap-4">
|
||||
<div class="flex items-end gap-2">
|
||||
<x-forms.listbox id="integration_token_uuid" label="Integration token" :live="true"
|
||||
placeholder="Select a secret manager token" :options="$availableTokens
|
||||
->map(fn ($token) => [
|
||||
'value' => $token->uuid,
|
||||
'label' => $token->name.' ('.$token->providerName().')',
|
||||
])
|
||||
->all()" />
|
||||
@if ($link)
|
||||
<x-modal-confirmation title="Remove secret manager source?" isErrorButton
|
||||
buttonTitle="Remove" submitAction="removeSource"
|
||||
:actions="[$removeSourceWarning]"
|
||||
:confirmWithText="false" :confirmWithPassword="false"
|
||||
step1ButtonText="Remove source" />
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($link)
|
||||
@if ($selectedToken?->provider === 'doppler')
|
||||
@if ($selectedToken->dopplerTokenType() === 'service_account')
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<x-forms.input required id="settings.project" label="Project (required)"
|
||||
wire:blur="saveSettings" />
|
||||
<x-forms.input required id="settings.config" label="Config (required)" placeholder="prd"
|
||||
wire:blur="saveSettings" />
|
||||
</div>
|
||||
@else
|
||||
<p class="text-[12px] text-neutral-500 dark:text-fg-dim">
|
||||
Project and config are fixed by this service token.
|
||||
</p>
|
||||
@endif
|
||||
@elseif ($selectedToken?->provider === 'infisical')
|
||||
<div class="grid gap-4 lg:grid-cols-3">
|
||||
<x-forms.input required id="settings.project_id" label="Project ID"
|
||||
wire:blur="saveSettings" />
|
||||
<x-forms.input required id="settings.environment" label="Environment slug"
|
||||
placeholder="prod" wire:blur="saveSettings" />
|
||||
<x-forms.input id="settings.secret_path" label="Secret path" placeholder="/"
|
||||
wire:blur="saveSettings" />
|
||||
</div>
|
||||
@elseif ($selectedToken?->provider === 'vault')
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<x-forms.input required id="settings.mount" label="KV v2 mount" placeholder="secret"
|
||||
wire:blur="saveSettings" />
|
||||
<x-forms.input required id="settings.path" label="Secret path"
|
||||
placeholder="my-app/production" wire:blur="saveSettings" />
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<x-forms.button wire:click="loadKeys" wire:target="loadKeys">
|
||||
{{ $keysLoaded ? 'Reload keys' : 'Browse keys' }}
|
||||
</x-forms.button>
|
||||
@if ($keysLoaded)
|
||||
<x-forms.button wire:click="importAll" wire:target="importAll" isHighlighted>
|
||||
Import all keys
|
||||
</x-forms.button>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($keysLoaded)
|
||||
@if (count($keys) === 0)
|
||||
<div class="text-[12px] text-neutral-500 dark:text-fg-dim">
|
||||
No secrets found at this source.
|
||||
</div>
|
||||
@else
|
||||
<x-forms.input label="Search keys" placeholder="Filter key names"
|
||||
wire:model.live.debounce.300ms="search" />
|
||||
<div class="divide-y divide-neutral-200 rounded-lg border border-neutral-200 dark:divide-white/[0.07] dark:border-white/[0.08]">
|
||||
@forelse ($filteredKeys as $key)
|
||||
<div wire:key="secret-key-{{ $key }}"
|
||||
class="flex items-center justify-between gap-3 px-3 py-2">
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<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({{ \Illuminate\Support\Js::from($key) }})"
|
||||
wire:target="addReference({{ \Illuminate\Support\Js::from($key) }})">
|
||||
Add as variable
|
||||
</x-forms.button>
|
||||
</div>
|
||||
@empty
|
||||
<div class="px-3 py-2 text-[12px] text-neutral-500 dark:text-fg-dim">
|
||||
No keys match the search.
|
||||
</div>
|
||||
@endforelse
|
||||
</div>
|
||||
<p class="text-[11px] text-neutral-500 dark:text-fg-dim">
|
||||
Key names only — values stay in the secret manager. "Add as variable" creates
|
||||
<span class="font-mono">KEY={{ '{{vault.KEY}'.'}' }}</span>; you can also paste a reference
|
||||
into any variable value, including inside a longer string.
|
||||
</p>
|
||||
@endif
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
@if ($link)
|
||||
<div class="px-1 py-2 text-[13px] text-black dark:text-fg">
|
||||
{{ $link->integrationToken->providerName() }}
|
||||
<span class="text-neutral-500 dark:text-fg-dim">
|
||||
{{ $link->integrationToken->name }} · {{ $link->sourceSummary() }}
|
||||
</span>
|
||||
</div>
|
||||
@endif
|
||||
@endcan
|
||||
@endif
|
||||
</x-application.settings-section>
|
||||
</div>
|
||||
@@ -2,29 +2,52 @@
|
||||
<form class="application-settings-form flex w-full flex-col gap-4" wire:submit="save">
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<x-forms.input required id="name" label="Token name" />
|
||||
<x-forms.input readonly label="Provider" value="Cloudflare" />
|
||||
<x-forms.input readonly label="Provider" value="{{ $integrationToken->providerName() }}" />
|
||||
<div class="lg:col-span-2">
|
||||
<x-forms.input type="password" id="newToken" label="New API token"
|
||||
<x-forms.input type="password" id="newToken"
|
||||
label="{{ $integrationToken->provider === 'infisical' ? 'New client secret' : 'New API token' }}"
|
||||
placeholder="Leave blank to keep the current token"
|
||||
helper="Paste a replacement token to rotate this credential." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset>
|
||||
<legend class="text-sm font-medium text-black dark:text-fg">Capabilities</legend>
|
||||
<div class="mt-3 rounded-lg border border-neutral-200 p-1 dark:border-white/[0.08]">
|
||||
<x-forms.checkbox id="edit-dns-capability" label="DNS" domValue="dns" fullWidth
|
||||
wire:model.live="capabilities" />
|
||||
<p class="px-2.5 pb-2 text-[11px] text-neutral-500 dark:text-fg-dim">
|
||||
Manage Cloudflare DNS records.
|
||||
</p>
|
||||
@if ($integrationToken->provider === 'infisical')
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<x-forms.input required id="metadata.base_url" label="Base URL"
|
||||
placeholder="https://app.infisical.com" />
|
||||
<x-forms.input required id="metadata.client_id" label="Client ID" />
|
||||
</div>
|
||||
@error('capabilities')
|
||||
<span class="text-xs text-red-500">{{ $message }}</span>
|
||||
@enderror
|
||||
</fieldset>
|
||||
@elseif ($integrationToken->provider === 'vault')
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<x-forms.input required id="metadata.base_url" label="Base URL"
|
||||
placeholder="https://vault.example.com:8200" />
|
||||
<x-forms.input id="metadata.namespace" label="Namespace (optional)" />
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (in_array('dns', $capabilities, true))
|
||||
@if ($integrationToken->provider === 'cloudflare')
|
||||
<fieldset>
|
||||
<legend class="text-sm font-medium text-black dark:text-fg">Capabilities</legend>
|
||||
<div class="mt-3 rounded-lg border border-neutral-200 p-1 dark:border-white/[0.08]">
|
||||
<x-forms.checkbox id="edit-dns-capability" label="DNS" domValue="dns" fullWidth
|
||||
wire:model.live="capabilities" />
|
||||
<p class="px-2.5 pb-2 text-[11px] text-neutral-500 dark:text-fg-dim">
|
||||
Manage Cloudflare DNS records.
|
||||
</p>
|
||||
</div>
|
||||
@error('capabilities')
|
||||
<span class="text-xs text-red-500">{{ $message }}</span>
|
||||
@enderror
|
||||
</fieldset>
|
||||
@else
|
||||
<div class="rounded-lg border border-neutral-200 bg-neutral-50 p-3 text-[11px] leading-5 text-neutral-600 dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-dim">
|
||||
<div class="font-medium text-black dark:text-fg">Capability: Secrets (read-only)</div>
|
||||
<p>Coolify reads secrets from this provider at deployment time. Secret values are never stored in
|
||||
the Coolify database.</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($integrationToken->provider === 'cloudflare' && in_array('dns', $capabilities, true))
|
||||
<div class="rounded-lg border border-neutral-200 bg-neutral-50 p-3 text-[11px] leading-5 text-neutral-600 dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-dim">
|
||||
<div class="font-medium text-black dark:text-fg">Required Cloudflare permissions</div>
|
||||
<ul class="list-inside list-disc">
|
||||
|
||||
@@ -1,30 +1,65 @@
|
||||
<div class="w-full">
|
||||
<form class="application-settings-form flex w-full flex-col gap-4" wire:submit="addToken">
|
||||
<x-forms.listbox required id="provider" label="Provider" :options="[
|
||||
<x-forms.listbox required id="provider" label="Provider" :live="true" :options="[
|
||||
['value' => 'cloudflare', 'label' => 'Cloudflare'],
|
||||
['value' => 'doppler', 'label' => 'Doppler'],
|
||||
['value' => 'infisical', 'label' => 'Infisical'],
|
||||
['value' => 'vault', 'label' => 'HashiCorp Vault'],
|
||||
]" />
|
||||
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<x-forms.input required id="name" label="Token name" placeholder="Production DNS" />
|
||||
<x-forms.input required type="password" id="token" label="API token"
|
||||
placeholder="Paste the provider token" />
|
||||
</div>
|
||||
|
||||
<fieldset>
|
||||
<legend class="text-sm font-medium text-black dark:text-fg">Capabilities</legend>
|
||||
<div class="mt-3 rounded-lg border border-neutral-200 p-1 dark:border-white/[0.08]">
|
||||
<x-forms.checkbox id="dns-capability" label="DNS" domValue="dns" fullWidth
|
||||
wire:model.live="capabilities" />
|
||||
<p class="px-2.5 pb-2 text-[11px] text-neutral-500 dark:text-fg-dim">
|
||||
Manage Cloudflare DNS records.
|
||||
</p>
|
||||
@if ($provider === 'infisical')
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<x-forms.input required id="name" label="Token name" placeholder="Production secrets" />
|
||||
<x-forms.input required id="metadata.client_id" label="Client ID"
|
||||
placeholder="Machine identity client ID" />
|
||||
<x-forms.input required type="password" id="token" label="Client secret"
|
||||
placeholder="Paste the machine identity client secret" />
|
||||
<x-forms.input required id="metadata.base_url" label="Base URL"
|
||||
placeholder="https://app.infisical.com"
|
||||
helper="Use the URL of your self-hosted Infisical instance, or the Infisical cloud URL." />
|
||||
</div>
|
||||
@error('capabilities')
|
||||
<span class="text-xs text-red-500">{{ $message }}</span>
|
||||
@enderror
|
||||
</fieldset>
|
||||
@else
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<x-forms.input required id="name" label="Token name"
|
||||
placeholder="{{ $provider === 'cloudflare' ? 'Production DNS' : 'Production secrets' }}" />
|
||||
<x-forms.input required type="password" id="token"
|
||||
label="{{ $provider === 'vault' ? 'Vault token' : 'API token' }}"
|
||||
placeholder="Paste the provider token" />
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (in_array('dns', $capabilities, true))
|
||||
@if ($provider === 'vault')
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
<x-forms.input required id="metadata.base_url" label="Base URL"
|
||||
placeholder="https://vault.example.com:8200" />
|
||||
<x-forms.input id="metadata.namespace" label="Namespace (optional)"
|
||||
placeholder="admin/team-a" helper="Only for Vault Enterprise / HCP Vault." />
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($provider === 'cloudflare')
|
||||
<fieldset>
|
||||
<legend class="text-sm font-medium text-black dark:text-fg">Capabilities</legend>
|
||||
<div class="mt-3 rounded-lg border border-neutral-200 p-1 dark:border-white/[0.08]">
|
||||
<x-forms.checkbox id="dns-capability" label="DNS" domValue="dns" fullWidth
|
||||
wire:model.live="capabilities" />
|
||||
<p class="px-2.5 pb-2 text-[11px] text-neutral-500 dark:text-fg-dim">
|
||||
Manage Cloudflare DNS records.
|
||||
</p>
|
||||
</div>
|
||||
@error('capabilities')
|
||||
<span class="text-xs text-red-500">{{ $message }}</span>
|
||||
@enderror
|
||||
</fieldset>
|
||||
@else
|
||||
<div class="rounded-lg border border-neutral-200 bg-neutral-50 p-3 text-[11px] leading-5 text-neutral-600 dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-dim">
|
||||
<div class="font-medium text-black dark:text-fg">Capability: Secrets (read-only)</div>
|
||||
<p>Coolify reads secrets from this provider at deployment time and writes them into the generated
|
||||
<code>.env</code> file. Secret values are never stored in the Coolify database.</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($provider === 'cloudflare' && in_array('dns', $capabilities, true))
|
||||
<div class="rounded-lg border border-neutral-200 bg-neutral-50 p-3 text-[11px] leading-5 text-neutral-600 dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-dim">
|
||||
<div class="font-medium text-black dark:text-fg">Required Cloudflare permissions</div>
|
||||
<ul class="list-inside list-disc">
|
||||
@@ -38,6 +73,24 @@
|
||||
Create this token in Cloudflare
|
||||
</a>
|
||||
</div>
|
||||
@elseif ($provider === 'doppler')
|
||||
<div class="rounded-lg border border-neutral-200 bg-neutral-50 p-3 text-[11px] leading-5 text-neutral-600 dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-dim">
|
||||
<div class="font-medium text-black dark:text-fg">Recommended Doppler token</div>
|
||||
<p>Use a read-only <span class="font-medium">Service Token</span> (dp.st.*). It is pinned to one
|
||||
project and config. Create it in Doppler under Project > Config > Access.</p>
|
||||
</div>
|
||||
@elseif ($provider === 'infisical')
|
||||
<div class="rounded-lg border border-neutral-200 bg-neutral-50 p-3 text-[11px] leading-5 text-neutral-600 dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-dim">
|
||||
<div class="font-medium text-black dark:text-fg">Infisical machine identity</div>
|
||||
<p>Create a machine identity with Universal Auth and read access to your project. Paste the client
|
||||
ID above and the client secret in the secret field.</p>
|
||||
</div>
|
||||
@elseif ($provider === 'vault')
|
||||
<div class="rounded-lg border border-neutral-200 bg-neutral-50 p-3 text-[11px] leading-5 text-neutral-600 dark:border-white/[0.08] dark:bg-white/[0.025] dark:text-fg-dim">
|
||||
<div class="font-medium text-black dark:text-fg">Vault token</div>
|
||||
<p>Use a token with read access to your KV v2 secrets. Prefer a periodic or long-lived token —
|
||||
deployments fail when the token expires.</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex justify-end border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<x-security.settings-layout>
|
||||
<div class="application-settings-form">
|
||||
<x-application.settings-section title="Integration tokens"
|
||||
description="Credentials used by third-party integrations such as DNS providers." flush>
|
||||
description="Credentials used by third-party integrations such as DNS providers and secret managers." flush>
|
||||
<x-slot:actions>
|
||||
@can('create', App\Models\IntegrationToken::class)
|
||||
<x-modal-input title="New Integration Token">
|
||||
@@ -56,7 +56,7 @@
|
||||
</h3>
|
||||
</div>
|
||||
<div class="text-center text-[12px] text-neutral-500 dark:text-fg-dim">
|
||||
{{ ucfirst($savedToken->provider) }}
|
||||
{{ $savedToken->providerName() }}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<template x-for="capability in tokenCapabilities" :key="capability">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Controllers\Api\ApplicationsController;
|
||||
use App\Http\Controllers\Api\ApplicationSecretManagerController;
|
||||
use App\Http\Controllers\Api\CloudInitScriptsController;
|
||||
use App\Http\Controllers\Api\CloudProviderTokensController;
|
||||
use App\Http\Controllers\Api\DatabasesController;
|
||||
@@ -11,6 +12,7 @@ use App\Http\Controllers\Api\GithubController;
|
||||
use App\Http\Controllers\Api\GitlabController;
|
||||
use App\Http\Controllers\Api\HetznerController;
|
||||
use App\Http\Controllers\Api\InstanceEmailSettingsController;
|
||||
use App\Http\Controllers\Api\IntegrationTokensController;
|
||||
use App\Http\Controllers\Api\NotificationsController;
|
||||
use App\Http\Controllers\Api\OtherController;
|
||||
use App\Http\Controllers\Api\ProjectController;
|
||||
@@ -120,6 +122,7 @@ Route::group([
|
||||
Route::get('/security/keys/{uuid}', [SecurityController::class, 'key_by_uuid'])->middleware(['api.ability:read']);
|
||||
Route::patch('/security/keys/{uuid}', [SecurityController::class, 'update_key'])->middleware(['api.ability:write']);
|
||||
Route::delete('/security/keys/{uuid}', [SecurityController::class, 'delete_key'])->middleware(['api.ability:write']);
|
||||
Route::post('/security/integration-tokens', [IntegrationTokensController::class, 'store'])->middleware(['api.ability:write']);
|
||||
|
||||
Route::get('/cloud-tokens', [CloudProviderTokensController::class, 'index'])->middleware(['api.ability:read']);
|
||||
Route::post('/cloud-tokens', [CloudProviderTokensController::class, 'store'])->middleware(['api.ability:write']);
|
||||
@@ -237,6 +240,7 @@ Route::group([
|
||||
Route::get('/applications/{uuid}', [ApplicationsController::class, 'application_by_uuid'])->middleware(['api.ability:read']);
|
||||
Route::patch('/applications/{uuid}', [ApplicationsController::class, 'update_by_uuid'])->middleware(['api.ability:write']);
|
||||
Route::delete('/applications/{uuid}', [ApplicationsController::class, 'delete_by_uuid'])->middleware(['api.ability:write']);
|
||||
Route::patch('/applications/{uuid}/secret-manager', [ApplicationSecretManagerController::class, 'update'])->middleware(['api.ability:write']);
|
||||
|
||||
Route::get('/applications/{uuid}/envs', [ApplicationsController::class, 'envs'])->middleware(['api.ability:read']);
|
||||
Route::post('/applications/{uuid}/envs', [ApplicationsController::class, 'create_env'])->middleware(['api.ability:write']);
|
||||
|
||||
@@ -14,6 +14,68 @@ use Illuminate\Support\Collection;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('does not persist environment write commands or generated Dockerfiles in deployment logs', function () {
|
||||
[$application, $server] = makeDeploymentControlVarFixture();
|
||||
|
||||
createApplicationEnvironmentVariable($application, [
|
||||
'key' => 'APP_SECRET',
|
||||
'value' => 'sensitive-value',
|
||||
]);
|
||||
|
||||
[$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 () {
|
||||
[$application, $server] = makeDeploymentControlVarFixture();
|
||||
[$job, $reflection] = makeControlVarFilteringJob($application, $server, [
|
||||
'remote_secrets_cache' => ['API_TOKEN' => 'remote-secret-value'],
|
||||
]);
|
||||
|
||||
expect(invokeDeploymentJobMethod($job, $reflection, 'redact_sensitive_info', 'token=remote-secret-value'))
|
||||
->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 = [];
|
||||
@@ -130,12 +192,12 @@ function makeControlVarFilteringJob(Application $application, Server $server, ar
|
||||
return [$job, $reflection];
|
||||
}
|
||||
|
||||
function invokeDeploymentJobMethod(object $job, ReflectionClass $reflection, string $method): mixed
|
||||
function invokeDeploymentJobMethod(object $job, ReflectionClass $reflection, string $method, mixed ...$arguments): mixed
|
||||
{
|
||||
$reflectionMethod = $reflection->getMethod($method);
|
||||
$reflectionMethod->setAccessible(true);
|
||||
|
||||
return $reflectionMethod->invoke($job);
|
||||
return $reflectionMethod->invoke($job, ...$arguments);
|
||||
}
|
||||
|
||||
function readDeploymentJobProperty(object $job, ReflectionClass $reflection, string $property): mixed
|
||||
@@ -403,10 +465,50 @@ it('filters buildpack control vars from dockerfile arg injection', function () {
|
||||
invokeDeploymentJobMethod($job, $reflection, 'add_build_env_variables_to_dockerfile');
|
||||
|
||||
expect($job->writtenDockerfile)->toContain('ARG APP_ENV=production');
|
||||
expect($job->writtenDockerfile)->toContain('ARG COOLIFY_BUILD_SECRETS_HASH=');
|
||||
expect($job->writtenDockerfile)->not->toContain('ARG NIXPACKS_NODE_VERSION=');
|
||||
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',
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Database\StartDatabase;
|
||||
use App\Jobs\DatabaseStartJob;
|
||||
use App\Models\Server;
|
||||
use App\Models\ServerSetting;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandaloneRedis;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Spatie\Activitylog\ActivityLogStatus;
|
||||
|
||||
it('returns an actionable error when database start activity logging is disabled', function () {
|
||||
config()->set('activitylog.enabled', false);
|
||||
app(ActivityLogStatus::class)->disable();
|
||||
Bus::fake();
|
||||
|
||||
$server = new Server(['ip' => '192.0.2.1']);
|
||||
$server->setRelation('settings', new ServerSetting([
|
||||
'is_reachable' => true,
|
||||
'is_usable' => true,
|
||||
'force_disabled' => false,
|
||||
]));
|
||||
|
||||
$destination = new StandaloneDocker;
|
||||
$destination->setRelation('server', $server);
|
||||
|
||||
$database = new StandaloneRedis;
|
||||
$database->setRelation('destination', $destination);
|
||||
|
||||
$result = (new StartDatabase)->handle($database);
|
||||
|
||||
expect($result)->toBe('Database start could not be queued because activity logging is disabled.');
|
||||
Bus::assertNotDispatched(DatabaseStartJob::class);
|
||||
});
|
||||
@@ -11,3 +11,54 @@ it('uses the current listbox design for environment variable suggestions', funct
|
||||
->toContain('border-emerald-500/25 bg-emerald-500/10')
|
||||
->not->toContain('dark:bg-coolgray-100');
|
||||
});
|
||||
|
||||
it('keeps the environment variable input enabled while secret manager keys load', function () {
|
||||
$view = file_get_contents(resource_path('views/components/forms/env-var-input.blade.php'));
|
||||
|
||||
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'));
|
||||
|
||||
expect($view)
|
||||
->toContain('with {{vault.KEY}}. Values are fetched')
|
||||
->not->toContain('{{doppler.KEY}}')
|
||||
->not->toContain('{{infisical.KEY}}')
|
||||
->toContain(':actions="[$removeSourceWarning]"')
|
||||
->not->toContain(':actions="[\'Existing {{vault.*}}');
|
||||
});
|
||||
|
||||
it('shows secret manager configuration for applications services and databases', function () {
|
||||
$views = [
|
||||
resource_path('views/livewire/project/application/configuration.blade.php'),
|
||||
resource_path('views/livewire/project/service/configuration.blade.php'),
|
||||
resource_path('views/livewire/project/database/configuration.blade.php'),
|
||||
];
|
||||
|
||||
foreach ($views as $view) {
|
||||
expect(file_get_contents($view))->toContain('livewire:project.shared.secret-manager-links');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\IntegrationToken;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config(['app.maintenance.driver' => 'file']);
|
||||
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(['id' => 0, 'is_api_enabled' => true]));
|
||||
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->team->members()->attach($this->user, ['role' => 'owner']);
|
||||
session(['currentTeam' => $this->team]);
|
||||
$this->bearerToken = $this->user->createToken('secret-manager-api-test', ['*'])->plainTextToken;
|
||||
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
});
|
||||
|
||||
function secretManagerApiHeaders(string $token): array
|
||||
{
|
||||
return ['Authorization' => 'Bearer '.$token];
|
||||
}
|
||||
|
||||
test('a secret manager integration token can be created through the api', function () {
|
||||
Http::fake(['https://api.doppler.com/v3/me' => Http::response([], 200)]);
|
||||
|
||||
$response = $this->withHeaders(secretManagerApiHeaders($this->bearerToken))
|
||||
->postJson('/api/v1/security/integration-tokens', [
|
||||
'provider' => 'doppler',
|
||||
'name' => 'Production secrets',
|
||||
'token' => 'dp.st.secret',
|
||||
])
|
||||
->assertCreated()
|
||||
->assertJsonStructure(['uuid']);
|
||||
|
||||
$token = IntegrationToken::query()->whereUuid($response->json('uuid'))->firstOrFail();
|
||||
|
||||
expect($token->team_id)->toBe($this->team->id)
|
||||
->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,
|
||||
'provider' => 'doppler',
|
||||
'name' => 'Production secrets',
|
||||
'token' => 'dp.sa.secret',
|
||||
'capabilities' => ['secrets'],
|
||||
]);
|
||||
|
||||
$this->withHeaders(secretManagerApiHeaders($this->bearerToken))
|
||||
->patchJson("/api/v1/applications/{$this->application->uuid}/secret-manager", [
|
||||
'integration_token_uuid' => $token->uuid,
|
||||
'settings' => [
|
||||
'project' => 'website',
|
||||
'config' => 'production',
|
||||
],
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('integration_token_uuid', $token->uuid)
|
||||
->assertJsonPath('provider', 'doppler')
|
||||
->assertJsonPath('settings.project', 'website');
|
||||
|
||||
$link = $this->application->secretManagerLink()->firstOrFail();
|
||||
|
||||
expect($link->integration_token_id)->toBe($token->id)
|
||||
->and($link->settings)->toBe(['project' => 'website', 'config' => 'production']);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -0,0 +1,471 @@
|
||||
<?php
|
||||
|
||||
use App\Actions\Database\StartRedis;
|
||||
use App\Exceptions\DeploymentException;
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Livewire\Security\IntegrationTokens;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\IntegrationToken;
|
||||
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;
|
||||
use App\Models\StandaloneMariadb;
|
||||
use App\Models\StandaloneMongodb;
|
||||
use App\Models\StandaloneMysql;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Traits\HasSecretManager;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
if (! InstanceSettings::query()->whereKey(0)->exists()) {
|
||||
$settings = new InstanceSettings;
|
||||
$settings->id = 0;
|
||||
$settings->save();
|
||||
}
|
||||
|
||||
$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);
|
||||
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$destination = $server->standaloneDockers()->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
});
|
||||
|
||||
function createSecretManagerLink(string $provider, array $settings = [], array $metadata = []): SecretManagerLink
|
||||
{
|
||||
$token = IntegrationToken::query()->create([
|
||||
'team_id' => test()->team->id,
|
||||
'provider' => $provider,
|
||||
'name' => ucfirst($provider).' token',
|
||||
'token' => 'the-secret-token',
|
||||
'capabilities' => ['secrets'],
|
||||
'metadata' => $metadata ?: null,
|
||||
]);
|
||||
|
||||
return test()->application->secretManagerLink()->create([
|
||||
'integration_token_id' => $token->id,
|
||||
'settings' => $settings ?: null,
|
||||
]);
|
||||
}
|
||||
|
||||
function makeDeploymentJobForSecrets(): ApplicationDeploymentJob
|
||||
{
|
||||
$job = (new ReflectionClass(ApplicationDeploymentJob::class))->newInstanceWithoutConstructor();
|
||||
|
||||
$queue = ApplicationDeploymentQueue::create([
|
||||
'application_id' => test()->application->id,
|
||||
'deployment_uuid' => 'secrets-test-'.fake()->uuid(),
|
||||
'status' => 'in_progress',
|
||||
'server_id' => test()->application->destination->server->id,
|
||||
'destination_id' => test()->application->destination->id,
|
||||
'commit' => 'HEAD',
|
||||
'pull_request_id' => 0,
|
||||
]);
|
||||
|
||||
$properties = [
|
||||
'application' => test()->application,
|
||||
'application_deployment_queue' => $queue,
|
||||
'mainServer' => test()->application->destination->server,
|
||||
'pull_request_id' => 0,
|
||||
];
|
||||
|
||||
foreach ($properties as $property => $value) {
|
||||
$reflection = new ReflectionProperty($job, $property);
|
||||
$reflection->setValue($job, $value);
|
||||
}
|
||||
|
||||
return $job;
|
||||
}
|
||||
|
||||
function resolveEnvOnJob(ApplicationDeploymentJob $job, $env): ?string
|
||||
{
|
||||
return (new ReflectionMethod($job, 'resolve_environment_variable'))->invoke($job, $env);
|
||||
}
|
||||
|
||||
function deploymentHasRemoteBuildtimeReferences(ApplicationDeploymentJob $job): bool
|
||||
{
|
||||
return (new ReflectionMethod($job, 'has_remote_buildtime_secret_references'))->invoke($job);
|
||||
}
|
||||
|
||||
test('remote build-time secret references prevent same-commit image reuse', function (string $reference) {
|
||||
$this->application->environment_variables()->create([
|
||||
'key' => 'BUILD_SECRET',
|
||||
'value' => $reference,
|
||||
'is_buildtime' => true,
|
||||
]);
|
||||
|
||||
expect(deploymentHasRemoteBuildtimeReferences(makeDeploymentJobForSecrets()))->toBeTrue();
|
||||
})->with([
|
||||
'provider-neutral reference' => '{{vault.BUILD_SECRET}}',
|
||||
]);
|
||||
|
||||
test('runtime-only remote secret references still allow same-commit image reuse', function () {
|
||||
$this->application->environment_variables()->create([
|
||||
'key' => 'RUNTIME_SECRET',
|
||||
'value' => '{{vault.RUNTIME_SECRET}}',
|
||||
'is_buildtime' => false,
|
||||
]);
|
||||
|
||||
expect(deploymentHasRemoteBuildtimeReferences(makeDeploymentJobForSecrets()))->toBeFalse();
|
||||
});
|
||||
|
||||
test('a doppler link fetches secrets with the stored token', function () {
|
||||
Http::fake([
|
||||
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
|
||||
'DB_PASSWORD' => 's3cret',
|
||||
]),
|
||||
]);
|
||||
|
||||
$link = createSecretManagerLink('doppler', ['project' => 'proj', 'config' => 'prd']);
|
||||
|
||||
expect($link->fetchSecrets())->toBe(['DB_PASSWORD' => 's3cret']);
|
||||
|
||||
Http::assertSent(fn ($request) => $request->hasHeader('Authorization', 'Bearer the-secret-token')
|
||||
&& str_contains($request->url(), 'project=proj'));
|
||||
});
|
||||
|
||||
test('a vault link uses the base url and namespace from the token metadata', function () {
|
||||
Http::fake([
|
||||
'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://example.com:8200', 'namespace' => 'team-a'],
|
||||
);
|
||||
|
||||
expect($link->fetchSecrets())->toBe(['KEY' => 'value']);
|
||||
|
||||
Http::assertSent(fn ($request) => $request->hasHeader('X-Vault-Namespace', 'team-a'));
|
||||
});
|
||||
|
||||
test('services resolve environment variables from their configured secret manager', function () {
|
||||
Http::fake([
|
||||
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
|
||||
'API_KEY' => 'remote-service-value',
|
||||
]),
|
||||
]);
|
||||
|
||||
$service = Service::factory()->create([
|
||||
'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' => 'Service secrets',
|
||||
'token' => 'the-secret-token',
|
||||
'capabilities' => ['secrets'],
|
||||
]);
|
||||
$service->secretManagerLink()->create(['integration_token_id' => $token->id]);
|
||||
$environmentVariable = $service->environment_variables()->create([
|
||||
'key' => 'API_KEY',
|
||||
'value' => '{{vault.API_KEY}}',
|
||||
]);
|
||||
|
||||
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([
|
||||
Application::class,
|
||||
Service::class,
|
||||
StandalonePostgresql::class,
|
||||
StandaloneMysql::class,
|
||||
StandaloneMariadb::class,
|
||||
StandaloneMongodb::class,
|
||||
StandaloneRedis::class,
|
||||
StandaloneKeydb::class,
|
||||
StandaloneDragonfly::class,
|
||||
StandaloneClickhouse::class,
|
||||
]);
|
||||
|
||||
test('an application has at most one secret manager source', function () {
|
||||
createSecretManagerLink('doppler');
|
||||
|
||||
$secondToken = IntegrationToken::query()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'vault',
|
||||
'name' => 'Vault token',
|
||||
'token' => 'other-token',
|
||||
'capabilities' => ['secrets'],
|
||||
'metadata' => ['base_url' => 'https://vault.internal:8200'],
|
||||
]);
|
||||
|
||||
expect(fn () => $this->application->secretManagerLink()->create([
|
||||
'integration_token_id' => $secondToken->id,
|
||||
]))->toThrow(QueryException::class);
|
||||
});
|
||||
|
||||
test('a secret reference is substituted at deploy time and formatted as a dotenv literal', function () {
|
||||
Http::fake([
|
||||
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
|
||||
'DB_PASSWORD' => 'p4$$word',
|
||||
]),
|
||||
]);
|
||||
|
||||
createSecretManagerLink('doppler');
|
||||
|
||||
$env = $this->application->environment_variables()->create([
|
||||
'key' => 'DATABASE_URL',
|
||||
'value' => 'postgres://app:{{vault.DB_PASSWORD}}@db:5432/app',
|
||||
]);
|
||||
|
||||
$job = makeDeploymentJobForSecrets();
|
||||
|
||||
expect(resolveEnvOnJob($job, $env))->toBe("'postgres://app:p4\$\$word@db:5432/app'");
|
||||
});
|
||||
|
||||
test('provider alias references resolve against the single source', function () {
|
||||
Http::fake([
|
||||
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
|
||||
'API_KEY' => 'abc',
|
||||
]),
|
||||
]);
|
||||
|
||||
createSecretManagerLink('doppler');
|
||||
|
||||
$env = $this->application->environment_variables()->create([
|
||||
'key' => 'API_KEY',
|
||||
'value' => '{{vault.API_KEY}}',
|
||||
]);
|
||||
|
||||
expect(resolveEnvOnJob(makeDeploymentJobForSecrets(), $env))->toBe("'abc'");
|
||||
});
|
||||
|
||||
test('the fetch happens once per deployment even with many references', function () {
|
||||
Http::fake([
|
||||
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
|
||||
'A' => '1',
|
||||
'B' => '2',
|
||||
]),
|
||||
]);
|
||||
|
||||
createSecretManagerLink('doppler');
|
||||
|
||||
$first = $this->application->environment_variables()->create(['key' => 'A', 'value' => '{{vault.A}}']);
|
||||
$second = $this->application->environment_variables()->create(['key' => 'B', 'value' => '{{vault.B}}']);
|
||||
|
||||
$job = makeDeploymentJobForSecrets();
|
||||
resolveEnvOnJob($job, $first);
|
||||
resolveEnvOnJob($job, $second);
|
||||
|
||||
Http::assertSentCount(1);
|
||||
});
|
||||
|
||||
test('variables without references never contact the secret manager', function () {
|
||||
Http::fake();
|
||||
|
||||
createSecretManagerLink('doppler');
|
||||
|
||||
$env = $this->application->environment_variables()->create([
|
||||
'key' => 'PLAIN',
|
||||
'value' => 'plain-value',
|
||||
]);
|
||||
|
||||
expect(resolveEnvOnJob(makeDeploymentJobForSecrets(), $env))->toBe('plain-value');
|
||||
|
||||
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([
|
||||
'OTHER' => 'value',
|
||||
]),
|
||||
]);
|
||||
|
||||
createSecretManagerLink('doppler');
|
||||
|
||||
$env = $this->application->environment_variables()->create([
|
||||
'key' => 'DB_PASSWORD',
|
||||
'value' => '{{vault.GONE_KEY}}',
|
||||
]);
|
||||
|
||||
expect(fn () => resolveEnvOnJob(makeDeploymentJobForSecrets(), $env))
|
||||
->toThrow(DeploymentException::class, 'Missing secret keys: GONE_KEY (referenced by DB_PASSWORD).');
|
||||
});
|
||||
|
||||
test('a reference without a configured source fails the deployment', function () {
|
||||
Http::fake();
|
||||
|
||||
$env = $this->application->environment_variables()->create([
|
||||
'key' => 'DB_PASSWORD',
|
||||
'value' => '{{vault.DB_PASSWORD}}',
|
||||
]);
|
||||
|
||||
expect(fn () => resolveEnvOnJob(makeDeploymentJobForSecrets(), $env))
|
||||
->toThrow(DeploymentException::class, 'no secret manager source is configured');
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('a fetch failure stops the deployment with a clear error', function () {
|
||||
Http::fake([
|
||||
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
|
||||
'messages' => ['Invalid Auth token'],
|
||||
], 401),
|
||||
]);
|
||||
|
||||
createSecretManagerLink('doppler');
|
||||
|
||||
$env = $this->application->environment_variables()->create([
|
||||
'key' => 'DB_PASSWORD',
|
||||
'value' => '{{vault.DB_PASSWORD}}',
|
||||
]);
|
||||
|
||||
expect(fn () => resolveEnvOnJob(makeDeploymentJobForSecrets(), $env))
|
||||
->toThrow(DeploymentException::class, 'Could not fetch secrets from Doppler.');
|
||||
});
|
||||
|
||||
test('import creates reference variables for missing keys only', function () {
|
||||
Http::fake([
|
||||
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
|
||||
'EXISTING' => 'value-a',
|
||||
'NEW_KEY' => 'value-b',
|
||||
]),
|
||||
]);
|
||||
|
||||
createSecretManagerLink('doppler');
|
||||
$this->application->environment_variables()->create(['key' => 'EXISTING', 'value' => 'local']);
|
||||
|
||||
$imported = $this->application->secretManagerLink->importMissingReferences();
|
||||
|
||||
expect($imported)->toBe(['NEW_KEY']);
|
||||
|
||||
$created = $this->application->environment_variables()->where('key', 'NEW_KEY')->firstOrFail();
|
||||
expect($created->value)->toBe('{{vault.NEW_KEY}}')
|
||||
->and($this->application->environment_variables()->where('key', 'EXISTING')->firstOrFail()->value)->toBe('local');
|
||||
});
|
||||
|
||||
test('secret references are not marked as shared variables', function () {
|
||||
$secretRef = $this->application->environment_variables()->create([
|
||||
'key' => 'A',
|
||||
'value' => '{{vault.A}}',
|
||||
]);
|
||||
$sharedRef = $this->application->environment_variables()->create([
|
||||
'key' => 'B',
|
||||
'value' => '{{team.B}}',
|
||||
]);
|
||||
|
||||
expect($secretRef->refresh()->is_shared)->toBeFalse()
|
||||
->and($sharedRef->refresh()->is_shared)->toBeTrue();
|
||||
});
|
||||
|
||||
test('remote secret values are formatted as dotenv literals', function () {
|
||||
$job = (new ReflectionClass(ApplicationDeploymentJob::class))->newInstanceWithoutConstructor();
|
||||
$format = fn (string $value) => (new ReflectionMethod($job, 'format_remote_secret_value'))->invoke($job, $value);
|
||||
|
||||
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}\'');
|
||||
});
|
||||
|
||||
test('deleting an integration token is blocked while links exist', function () {
|
||||
$link = createSecretManagerLink('doppler');
|
||||
|
||||
Livewire\Livewire::test(IntegrationTokens::class)
|
||||
->call('deleteToken', $link->integration_token_id)
|
||||
->assertDispatched('error');
|
||||
|
||||
expect(IntegrationToken::query()->whereKey($link->integration_token_id)->exists())->toBeTrue();
|
||||
|
||||
$link->delete();
|
||||
|
||||
Livewire\Livewire::test(IntegrationTokens::class)
|
||||
->call('deleteToken', $link->integration_token_id)
|
||||
->assertDispatched('success');
|
||||
|
||||
expect(IntegrationToken::query()->whereKey($link->integration_token_id)->exists())->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\Shared\EnvironmentVariable\Show;
|
||||
use App\Livewire\Project\Shared\SecretManagerLinks;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\IntegrationToken;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
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);
|
||||
|
||||
beforeEach(function () {
|
||||
if (! InstanceSettings::query()->whereKey(0)->exists()) {
|
||||
$settings = new InstanceSettings;
|
||||
$settings->id = 0;
|
||||
$settings->save();
|
||||
}
|
||||
|
||||
$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);
|
||||
|
||||
$server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$destination = $server->standaloneDockers()->firstOrFail();
|
||||
$project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
]);
|
||||
|
||||
$this->token = IntegrationToken::query()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'doppler',
|
||||
'name' => 'Doppler production',
|
||||
'token' => 'dp.st.token',
|
||||
'capabilities' => ['secrets'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('selecting a token in the dropdown saves the source automatically', function () {
|
||||
Livewire::test(SecretManagerLinks::class, ['resource' => $this->application])
|
||||
->set('integration_token_uuid', $this->token->uuid)
|
||||
->assertDispatched('success');
|
||||
|
||||
$this->assertDatabaseHas('secret_manager_links', [
|
||||
'resourceable_type' => $this->application->getMorphClass(),
|
||||
'resourceable_id' => $this->application->id,
|
||||
'integration_token_id' => $this->token->id,
|
||||
]);
|
||||
});
|
||||
|
||||
test('service account settings are required and save automatically on blur', function () {
|
||||
$serviceAccountToken = IntegrationToken::query()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'doppler',
|
||||
'name' => 'Doppler service account',
|
||||
'token' => 'dp.sa.token',
|
||||
'capabilities' => ['secrets'],
|
||||
]);
|
||||
|
||||
$this->application->secretManagerLink()->create(['integration_token_id' => $serviceAccountToken->id]);
|
||||
|
||||
Livewire::test(SecretManagerLinks::class, ['resource' => $this->application])
|
||||
->call('saveSettings')
|
||||
->assertHasErrors(['settings.project', 'settings.config']);
|
||||
|
||||
Livewire::test(SecretManagerLinks::class, ['resource' => $this->application])
|
||||
->set('settings', ['project' => 'proj', 'config' => 'prd'])
|
||||
->call('saveSettings')
|
||||
->assertHasNoErrors()
|
||||
->assertDispatched('success');
|
||||
|
||||
expect($this->application->secretManagerLink()->firstOrFail()->settings)
|
||||
->toBe(['project' => 'proj', 'config' => 'prd']);
|
||||
});
|
||||
|
||||
test('doppler settings match the selected token type', function () {
|
||||
$this->application->secretManagerLink()->create(['integration_token_id' => $this->token->id]);
|
||||
|
||||
Livewire::test(SecretManagerLinks::class, ['resource' => $this->application])
|
||||
->assertSee('Project and config are fixed by this service token.')
|
||||
->assertDontSee('Project (required)');
|
||||
|
||||
$serviceAccountToken = IntegrationToken::query()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'doppler',
|
||||
'name' => 'Doppler service account',
|
||||
'token' => 'dp.sa.token',
|
||||
'capabilities' => ['secrets'],
|
||||
]);
|
||||
|
||||
$this->application->secretManagerLink()->update([
|
||||
'integration_token_id' => $serviceAccountToken->id,
|
||||
]);
|
||||
|
||||
Livewire::test(SecretManagerLinks::class, ['resource' => $this->application])
|
||||
->assertSee('Project (required)')
|
||||
->assertSee('Config (required)');
|
||||
});
|
||||
|
||||
test('selecting another token replaces the source and clears provider settings without checking references', function () {
|
||||
$this->application->secretManagerLink()->create([
|
||||
'integration_token_id' => $this->token->id,
|
||||
'settings' => ['project' => 'proj'],
|
||||
]);
|
||||
$this->application->environment_variables()->create(['key' => 'A', 'value' => '{{vault.A}}']);
|
||||
|
||||
$otherToken = IntegrationToken::query()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'vault',
|
||||
'name' => 'Vault',
|
||||
'token' => 'hvs.token',
|
||||
'capabilities' => ['secrets'],
|
||||
'metadata' => ['base_url' => 'https://vault.internal:8200'],
|
||||
]);
|
||||
|
||||
Livewire::test(SecretManagerLinks::class, ['resource' => $this->application])
|
||||
->set('integration_token_uuid', $otherToken->uuid)
|
||||
->assertDispatched('success')
|
||||
->assertSet('settings', []);
|
||||
|
||||
$this->assertDatabaseCount('secret_manager_links', 1);
|
||||
$this->assertDatabaseHas('secret_manager_links', [
|
||||
'integration_token_id' => $otherToken->id,
|
||||
'settings' => null,
|
||||
]);
|
||||
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
|
||||
test('browse keys shows key names only and search filters them', function () {
|
||||
Http::fake([
|
||||
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
|
||||
'DB_PASSWORD' => 'super-secret-value',
|
||||
'API_KEY' => 'another-secret',
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->application->secretManagerLink()->create(['integration_token_id' => $this->token->id]);
|
||||
|
||||
$component = Livewire::test(SecretManagerLinks::class, ['resource' => $this->application])
|
||||
->call('loadKeys')
|
||||
->assertSee('DB_PASSWORD')
|
||||
->assertSee('API_KEY')
|
||||
->assertSee('{{vault.DB_PASSWORD}}')
|
||||
->assertSeeHtml('class="flex min-w-0 flex-col"')
|
||||
->assertDontSee('{{ $key }}')
|
||||
->assertDontSee('super-secret-value')
|
||||
->assertDontSee('another-secret');
|
||||
|
||||
expect($component->get('keys'))->toBe(['API_KEY', 'DB_PASSWORD']);
|
||||
|
||||
$component->set('search', 'db_pass')
|
||||
->assertSee('DB_PASSWORD')
|
||||
->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([
|
||||
'DB_PASSWORD' => 'super-secret-value',
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->application->secretManagerLink()->create(['integration_token_id' => $this->token->id]);
|
||||
|
||||
Livewire::test(SecretManagerLinks::class, ['resource' => $this->application])
|
||||
->call('loadKeys')
|
||||
->call('addReference', 'DB_PASSWORD')
|
||||
->assertDispatched('refreshEnvs')
|
||||
->assertDispatched('success');
|
||||
|
||||
$created = $this->application->environment_variables()->where('key', 'DB_PASSWORD')->firstOrFail();
|
||||
expect($created->value)->toBe('{{vault.DB_PASSWORD}}');
|
||||
});
|
||||
|
||||
test('import all creates references for missing keys and skips existing ones', function () {
|
||||
Http::fake([
|
||||
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
|
||||
'EXISTING' => 'a',
|
||||
'NEW_KEY' => 'b',
|
||||
]),
|
||||
]);
|
||||
|
||||
$this->application->secretManagerLink()->create(['integration_token_id' => $this->token->id]);
|
||||
$this->application->environment_variables()->create(['key' => 'EXISTING', 'value' => 'local']);
|
||||
|
||||
Livewire::test(SecretManagerLinks::class, ['resource' => $this->application])
|
||||
->call('importAll')
|
||||
->assertDispatched('refreshEnvs')
|
||||
->assertDispatched('success');
|
||||
|
||||
expect($this->application->environment_variables()->where('key', 'NEW_KEY')->firstOrFail()->value)
|
||||
->toBe('{{vault.NEW_KEY}}')
|
||||
->and($this->application->environment_variables()->where('key', 'EXISTING')->firstOrFail()->value)
|
||||
->toBe('local');
|
||||
});
|
||||
|
||||
test('the source can be removed', function () {
|
||||
$this->application->secretManagerLink()->create(['integration_token_id' => $this->token->id]);
|
||||
|
||||
Livewire::test(SecretManagerLinks::class, ['resource' => $this->application])
|
||||
->call('removeSource')
|
||||
->assertDispatched('success');
|
||||
|
||||
$this->assertDatabaseCount('secret_manager_links', 0);
|
||||
});
|
||||
|
||||
test('members without update permission cannot save a source', function () {
|
||||
$member = User::factory()->create();
|
||||
$this->team->members()->attach($member->id, ['role' => 'member']);
|
||||
$this->actingAs($member);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(SecretManagerLinks::class, ['resource' => $this->application])
|
||||
->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);
|
||||
});
|
||||
|
||||
test('the edit modal value autocomplete offers the vault scope with lazy key fetch', function () {
|
||||
Http::fake([
|
||||
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
|
||||
'DB_PASSWORD' => 'super-secret-value',
|
||||
]),
|
||||
]);
|
||||
|
||||
$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'])
|
||||
->call('loadValues')
|
||||
->assertSeeHtml('hasVaultSource: true');
|
||||
|
||||
expect($component->instance()->fetchSecretManagerKeys())->toBe(['DB_PASSWORD']);
|
||||
});
|
||||
|
||||
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),
|
||||
]);
|
||||
|
||||
$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 () {
|
||||
$env = $this->application->environment_variables()->create(['key' => 'MY_VAR', 'value' => 'plain']);
|
||||
|
||||
$component = Livewire::test(Show::class, ['env' => $env, 'type' => 'application'])
|
||||
->call('loadValues')
|
||||
->assertSeeHtml('hasVaultSource: false');
|
||||
|
||||
expect($component->instance()->fetchSecretManagerKeys())->toBe([]);
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
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 () {
|
||||
Http::fake([
|
||||
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
|
||||
'DATABASE_URL' => 'postgres://user:pass@host/db',
|
||||
'API_KEY' => 'secret-value',
|
||||
]),
|
||||
]);
|
||||
|
||||
$secrets = (new DopplerService('dp.st.test'))->fetchSecrets();
|
||||
|
||||
expect($secrets)->toBe([
|
||||
'DATABASE_URL' => 'postgres://user:pass@host/db',
|
||||
'API_KEY' => 'secret-value',
|
||||
]);
|
||||
|
||||
Http::assertSent(fn ($request) => $request->hasHeader('Authorization', 'Bearer dp.st.test')
|
||||
&& str_contains($request->url(), 'format=json')
|
||||
&& ! str_contains($request->url(), 'project='));
|
||||
});
|
||||
|
||||
test('sends project and config for service account tokens', function () {
|
||||
Http::fake([
|
||||
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response(['KEY' => 'value']),
|
||||
]);
|
||||
|
||||
(new DopplerService('dp.sa.test'))->fetchSecrets('my-project', 'prd');
|
||||
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), 'project=my-project')
|
||||
&& str_contains($request->url(), 'config=prd'));
|
||||
});
|
||||
|
||||
test('throws a readable error when the download fails', function () {
|
||||
Http::fake([
|
||||
'https://api.doppler.com/v3/configs/config/secrets/download*' => Http::response([
|
||||
'messages' => ['Invalid Auth token'],
|
||||
], 401),
|
||||
]);
|
||||
|
||||
expect(fn () => (new DopplerService('bad-token'))->fetchSecrets())
|
||||
->toThrow(RuntimeException::class, 'Doppler API error: Invalid Auth token');
|
||||
});
|
||||
|
||||
test('validates the token against the me endpoint', function () {
|
||||
Http::fake([
|
||||
'https://api.doppler.com/v3/me' => Http::response(['type' => 'service_token']),
|
||||
]);
|
||||
|
||||
expect((new DopplerService('dp.st.test'))->validate())->toBeTrue();
|
||||
});
|
||||
|
||||
test('validation fails for a rejected token', function () {
|
||||
Http::fake([
|
||||
'https://api.doppler.com/v3/me' => Http::response([], 401),
|
||||
]);
|
||||
|
||||
expect((new DopplerService('bad'))->validate())->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
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://example.com/infisical/api/v1/auth/universal-auth/login' => Http::response([
|
||||
'accessToken' => 'short-lived-token',
|
||||
]),
|
||||
'https://example.com/infisical/api/v4/secrets*' => Http::response([
|
||||
'secrets' => [
|
||||
['secretKey' => 'DB_PASSWORD', 'secretValue' => 's3cret'],
|
||||
['secretKey' => 'API_KEY', 'secretValue' => 'abc'],
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
$service = new InfisicalService('https://example.com/infisical/', 'client-id', 'client-secret');
|
||||
$secrets = $service->fetchSecrets('project-1', 'prod', '/');
|
||||
|
||||
expect($secrets)->toBe([
|
||||
'DB_PASSWORD' => 's3cret',
|
||||
'API_KEY' => 'abc',
|
||||
]);
|
||||
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), '/api/v4/secrets')
|
||||
&& $request->hasHeader('Authorization', 'Bearer short-lived-token')
|
||||
&& str_contains($request->url(), 'projectId=project-1'));
|
||||
});
|
||||
|
||||
test('falls back to the v3 raw endpoint on older self-hosted instances', function () {
|
||||
Http::fake([
|
||||
'https://example.com/infisical/api/v1/auth/universal-auth/login' => Http::response([
|
||||
'accessToken' => 'short-lived-token',
|
||||
]),
|
||||
'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://example.com/infisical', 'client-id', 'client-secret');
|
||||
|
||||
expect($service->fetchSecrets('project-1', 'prod'))->toBe(['LEGACY_KEY' => 'legacy-value']);
|
||||
|
||||
Http::assertSent(fn ($request) => str_contains($request->url(), 'workspaceId=project-1'));
|
||||
});
|
||||
|
||||
test('throws when the login fails', function () {
|
||||
Http::fake([
|
||||
'https://example.com/infisical/api/v1/auth/universal-auth/login' => Http::response([
|
||||
'message' => 'Invalid credentials',
|
||||
], 401),
|
||||
]);
|
||||
|
||||
$service = new InfisicalService('https://example.com/infisical', 'client-id', 'wrong');
|
||||
|
||||
expect($service->validate())->toBeFalse()
|
||||
->and(fn () => $service->fetchSecrets('project-1', 'prod'))
|
||||
->toThrow(RuntimeException::class, 'Infisical login failed: Invalid credentials');
|
||||
});
|
||||
});
|
||||
|
||||
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://example.com:8200/vault/v1/secret/data/my-app/production' => Http::response([
|
||||
'data' => [
|
||||
'data' => [
|
||||
'DB_PASSWORD' => 's3cret',
|
||||
'REPLICAS' => 3,
|
||||
],
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
$secrets = (new VaultService('https://example.com:8200/vault/', 'hvs.token'))
|
||||
->fetchSecrets('secret', '/my-app/production/');
|
||||
|
||||
expect($secrets)->toBe([
|
||||
'DB_PASSWORD' => 's3cret',
|
||||
'REPLICAS' => '3',
|
||||
]);
|
||||
|
||||
Http::assertSent(fn ($request) => $request->hasHeader('X-Vault-Token', 'hvs.token')
|
||||
&& ! $request->hasHeader('X-Vault-Namespace'));
|
||||
});
|
||||
|
||||
test('sends the namespace header when configured', function () {
|
||||
Http::fake([
|
||||
'https://example.com:8200/vault/v1/secret/data/my-app' => Http::response([
|
||||
'data' => ['data' => ['KEY' => 'value']],
|
||||
]),
|
||||
]);
|
||||
|
||||
(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'));
|
||||
});
|
||||
|
||||
test('throws a readable error when the read fails', function () {
|
||||
Http::fake([
|
||||
'https://example.com:8200/vault/v1/secret/data/missing' => Http::response([
|
||||
'errors' => ['permission denied'],
|
||||
], 403),
|
||||
]);
|
||||
|
||||
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://example.com:8200/vault/v1/auth/token/lookup-self' => Http::response(['data' => []]),
|
||||
]);
|
||||
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Security\IntegrationTokenForm;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\IntegrationToken;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
if (! InstanceSettings::query()->whereKey(0)->exists()) {
|
||||
$settings = new InstanceSettings;
|
||||
$settings->id = 0;
|
||||
$settings->save();
|
||||
}
|
||||
|
||||
$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);
|
||||
});
|
||||
|
||||
test('a doppler token is validated against the doppler api before it is saved', function () {
|
||||
Http::fake([
|
||||
'https://api.doppler.com/v3/me' => Http::response(['type' => 'service_token']),
|
||||
]);
|
||||
|
||||
Livewire::test(IntegrationTokenForm::class, ['modal_mode' => true])
|
||||
->set('provider', 'doppler')
|
||||
->set('name', 'Production secrets')
|
||||
->set('token', 'dp.st.token')
|
||||
->call('addToken')
|
||||
->assertHasNoErrors()
|
||||
->assertDispatched('close-modal');
|
||||
|
||||
$this->assertDatabaseHas('integration_tokens', [
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'doppler',
|
||||
'name' => 'Production secrets',
|
||||
]);
|
||||
});
|
||||
|
||||
test('selecting a secret manager provider switches the capability to secrets', function () {
|
||||
Livewire::test(IntegrationTokenForm::class)
|
||||
->set('provider', 'doppler')
|
||||
->assertSet('capabilities', ['secrets'])
|
||||
->set('provider', 'cloudflare')
|
||||
->assertSet('capabilities', ['dns']);
|
||||
});
|
||||
|
||||
test('an invalid doppler token is not saved', function () {
|
||||
Http::fake([
|
||||
'https://api.doppler.com/v3/me' => Http::response([], 401),
|
||||
]);
|
||||
|
||||
Livewire::test(IntegrationTokenForm::class)
|
||||
->set('provider', 'doppler')
|
||||
->set('name', 'Bad token')
|
||||
->set('token', 'dp.st.rejected')
|
||||
->call('addToken')
|
||||
->assertHasNoErrors()
|
||||
->assertDispatched('error');
|
||||
|
||||
$this->assertDatabaseCount('integration_tokens', 0);
|
||||
});
|
||||
|
||||
test('doppler only accepts service and service account tokens', function (string $token) {
|
||||
Livewire::test(IntegrationTokenForm::class)
|
||||
->set('provider', 'doppler')
|
||||
->set('name', 'Unsupported token')
|
||||
->set('token', $token)
|
||||
->call('addToken')
|
||||
->assertHasErrors(['token']);
|
||||
|
||||
$this->assertDatabaseCount('integration_tokens', 0);
|
||||
})->with([
|
||||
'personal token' => 'dp.pt.token',
|
||||
'unknown token' => 'token',
|
||||
]);
|
||||
|
||||
test('a doppler service account token is accepted', function () {
|
||||
Http::fake([
|
||||
'https://api.doppler.com/v3/me' => Http::response(['type' => 'service_account']),
|
||||
]);
|
||||
|
||||
Livewire::test(IntegrationTokenForm::class)
|
||||
->set('provider', 'doppler')
|
||||
->set('name', 'Shared secrets')
|
||||
->set('token', 'dp.sa.token')
|
||||
->call('addToken')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$this->assertDatabaseHas('integration_tokens', [
|
||||
'provider' => 'doppler',
|
||||
'name' => 'Shared secrets',
|
||||
]);
|
||||
});
|
||||
|
||||
test('an infisical token requires a base url and a client id', function () {
|
||||
Livewire::test(IntegrationTokenForm::class)
|
||||
->set('provider', 'infisical')
|
||||
->set('name', 'Infisical')
|
||||
->set('token', 'client-secret')
|
||||
->set('metadata', [])
|
||||
->call('addToken')
|
||||
->assertHasErrors(['metadata.base_url', 'metadata.client_id']);
|
||||
|
||||
$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')
|
||||
->assertSeeInOrder(['Token name', 'Client ID', 'Client secret', 'Base URL']);
|
||||
});
|
||||
|
||||
test('an infisical token stores its metadata after a successful login', function () {
|
||||
Http::fake([
|
||||
'https://example.com/api/v1/auth/universal-auth/login' => Http::response([
|
||||
'accessToken' => 'token',
|
||||
]),
|
||||
]);
|
||||
|
||||
Livewire::test(IntegrationTokenForm::class)
|
||||
->set('provider', 'infisical')
|
||||
->set('name', 'Infisical')
|
||||
->set('token', 'client-secret')
|
||||
->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://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://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://example.com:8200'])
|
||||
->call('addToken')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$this->assertDatabaseHas('integration_tokens', [
|
||||
'provider' => 'vault',
|
||||
'name' => 'Vault',
|
||||
]);
|
||||
});
|
||||
|
||||
test('the dns capability is rejected for secret manager providers', function () {
|
||||
Livewire::test(IntegrationTokenForm::class)
|
||||
->set('provider', 'doppler')
|
||||
->set('name', 'Doppler')
|
||||
->set('token', 'dp.st.token')
|
||||
->set('capabilities', ['dns'])
|
||||
->call('addToken')
|
||||
->assertHasErrors(['capabilities.0']);
|
||||
|
||||
$this->assertDatabaseCount('integration_tokens', 0);
|
||||
});
|
||||
@@ -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,83 @@
|
||||
<?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\''],
|
||||
],
|
||||
]);
|
||||
|
||||
it('runs database start commands without persisting them through remote process', function (string $action) {
|
||||
$source = file_get_contents(__DIR__."/../../app/Actions/Database/{$action}.php");
|
||||
|
||||
expect($source)
|
||||
->toContain('ExecutesDatabaseStartCommands')
|
||||
->toContain('executeDatabaseStartCommands(')
|
||||
->not->toContain('return remote_process(');
|
||||
})->with([
|
||||
'StartClickhouse',
|
||||
'StartDragonfly',
|
||||
'StartKeydb',
|
||||
'StartMariadb',
|
||||
'StartMongodb',
|
||||
'StartMysql',
|
||||
'StartPostgresql',
|
||||
'StartRedis',
|
||||
]);
|
||||
|
||||
it('queues database starts with identifiers instead of generated commands', function () {
|
||||
$source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartDatabase.php');
|
||||
|
||||
expect($source)
|
||||
->toContain('DatabaseStartJob::dispatch(')
|
||||
->not->toContain('StartPostgresql::run(')
|
||||
->not->toContain('StartRedis::run(');
|
||||
});
|
||||
|
||||
it('keeps raw secret values separate from compose environment formatting', function (string $action, array $rawAssignments) {
|
||||
$source = file_get_contents(__DIR__."/../../app/Actions/Database/{$action}.php");
|
||||
|
||||
expect($source)
|
||||
->toContain('$rawValue = (string) $this->database->resolveSecretManagerEnvironmentVariableValue($env);')
|
||||
->toContain('$resolvedValue = (string) $this->database->formatEnvironmentVariableValue($env, $rawValue);')
|
||||
->toContain('$environment_variables->push($env->key.\'=\'.$resolvedValue);')
|
||||
->toContain(...$rawAssignments);
|
||||
})->with([
|
||||
'clickhouse' => ['StartClickhouse', ['$this->resolvedClickhouseUser = $rawValue;', '$this->resolvedClickhousePassword = $rawValue;']],
|
||||
'dragonfly' => ['StartDragonfly', ['$this->resolvedRedisPassword = $rawValue;', 'escapeshellarg($this->resolvedRedisPassword)']],
|
||||
'keydb' => ['StartKeydb', ['$this->resolvedRedisPassword = $rawValue;', 'escapeshellarg($this->resolvedRedisPassword)']],
|
||||
'mongodb' => ['StartMongodb', ['$this->resolvedMongoUsername = $rawValue;', '$this->resolvedMongoPassword = $rawValue;', '$this->resolvedMongoDatabase = $rawValue;', 'json_encode($this->resolvedMongoPassword']],
|
||||
'mysql' => ['StartMysql', ['$this->resolvedMysqlRootPassword = $rawValue;']],
|
||||
'postgresql' => ['StartPostgresql', ['$this->resolvedPostgresUser = $rawValue;', '$this->resolvedPostgresDatabase = $rawValue;']],
|
||||
]);
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use App\Events\DatabaseStatusChanged;
|
||||
use App\Jobs\DatabaseStartJob;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
it('broadcasts failed database starts to the initiating user even when the activity is missing', function () {
|
||||
Event::fake([DatabaseStatusChanged::class]);
|
||||
|
||||
$job = new DatabaseStartJob(
|
||||
databaseClass: 'MissingDatabase',
|
||||
databaseId: 123,
|
||||
teamId: 456,
|
||||
activityId: 789,
|
||||
userId: 42,
|
||||
);
|
||||
|
||||
$job->failed(new RuntimeException('Database start failed.'));
|
||||
|
||||
Event::assertDispatched(
|
||||
DatabaseStatusChanged::class,
|
||||
fn (DatabaseStatusChanged $event): bool => $event->userId === 42,
|
||||
);
|
||||
});
|
||||
|
||||
it('targets normal database start status changes to the initiating user', function () {
|
||||
$source = file_get_contents(__DIR__.'/../../app/Jobs/DatabaseStartJob.php');
|
||||
|
||||
expect($source)
|
||||
->toContain('event(new DatabaseStatusChanged($this->userId));')
|
||||
->not->toContain('event(new DatabaseStatusChanged($database));');
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
use App\Support\RemoteSecretReferences;
|
||||
|
||||
test('detects references only for the vault namespace', function () {
|
||||
expect(RemoteSecretReferences::containsReference('{{vault.DB_PASSWORD}}'))->toBeTrue()
|
||||
->and(RemoteSecretReferences::containsReference('{{doppler.KEY}}'))->toBeFalse()
|
||||
->and(RemoteSecretReferences::containsReference('{{infisical.KEY}}'))->toBeFalse()
|
||||
->and(RemoteSecretReferences::containsReference('{{ vault.KEY }}'))->toBeTrue()
|
||||
->and(RemoteSecretReferences::containsReference('pre-{{vault.KEY}}-post'))->toBeTrue();
|
||||
});
|
||||
|
||||
test('ignores the secret namespace, shared variables, and plain values', function () {
|
||||
expect(RemoteSecretReferences::containsReference('{{secret.KEY}}'))->toBeFalse()
|
||||
->and(RemoteSecretReferences::containsReference('{{team.KEY}}'))->toBeFalse()
|
||||
->and(RemoteSecretReferences::containsReference('{{project.KEY}}'))->toBeFalse()
|
||||
->and(RemoteSecretReferences::containsReference('plain'))->toBeFalse()
|
||||
->and(RemoteSecretReferences::containsReference('$OTHER_VAR'))->toBeFalse()
|
||||
->and(RemoteSecretReferences::containsReference(null))->toBeFalse()
|
||||
->and(RemoteSecretReferences::containsReference(''))->toBeFalse();
|
||||
});
|
||||
|
||||
test('extracts unique referenced keys in order', function () {
|
||||
$value = 'a={{vault.A}} ignored={{doppler.B}} again={{vault.A}}';
|
||||
|
||||
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'];
|
||||
|
||||
expect(RemoteSecretReferences::substitute('x={{vault.A}} y={{vault.MISSING}}', $secrets))
|
||||
->toBe('x=value-a y={{vault.MISSING}}');
|
||||
});
|
||||
|
||||
test('reports missing keys', function () {
|
||||
expect(RemoteSecretReferences::missingKeys('{{vault.A}}-{{vault.B}}', ['A' => '1']))->toBe(['B'])
|
||||
->and(RemoteSecretReferences::missingKeys('{{vault.A}}', ['A' => '1']))->toBe([]);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
it('shows environment variables before the optional secret manager for every resource type', function (string $view, string $resource) {
|
||||
$source = file_get_contents(__DIR__."/../../resources/views/livewire/project/{$view}/configuration.blade.php");
|
||||
$environmentVariables = '<livewire:project.shared.environment-variable.all :resource="$'.$resource.'" />';
|
||||
$secretManager = '<livewire:project.shared.secret-manager-links :resource="$'.$resource.'" />';
|
||||
|
||||
expect($source)
|
||||
->toContain($environmentVariables, $secretManager)
|
||||
->and(strpos($source, $environmentVariables))->toBeLessThan(strpos($source, $secretManager));
|
||||
})->with([
|
||||
'application' => ['application', 'application'],
|
||||
'database' => ['database', 'database'],
|
||||
'service' => ['service', 'service'],
|
||||
]);
|
||||
Reference in New Issue
Block a user