mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
fix(secrets): keep the secret manager source when cloning resources
Cloning an application, database, service or environment did not copy the secret manager link, so variables with remote secret references failed on the next deployment with "no secret manager source is configured". Clones now get a copy of the link when the integration token belongs to the clone's team. Server transfer does not export integration tokens or links; the export now warns which resources need the token and link again on the target instance.
This commit is contained in:
@@ -268,6 +268,8 @@ class CloneMe extends Component
|
||||
])->fill($payload);
|
||||
$newEnvironmentVariable->save();
|
||||
}
|
||||
|
||||
$database->cloneSecretManagerLinkTo($newDatabase);
|
||||
}
|
||||
|
||||
foreach ($services as $service) {
|
||||
@@ -317,6 +319,8 @@ class CloneMe extends Component
|
||||
$newEnvironmentVariable->save();
|
||||
}
|
||||
|
||||
$service->cloneSecretManagerLinkTo($newService);
|
||||
|
||||
foreach ($newService->applications() as $application) {
|
||||
$application->fill([
|
||||
'status' => 'exited',
|
||||
|
||||
@@ -243,6 +243,8 @@ class ResourceOperations extends Component
|
||||
$newEnvironmentVariable->save();
|
||||
}
|
||||
|
||||
$this->resource->cloneSecretManagerLinkTo($new_resource);
|
||||
|
||||
$route = route('project.database.configuration', [
|
||||
'project_uuid' => $new_environment->project->uuid,
|
||||
'environment_uuid' => $new_environment->uuid,
|
||||
@@ -299,6 +301,8 @@ class ResourceOperations extends Component
|
||||
$newEnvironmentVariable->save();
|
||||
}
|
||||
|
||||
$this->resource->cloneSecretManagerLinkTo($new_resource);
|
||||
|
||||
foreach ($new_resource->applications() as $application) {
|
||||
$application->fill([
|
||||
'status' => 'exited',
|
||||
|
||||
@@ -106,6 +106,9 @@ class ServerTransferExporter
|
||||
|
||||
$sourceInstanceUrl = rtrim((string) (instanceSettings()->fqdn ?: config('app.url')), '/');
|
||||
$warnings = $this->buildWarnings($applications, $dependencies, $sourceInstanceUrl);
|
||||
if ($secretManagerWarning = $this->secretManagerWarning($applications->concat($databases)->concat($services))) {
|
||||
$warnings[] = $secretManagerWarning;
|
||||
}
|
||||
|
||||
$payload = ServerTransferBundle::wrap([
|
||||
'source_instance' => [
|
||||
@@ -331,6 +334,27 @@ class ServerTransferExporter
|
||||
return $warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Secret manager links point to integration tokens of this instance, which are not exported.
|
||||
*
|
||||
* @param Collection<int, Model> $resources
|
||||
*/
|
||||
private function secretManagerWarning(Collection $resources): ?string
|
||||
{
|
||||
$linkedNames = $resources
|
||||
->filter(fn (Model $resource) => method_exists($resource, 'secretManagerLink') && $resource->secretManagerLink()->exists())
|
||||
->pluck('name')
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
if ($linkedNames->isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return 'Some resources load environment variables from a secret manager: '.$linkedNames->implode(', ').'. '
|
||||
.'Secret manager tokens and links are not exported. After import, add the integration token on the target instance and link the secret manager source again, or deployments of these resources fail.';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Traits;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\SecretManagerLink;
|
||||
use App\Support\RemoteSecretReferences;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphOne;
|
||||
use RuntimeException;
|
||||
|
||||
@@ -26,6 +27,25 @@ trait HasSecretManager
|
||||
return $this->morphOne(SecretManagerLink::class, 'resourceable');
|
||||
}
|
||||
|
||||
/**
|
||||
* Give a cloned resource the same secret manager source. The token is team-scoped, so a clone in another team gets no link.
|
||||
*/
|
||||
public function cloneSecretManagerLinkTo(Model $target): void
|
||||
{
|
||||
$link = $this->secretManagerLink()->with('integrationToken')->first();
|
||||
// fresh(): a replicated model keeps the relations of its source, such as the old environment.
|
||||
$targetTeamId = $target->fresh()?->team()?->id;
|
||||
|
||||
if (! $link || $targetTeamId === null || (int) $link->integrationToken?->team_id !== (int) $targetTeamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
$target->secretManagerLink()->create([
|
||||
'integration_token_id' => $link->integration_token_id,
|
||||
'settings' => $link->settings,
|
||||
]);
|
||||
}
|
||||
|
||||
public function resolveSecretManagerEnvironmentVariable(EnvironmentVariable $environmentVariable): ?string
|
||||
{
|
||||
$value = $this->resolveSecretManagerEnvironmentVariableValue($environmentVariable);
|
||||
|
||||
@@ -401,6 +401,8 @@ function clone_application(Application $source, $destination, array $overrides =
|
||||
});
|
||||
}
|
||||
|
||||
$source->cloneSecretManagerLinkTo($newApplication);
|
||||
|
||||
// Clone preview environment variables
|
||||
$previewEnvironmentVariables = $source->environment_variables_preview()->get();
|
||||
foreach ($previewEnvironmentVariables as $previewEnvironmentVariable) {
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\CloneMe;
|
||||
use App\Livewire\Project\Shared\ResourceOperations;
|
||||
use App\Models\Application;
|
||||
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\StandalonePostgresql;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->withoutVite();
|
||||
InstanceSettings::forceCreate(['id' => 0]);
|
||||
Server::flushIdentityMap();
|
||||
|
||||
$this->user = User::factory()->create();
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user->teams()->attach($this->team, ['role' => 'owner']);
|
||||
|
||||
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->destination = $this->server->standaloneDockers()->firstOrFail();
|
||||
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
|
||||
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
|
||||
|
||||
$this->application = Application::factory()->create([
|
||||
'environment_id' => $this->environment->id,
|
||||
'destination_id' => $this->destination->id,
|
||||
'destination_type' => $this->destination->getMorphClass(),
|
||||
]);
|
||||
$this->application->settings->fill(['is_container_label_readonly_enabled' => false])->save();
|
||||
|
||||
$this->token = IntegrationToken::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'vault',
|
||||
'capabilities' => ['secrets'],
|
||||
'metadata' => ['base_url' => 'https://vault.example.com'],
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => $this->team]);
|
||||
});
|
||||
|
||||
function cloneLinkTestDatabase(Environment $environment, $destination): StandalonePostgresql
|
||||
{
|
||||
return StandalonePostgresql::create([
|
||||
'name' => 'pg-linked',
|
||||
'uuid' => new_public_id(),
|
||||
'postgres_password' => 'secret',
|
||||
'postgres_user' => 'postgres',
|
||||
'postgres_db' => 'postgres',
|
||||
'environment_id' => $environment->id,
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
'status' => 'exited',
|
||||
]);
|
||||
}
|
||||
|
||||
test('cloning an application copies its secret manager link', function () {
|
||||
$this->application->secretManagerLink()->create([
|
||||
'integration_token_id' => $this->token->id,
|
||||
'settings' => ['mount' => 'secret', 'path' => 'apps/demo'],
|
||||
]);
|
||||
|
||||
$clone = clone_application($this->application, $this->destination, [
|
||||
'environment_id' => $this->environment->id,
|
||||
]);
|
||||
|
||||
$link = $clone->secretManagerLink()->first();
|
||||
expect($link)->not->toBeNull()
|
||||
->and($link->integration_token_id)->toBe($this->token->id)
|
||||
->and($link->settings)->toBe(['mount' => 'secret', 'path' => 'apps/demo'])
|
||||
->and($this->application->secretManagerLink()->exists())->toBeTrue()
|
||||
->and(SecretManagerLink::count())->toBe(2);
|
||||
});
|
||||
|
||||
test('cloning a database copies its secret manager link', function () {
|
||||
$database = cloneLinkTestDatabase($this->environment, $this->destination);
|
||||
$database->secretManagerLink()->create([
|
||||
'integration_token_id' => $this->token->id,
|
||||
'settings' => ['mount' => 'secret', 'path' => 'db'],
|
||||
]);
|
||||
|
||||
Livewire::test(ResourceOperations::class, ['resource' => $database])
|
||||
->call('cloneTo', $this->destination->uuid)
|
||||
->assertRedirect();
|
||||
|
||||
$clone = StandalonePostgresql::whereKeyNot($database->id)->sole();
|
||||
expect($clone->secretManagerLink()->first()?->integration_token_id)->toBe($this->token->id)
|
||||
->and($clone->secretManagerLink()->first()?->settings)->toBe(['mount' => 'secret', 'path' => 'db']);
|
||||
});
|
||||
|
||||
test('cloning an environment copies the secret manager links of its resources', function () {
|
||||
$this->application->secretManagerLink()->create(['integration_token_id' => $this->token->id]);
|
||||
$database = cloneLinkTestDatabase($this->environment, $this->destination);
|
||||
$database->secretManagerLink()->create(['integration_token_id' => $this->token->id]);
|
||||
|
||||
Livewire::test(CloneMe::class, [
|
||||
'project_uuid' => $this->project->uuid,
|
||||
'environment_uuid' => $this->environment->uuid,
|
||||
])
|
||||
->set('selectedDestination', $this->destination->uuid)
|
||||
->set('newName', 'linked-clone')
|
||||
->call('clone', 'environment')
|
||||
->assertNotDispatched('error');
|
||||
|
||||
$clonedEnvironment = $this->project->environments()->where('name', 'linked-clone')->sole();
|
||||
$clonedApplication = $clonedEnvironment->applications()->sole();
|
||||
$clonedDatabase = StandalonePostgresql::where('environment_id', $clonedEnvironment->id)->sole();
|
||||
|
||||
expect($clonedApplication->secretManagerLink()->first()?->integration_token_id)->toBe($this->token->id)
|
||||
->and($clonedDatabase->secretManagerLink()->first()?->integration_token_id)->toBe($this->token->id);
|
||||
});
|
||||
|
||||
test('cloning does not copy a secret manager link whose token belongs to another team', function () {
|
||||
$foreignToken = IntegrationToken::factory()->create([
|
||||
'team_id' => Team::factory()->create()->id,
|
||||
'provider' => 'vault',
|
||||
'capabilities' => ['secrets'],
|
||||
]);
|
||||
$this->application->secretManagerLink()->create(['integration_token_id' => $foreignToken->id]);
|
||||
|
||||
$clone = clone_application($this->application, $this->destination, [
|
||||
'environment_id' => $this->environment->id,
|
||||
]);
|
||||
|
||||
expect($clone->secretManagerLink()->exists())->toBeFalse();
|
||||
});
|
||||
@@ -7,6 +7,7 @@ use App\Models\EnvironmentVariable;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\GitlabApp;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\IntegrationToken;
|
||||
use App\Models\LocalFileVolume;
|
||||
use App\Models\LocalPersistentVolume;
|
||||
use App\Models\PrivateKey;
|
||||
@@ -980,3 +981,35 @@ test('transfer keeps the link between an application volume and its sqlite datab
|
||||
->and($importedSqlite->hasConnectedApplications())->toBeTrue()
|
||||
->and($importedSqlite->persistentStorages()->sole()->isSharedWithAnotherResource())->toBeTrue();
|
||||
});
|
||||
|
||||
test('export warns about secret manager links without exporting the integration token', function () {
|
||||
$token = IntegrationToken::factory()->create([
|
||||
'team_id' => $this->team->id,
|
||||
'provider' => 'vault',
|
||||
'token' => 'vault-root-token',
|
||||
'capabilities' => ['secrets'],
|
||||
]);
|
||||
$this->application->secretManagerLink()->create(['integration_token_id' => $token->id]);
|
||||
$this->database->secretManagerLink()->create(['integration_token_id' => $token->id]);
|
||||
|
||||
$bundle = $this->exporter->export($this->server);
|
||||
$warning = collect($bundle['warnings'])->first(fn (string $warning) => str_contains($warning, 'secret manager'));
|
||||
|
||||
expect($warning)->not->toBeNull()
|
||||
->and($warning)->toContain('my-app')
|
||||
->and($warning)->toContain('app-db')
|
||||
->and(json_encode($bundle))->not->toContain('vault-root-token');
|
||||
|
||||
$this->service->forceDelete();
|
||||
$this->application->forceDelete();
|
||||
$this->database->forceDelete();
|
||||
$this->server->forceDelete();
|
||||
Tag::query()->delete();
|
||||
ScheduledDatabaseBackup::query()->delete();
|
||||
ScheduledTask::query()->delete();
|
||||
$this->privateKey->delete();
|
||||
|
||||
$result = $this->importer->import($bundle, teamId: $this->team->id, dryRun: false, preserveUuids: true, adoptMode: true);
|
||||
|
||||
expect($result['warnings'])->toContain($warning);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user