Files
coolify/app/Services/ServerTransfer/ServerTransferImporter.php
T
e3d4411ff2 chore: merge main into release/v4.5 (#12192)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Furox <177975472+Furox-Art@users.noreply.github.com>
Co-authored-by: johandenhollander <johan.den.hollander@finalist.nl>
Co-authored-by: João Cruz <161638790+SrJooJ@users.noreply.github.com>
Co-authored-by: Saim Hashmi <hashmisaim037@gmail.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-18 23:43:20 +02:00

2415 lines
106 KiB
PHP

<?php
namespace App\Services\ServerTransfer;
use App\Enums\GithubRunnerDockerMode;
use App\Enums\ServerRole;
use App\Jobs\ValidateAndInstallServerJob;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\CloudProviderToken;
use App\Models\DnsProviderZone;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\GithubApp;
use App\Models\GithubRunnerConfig;
use App\Models\GitlabApp;
use App\Models\IntegrationToken;
use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
use App\Models\ManagedDnsRecord;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\ResourceLink;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledTask;
use App\Models\ScheduledVolumeBackup;
use App\Models\SecretManagerLink;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Models\SharedEnvironmentVariable;
use App\Models\SslCertificate;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDocker;
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\StandaloneSqlite;
use App\Models\SwarmDocker;
use App\Models\Tag;
use App\Models\Team;
use App\Notifications\Internal\GeneralNotification;
use App\Rules\DockerImageFormat;
use App\Rules\ValidServerIp;
use App\Services\Dns\CloudflareDnsProvider;
use App\Services\ProjectIconStorageService;
use App\Support\ValidationPatterns;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use RuntimeException;
use Throwable;
class ServerTransferImporter
{
/**
* @var array<string, class-string>
*/
private const DATABASE_MODELS = [
'StandalonePostgresql' => StandalonePostgresql::class,
'StandaloneMysql' => StandaloneMysql::class,
'StandaloneMariadb' => StandaloneMariadb::class,
'StandaloneMongodb' => StandaloneMongodb::class,
'StandaloneRedis' => StandaloneRedis::class,
'StandaloneKeydb' => StandaloneKeydb::class,
'StandaloneDragonfly' => StandaloneDragonfly::class,
'StandaloneClickhouse' => StandaloneClickhouse::class,
'StandaloneSqlite' => StandaloneSqlite::class,
];
/** @var array<string, PrivateKey> */
private array $privateKeyMap = [];
/** @var array<string, GithubApp> */
private array $githubAppMap = [];
/** @var array<string, GitlabApp> */
private array $gitlabAppMap = [];
/** @var array<string, S3Storage> */
private array $s3StorageMap = [];
/** @var array<string, CloudProviderToken> */
private array $cloudTokenMap = [];
/** @var array<string, IntegrationToken> */
private array $integrationTokenMap = [];
/** @var array<string, DnsProviderZone> */
private array $dnsZoneMap = [];
/**
* Imported owned DNS records that carry the ownership comment of the source instance. This instance takes
* ownership at the provider after the import transaction commits.
*
* @var list<array{record_id: int, previous_comment: string}>
*/
private array $pendingDnsAdoptions = [];
/**
* Icons of imported projects. They are stored after the import transaction commits, so a storage error
* cannot roll back DB rows.
*
* @var list<array{project: Project, icon: array<string, mixed>}>
*/
private array $pendingProjectIcons = [];
/** @var array<string, LocalPersistentVolume|LocalFileVolume> */
private array $volumeMap = [];
/** @var array<string, Application> */
private array $applicationMap = [];
/** @var array<string, ApplicationPreview> */
private array $previewMap = [];
/** @var array<string, Service> */
private array $serviceMap = [];
/** @var array<string, ServiceApplication> */
private array $serviceApplicationMap = [];
/** @var array<string, Model> */
private array $databaseMap = [];
/**
* Imported volumes that mount a SQLite database volume, keyed by volume id. The database may be imported later.
*
* @var array<int, array{volume: LocalPersistentVolume, sqlite_uuid: string}>
*/
private array $pendingSqliteLinks = [];
/**
* Warnings about references of imported resources that this instance cannot resolve.
*
* @var list<string>
*/
private array $importWarnings = [];
/**
* Applications whose git source needs a check after the transfer: a system-wide source of the source
* instance, or a source that this instance does not have.
*
* @var list<string>
*/
private array $gitSourceApplications = [];
/**
* @param array<string, mixed> $bundle
* @return array{
* dry_run: bool,
* warnings: list<string>,
* server_uuid: string|null,
* private_key_uuid: string|null,
* created: array<string, int>,
* preserved_uuids: bool,
* export_id: string|null,
* claimed: bool,
* claim: array<string, mixed>|null,
* checklist: list<string>
* }
*/
public function import(
array $bundle,
int $teamId,
bool $dryRun = false,
bool $preserveUuids = true,
bool $adoptMode = true,
): array {
ServerTransferBundle::assertValid($bundle);
$this->assertValidServerPayload((array) data_get($bundle, 'server', []));
if ($adoptMode && ! $preserveUuids) {
throw ValidationException::withMessages([
'preserve_uuids' => ['Adopt mode keeps the running containers, so it needs the original UUIDs. Import with preserve_uuids=true.'],
]);
}
$validation = ServerTransferBundle::validate($bundle);
$warnings = $validation['warnings'];
if (is_array(data_get($bundle, 'warnings'))) {
$warnings = array_values(array_unique(array_merge($warnings, $bundle['warnings'])));
}
$serverUuid = (string) data_get($bundle, 'server.uuid');
$serverIp = (string) data_get($bundle, 'server.ip');
// A server that comes back replaces the archived copy that this instance kept from the first transfer.
$archive = $preserveUuids ? $this->replaceableArchive($serverUuid, $teamId) : null;
$uuidConflict = $archive === null && $preserveUuids && Server::withTrashed()->where('uuid', $serverUuid)->exists();
$ipConflict = Server::where('ip', Server::normalizeIp($serverIp))->when($archive, fn ($query) => $query->whereKeyNot($archive->id))->exists();
if (! $dryRun) {
$this->assertCanImport($serverUuid, $serverIp, $teamId, $preserveUuids);
}
if ($uuidConflict) {
$warnings[] = "A server with UUID {$serverUuid} already exists on this instance.";
}
if ($ipConflict) {
$warnings[] = "A server with IP/domain {$serverIp} already exists on this instance.";
}
$created = [
'projects' => 0,
'environments' => 0,
'applications' => 0,
'databases' => 0,
'services' => 0,
'destinations' => 0,
'private_keys' => 0,
'github_apps' => 0,
'gitlab_apps' => 0,
's3_storages' => 0,
'cloud_provider_tokens' => 0,
'integration_tokens' => 0,
'managed_dns_records' => 0,
'ssl_certificates' => 0,
'volume_backups' => 0,
'previews' => 0,
];
if ($dryRun) {
foreach (data_get($bundle, 'projects', []) as $project) {
$created['projects']++;
foreach (data_get($project, 'environments', []) as $environment) {
$created['environments']++;
$created['applications'] += count(data_get($environment, 'applications', []));
$created['databases'] += count(data_get($environment, 'databases', []));
$created['services'] += count(data_get($environment, 'services', []));
foreach (data_get($environment, 'applications', []) as $app) {
$created['previews'] += count(data_get($app, 'previews', []));
}
}
}
$created['destinations'] = count(data_get($bundle, 'destinations', []));
$created['private_keys'] = max(
count(data_get($bundle, 'private_keys', [])),
data_get($bundle, 'private_key') ? 1 : 0
);
$created['github_apps'] = count(data_get($bundle, 'github_apps', []));
$created['gitlab_apps'] = count(data_get($bundle, 'gitlab_apps', []));
$created['s3_storages'] = count(data_get($bundle, 's3_storages', []));
$created['cloud_provider_tokens'] = count(data_get($bundle, 'cloud_provider_tokens', []));
$created['integration_tokens'] = count(data_get($bundle, 'integration_tokens', []));
$created['managed_dns_records'] = count(data_get($bundle, 'managed_dns_records', []));
$created['ssl_certificates'] = count(data_get($bundle, 'ssl_certificates', []));
$created['volume_backups'] = count(data_get($bundle, 'volume_backups', []));
return [
'dry_run' => true,
'warnings' => $warnings,
'server_uuid' => $preserveUuids ? $serverUuid : null,
'private_key_uuid' => data_get($bundle, 'private_key.uuid'),
'created' => $created,
'preserved_uuids' => $preserveUuids,
'export_id' => data_get($bundle, 'export_id'),
'claimed' => false,
'claim' => null,
'checklist' => $this->postTransferChecklist($bundle),
];
}
$result = DB::transaction(function () use ($bundle, $teamId, $preserveUuids, $adoptMode, $warnings, $archive, &$created) {
// Removed in the same transaction as the import, so a failed import keeps the archive.
if ($archive) {
app(ServerTransferClaimer::class)->deleteArchive($archive);
}
Team::ensureServerCapacity($teamId);
$this->privateKeyMap = [];
$this->githubAppMap = [];
$this->gitlabAppMap = [];
$this->s3StorageMap = [];
$this->cloudTokenMap = [];
$this->integrationTokenMap = [];
$this->dnsZoneMap = [];
$this->pendingDnsAdoptions = [];
$this->pendingProjectIcons = [];
$this->volumeMap = [];
$this->applicationMap = [];
$this->previewMap = [];
$this->serviceMap = [];
$this->serviceApplicationMap = [];
$this->databaseMap = [];
$this->pendingSqliteLinks = [];
$this->importWarnings = [];
$this->gitSourceApplications = [];
// Import shared dependencies first
$keyPayloads = data_get($bundle, 'private_keys', []);
if ($keyPayloads === [] && data_get($bundle, 'private_key')) {
$keyPayloads = [data_get($bundle, 'private_key')];
}
foreach ($keyPayloads as $keyPayload) {
$key = $this->importPrivateKey($keyPayload, $teamId, $preserveUuids);
$this->privateKeyMap[(string) data_get($keyPayload, 'uuid', $key->uuid)] = $key;
$this->privateKeyMap[$key->uuid] = $key;
$created['private_keys']++;
}
foreach (data_get($bundle, 'github_apps', []) as $ghPayload) {
// Defensive: never import system-wide GitHub Apps from a bundle.
if ((bool) data_get($ghPayload, 'is_system_wide', false)) {
continue;
}
$gh = $this->importGithubApp($ghPayload, $teamId, $preserveUuids);
$this->githubAppMap[(string) data_get($ghPayload, 'uuid', $gh->uuid)] = $gh;
$this->githubAppMap[$gh->uuid] = $gh;
$created['github_apps']++;
}
foreach (data_get($bundle, 'gitlab_apps', []) as $glPayload) {
// Defensive: never import system-wide GitLab Apps from a bundle.
if ((bool) data_get($glPayload, 'is_system_wide', false)) {
continue;
}
$gl = $this->importGitlabApp($glPayload, $teamId, $preserveUuids);
$this->gitlabAppMap[(string) data_get($glPayload, 'uuid', $gl->uuid)] = $gl;
$this->gitlabAppMap[$gl->uuid] = $gl;
$created['gitlab_apps']++;
}
foreach (data_get($bundle, 's3_storages', []) as $s3Payload) {
$s3 = $this->importS3Storage($s3Payload, $teamId, $preserveUuids);
$this->s3StorageMap[(string) data_get($s3Payload, 'uuid', $s3->uuid)] = $s3;
$this->s3StorageMap[$s3->uuid] = $s3;
$created['s3_storages']++;
}
foreach (data_get($bundle, 'cloud_provider_tokens', []) as $tokenPayload) {
$token = $this->importCloudProviderToken($tokenPayload, $teamId, $preserveUuids);
$this->cloudTokenMap[(string) data_get($tokenPayload, 'uuid', $token->uuid)] = $token;
$this->cloudTokenMap[$token->uuid] = $token;
$created['cloud_provider_tokens']++;
}
foreach (data_get($bundle, 'integration_tokens', []) as $tokenPayload) {
$token = $this->importIntegrationToken($tokenPayload, $teamId, $preserveUuids);
$this->integrationTokenMap[(string) data_get($tokenPayload, 'uuid', $token->uuid)] = $token;
$this->integrationTokenMap[$token->uuid] = $token;
$created['integration_tokens']++;
}
$serverKeyUuid = (string) data_get($bundle, 'private_key.uuid', '');
$privateKey = $this->privateKeyMap[$serverKeyUuid]
?? $this->importPrivateKey(data_get($bundle, 'private_key', []), $teamId, $preserveUuids);
$server = $this->importServer(data_get($bundle, 'server', []), $privateKey, $teamId, $preserveUuids, $adoptMode);
$destinationMap = $this->importDestinations(data_get($bundle, 'destinations', []), $server, $preserveUuids);
$created['destinations'] = count($destinationMap);
$this->importGithubRunnerConfig(data_get($bundle, 'github_runner_config'), $server, $teamId, $preserveUuids);
$this->importServerSharedEnvVars(data_get($bundle, 'shared_environment_variables.server', []), $server, $teamId);
$this->importTeamSharedEnvVars(data_get($bundle, 'shared_environment_variables.team', []), $teamId);
foreach (data_get($bundle, 'projects', []) as $projectPayload) {
$project = $this->importProject($projectPayload, $teamId, $preserveUuids, $created);
foreach (data_get($projectPayload, 'environments', []) as $environmentPayload) {
$environment = $this->importEnvironment($environmentPayload, $project, $preserveUuids, $created);
foreach (data_get($environmentPayload, 'applications', []) as $appPayload) {
$app = $this->importApplication($appPayload, $environment, $destinationMap, $preserveUuids, $adoptMode, $teamId);
$this->applicationMap[(string) data_get($appPayload, 'uuid', $app->uuid)] = $app;
$created['applications']++;
$created['previews'] += count(data_get($appPayload, 'previews', []));
}
foreach (data_get($environmentPayload, 'databases', []) as $dbPayload) {
$db = $this->importDatabase($dbPayload, $environment, $destinationMap, $preserveUuids, $adoptMode, $teamId);
if ($db) {
$this->databaseMap[(string) data_get($dbPayload, 'uuid', $db->uuid)] = $db;
}
$created['databases']++;
}
foreach (data_get($environmentPayload, 'services', []) as $servicePayload) {
$service = $this->importService($servicePayload, $environment, $server, $destinationMap, $preserveUuids, $adoptMode, $teamId);
$this->serviceMap[(string) data_get($servicePayload, 'uuid', $service->uuid)] = $service;
$created['services']++;
}
$this->importCanvasPositions((array) data_get($environmentPayload, 'canvas_positions', []), $environment);
$this->importResourceLinks((array) data_get($environmentPayload, 'resource_links', []), $environment, $preserveUuids);
}
}
$warnings = array_merge($warnings, $this->linkSqliteVolumes());
$this->importDnsZones(data_get($bundle, 'dns_zones', []), $preserveUuids);
$created['managed_dns_records'] = $this->importManagedDnsRecords(data_get($bundle, 'managed_dns_records', []), $teamId, $preserveUuids);
$created['ssl_certificates'] = $this->importSslCertificates(data_get($bundle, 'ssl_certificates', []), $server);
$created['volume_backups'] = $this->importVolumeBackups(data_get($bundle, 'volume_backups', []), $teamId);
$warnings = array_merge($warnings, $this->importWarnings);
$metadata = $server->server_metadata ?? [];
$metadata['transfer'] = [
'status' => 'imported',
'export_id' => data_get($bundle, 'export_id'),
'source_instance_url' => data_get($bundle, 'source_instance.url'),
'imported_at' => now()->toIso8601String(),
'adopt_mode' => $adoptMode,
'git_source_applications' => array_values(array_unique($this->gitSourceApplications)),
];
$server->server_metadata = $metadata;
$server->save();
return [
'dry_run' => false,
'warnings' => array_values(array_unique($warnings)),
'server_uuid' => $server->uuid,
'private_key_uuid' => $privateKey->uuid,
'created' => $created,
'preserved_uuids' => $preserveUuids,
'export_id' => data_get($bundle, 'export_id'),
'claimed' => false,
'claim' => null,
'checklist' => $this->postTransferChecklist($bundle),
];
});
// Provider and storage calls run after the import transaction commits, so an error cannot roll back DB rows.
$result['warnings'] = array_values(array_unique(array_merge($result['warnings'], $this->adoptManagedDnsRecords(), $this->storeProjectIcons())));
// Claim after the import transaction commits so a claim failure cannot roll back DB rows.
if (filled(data_get($result, 'server_uuid'))) {
$server = Server::where('uuid', $result['server_uuid'])->where('team_id', $teamId)->first();
if ($server) {
try {
$result['claim'] = app(ServerTransferClaimer::class)->claim($server);
$result['claimed'] = true;
// Same check as the Validate button. A running proxy and Docker are kept as they are.
ValidateAndInstallServerJob::dispatch($server);
$this->startFinishChecklist($server);
$result['server_url'] = rtrim((string) (instanceSettings()->fqdn ?: config('app.url')), '/').'/server/'.$server->uuid;
} catch (Throwable $e) {
$result['claimed'] = false;
$result['claim'] = null;
$result['warnings'][] = 'Server imported, but automatic claim failed: '.$e->getMessage();
}
}
}
return $result;
}
/**
* Check the server connection fields with the same rules as the create server API.
*
* @param array<string, mixed> $server
*/
private function assertValidServerPayload(array $server): void
{
Validator::make($server, [
'name' => 'nullable|string|max:255',
'ip' => ['required', 'string', new ValidServerIp],
'port' => 'required|integer|between:1,65535',
'user' => ValidationPatterns::serverUsernameRules(required: false),
], [], [
'ip' => 'server.ip',
'port' => 'server.port',
'user' => 'server.user',
])->validate();
}
/**
* Checks that a server can be imported into the team, without the bundle. Every check runs, so the
* source instance can show which checks are OK and which failed.
*
* @return list<array{field: string, label: string, ok: bool, message: string|null}>
*/
public function importChecks(string $serverUuid, string $serverIp, int $teamId, bool $preserveUuids = true): array
{
$team = Team::find($teamId);
$archive = $preserveUuids ? $this->replaceableArchive($serverUuid, $teamId) : null;
// The archive is replaced, so the import does not add a server.
$limitReached = ! $team || ($archive === null && Team::serverLimitReached($team));
$existingServer = $preserveUuids && $archive === null ? Server::withTrashed()->where('uuid', $serverUuid)->first() : null;
$uuidMessage = match (true) {
$existingServer === null => null,
$existingServer->trashed() => "The server with UUID {$serverUuid} is still being deleted on this instance. Wait a minute, then transfer again.",
$existingServer->isTransferredAway() => "Another team of this instance has the archived copy of server {$existingServer->name}. Transfer the server into that team, or delete the archive there first.",
default => "A server with UUID {$serverUuid} already exists on this instance. Delete/transfer it first, or import with preserve_uuids=false.",
};
$ipExists = Server::where('ip', Server::normalizeIp($serverIp))->when($archive, fn ($query) => $query->whereKeyNot($archive->id))->exists();
return [
[
'field' => 'server',
'label' => 'the team can have one more server',
'ok' => ! $limitReached,
'message' => $limitReached ? 'Server limit reached for your subscription.' : null,
],
[
'field' => 'server.uuid',
'label' => $archive ? 'the archived copy of this server on this instance is replaced' : 'no server with the same UUID exists',
'ok' => $uuidMessage === null,
'message' => $uuidMessage,
],
[
'field' => 'server.ip',
'label' => 'no server with the same IP exists',
'ok' => ! $ipExists,
'message' => $ipExists ? "A server with IP/domain {$serverIp} already exists on this instance. Remove it first." : null,
],
];
}
/**
* The steps that finish the transfer here, and one notification that points the team to them.
* A failure here never fails the import: the server is imported and claimed already.
*/
private function startFinishChecklist(Server $server): void
{
try {
$items = app(TransferFinishChecklist::class)->start($server);
$server->team?->notify(new GeneralNotification(
"Server '{$server->name}' was transferred to this instance. Open the server to finish the transfer: ".count($items).' steps to check.'
));
} catch (Throwable $e) {
Log::warning('Could not start the transfer finish checklist', ['server_uuid' => $server->uuid, 'error' => $e->getMessage()]);
}
}
/**
* The archive that this instance kept when it transferred the server away. A transfer back into the same team
* replaces it.
*/
private function replaceableArchive(string $serverUuid, int $teamId): ?Server
{
$server = Server::query()->where('uuid', $serverUuid)->where('team_id', $teamId)->first();
return $server?->isTransferredAway() ? $server : null;
}
/**
* @throws ValidationException with the first failed import check
*/
public function assertCanImport(string $serverUuid, string $serverIp, int $teamId, bool $preserveUuids = true): void
{
foreach ($this->importChecks($serverUuid, $serverIp, $teamId, $preserveUuids) as $check) {
if (! $check['ok']) {
throw ValidationException::withMessages([$check['field'] => [$check['message']]]);
}
}
}
/**
* Running containers are found by the resource UUID. In adopt mode a new UUID would leave them
* without a resource, so a conflict stops the import instead.
*
* @param class-string<Model> $modelClass
*/
private function resolveResourceUuid(string $modelClass, string $uuid, bool $adoptMode, string $label): string
{
if (! $modelClass::withTrashed()->where('uuid', $uuid)->exists()) {
return $uuid;
}
if ($adoptMode) {
throw ValidationException::withMessages([
'bundle' => ["The {$label} UUID {$uuid} is already used on this instance. Delete that {$label} permanently first, because adopt mode needs the same UUID to keep the running containers."],
]);
}
return new_public_id();
}
/**
* @param array<string, mixed> $payload
*/
private function importPrivateKey(array $payload, int $teamId, bool $preserveUuids): PrivateKey
{
$material = (string) data_get($payload, 'private_key');
if ($material === '') {
throw new RuntimeException('Private key material is required.');
}
$fingerprint = PrivateKey::generateFingerprint($material)
?? data_get($payload, 'fingerprint');
if ($fingerprint) {
// Keys are unique per team, so only a key of the target team can be reused.
$existing = PrivateKey::query()
->where('team_id', $teamId)
->where('fingerprint', $fingerprint)
->first();
if ($existing) {
return $existing;
}
}
$uuid = $preserveUuids && filled(data_get($payload, 'uuid'))
? (string) data_get($payload, 'uuid')
: new_public_id();
if (PrivateKey::where('uuid', $uuid)->exists()) {
$uuid = new_public_id();
}
$key = new PrivateKey([
'name' => data_get($payload, 'name') ?: 'Imported transfer key',
'description' => data_get($payload, 'description'),
'private_key' => $material,
'team_id' => $teamId,
'is_git_related' => (bool) data_get($payload, 'is_git_related', false),
]);
$key->uuid = $uuid;
try {
$key->save();
} catch (Throwable $e) {
// Retry once without forcing uuid if uniqueness/validation conflicts.
$key->uuid = new_public_id();
$key->save();
}
return $key->fresh();
}
/**
* @param array<string, mixed> $payload
*/
private function importServer(array $payload, PrivateKey $privateKey, int $teamId, bool $preserveUuids, bool $adoptMode): Server
{
$uuid = $preserveUuids && filled(data_get($payload, 'uuid'))
? (string) data_get($payload, 'uuid')
: new_public_id();
if (Server::withTrashed()->where('uuid', $uuid)->exists()) {
$uuid = new_public_id();
}
$cloudTokenId = null;
$cloudTokenUuid = data_get($payload, 'cloud_provider_token_uuid');
if ($cloudTokenUuid && isset($this->cloudTokenMap[$cloudTokenUuid])) {
$cloudTokenId = $this->cloudTokenMap[$cloudTokenUuid]->id;
}
$server = new Server;
$server->forceFill([
'name' => data_get($payload, 'name') ?: generate_random_name(),
'description' => data_get($payload, 'description'),
'ip' => data_get($payload, 'ip'),
'port' => (int) data_get($payload, 'port', 22),
'user' => data_get($payload, 'user') ?: 'root',
'private_key_id' => $privateKey->id,
'team_id' => $teamId,
'cloud_provider_token_id' => $cloudTokenId,
'hetzner_server_id' => data_get($payload, 'hetzner_server_id'),
'vultr_instance_id' => data_get($payload, 'vultr_instance_id'),
'digitalocean_droplet_id' => data_get($payload, 'digitalocean_droplet_id'),
'hostinger_virtual_machine_id' => data_get($payload, 'hostinger_virtual_machine_id'),
'proxy_error_page' => data_get($payload, 'proxy_error_page'),
'swarm_cluster' => data_get($payload, 'swarm_cluster'),
]);
$server->uuid = $uuid;
$server->save();
if ($server->settings) {
$serverRole = data_get($payload, 'server_role');
if (! in_array($serverRole, array_column(ServerRole::cases(), 'value'), true)) {
$serverRole = data_get($payload, 'is_build_server') ? ServerRole::BUILD->value : ServerRole::BOTH->value;
}
$server->settings->server_role = $serverRole;
$server->settings->is_build_server = $serverRole === ServerRole::BUILD->value;
$server->settings->save();
}
$proxy = data_get($payload, 'proxy', []);
if (is_array($proxy) && $proxy !== []) {
foreach ($proxy as $key => $value) {
$server->proxy->set($key, $value);
}
$server->save();
}
$settingsPayload = data_get($payload, 'settings', []);
if (is_array($settingsPayload) && $server->settings) {
$safe = collect($settingsPayload)
->except([
'id', 'server_id', 'created_at', 'updated_at',
'is_reachable', 'is_usable', 'force_disabled',
'sentinel_token', 'sentinel_custom_url',
// A foreign key of the source instance; older bundles still contain it.
'cloudflare_tunnel_integration_token_id',
'cloudflare_tunnel_integration_token_uuid',
])
->all();
$server->settings->fill($safe);
$server->settings->cloudflare_tunnel_integration_token_id = $this->importedIntegrationTokenId(
data_get($settingsPayload, 'cloudflare_tunnel_integration_token_uuid'),
'The Cloudflare tunnel of this server: its Cloudflare API token was not found on this instance; select it again in the Cloudflare tunnel settings.',
);
// Imported servers start not validated; claim will rebind sentinel.
$server->settings->is_reachable = false;
$server->settings->is_usable = false;
$server->settings->force_disabled = false;
$server->settings->is_sentinel_enabled = false;
$server->settings->save();
}
if ($adoptMode) {
// Do not trigger validation/install automatically; operator claims next.
$server->settings->is_reachable = false;
$server->settings->is_usable = false;
$server->settings->save();
}
return $server->fresh(['settings', 'standaloneDockers', 'swarmDockers']);
}
/**
* @param list<array<string, mixed>> $destinations
* @return array<string, StandaloneDocker|SwarmDocker> keyed by original uuid
*/
private function importDestinations(array $destinations, Server $server, bool $preserveUuids): array
{
$map = [];
$existingStandalone = $server->standaloneDockers->values();
$existingSwarm = $server->swarmDockers->values();
$standaloneIndex = 0;
$swarmIndex = 0;
if ($destinations === []) {
$first = $existingStandalone->first() ?? $existingSwarm->first();
if ($first) {
$map[$first->uuid] = $first;
}
return $map;
}
foreach ($destinations as $destination) {
$type = data_get($destination, 'type', 'standalone');
$originalUuid = (string) data_get($destination, 'uuid');
$uuid = $preserveUuids && $originalUuid !== '' ? $originalUuid : new_public_id();
if ($type === 'swarm') {
$model = $existingSwarm->get($swarmIndex);
$swarmIndex++;
if ($model) {
$model->forceFill([
'uuid' => SwarmDocker::where('uuid', $uuid)->where('id', '!=', $model->id)->exists() ? $model->uuid : $uuid,
'name' => data_get($destination, 'name') ?: $model->name,
'network' => data_get($destination, 'network') ?: $model->network,
])->saveQuietly();
} else {
$model = new SwarmDocker;
$model->forceFill([
'uuid' => SwarmDocker::where('uuid', $uuid)->exists() ? new_public_id() : $uuid,
'name' => data_get($destination, 'name') ?: 'coolify-overlay',
'network' => data_get($destination, 'network') ?: 'coolify-overlay',
'server_id' => $server->id,
])->saveQuietly();
}
} else {
$model = $existingStandalone->get($standaloneIndex);
$standaloneIndex++;
if ($model) {
$targetUuid = StandaloneDocker::where('uuid', $uuid)->where('id', '!=', $model->id)->exists()
? $model->uuid
: $uuid;
$model->forceFill([
'uuid' => $targetUuid,
'name' => data_get($destination, 'name') ?: $model->name,
'network' => data_get($destination, 'network') ?: $model->network,
])->saveQuietly();
} else {
$model = new StandaloneDocker;
$model->forceFill([
'uuid' => StandaloneDocker::where('uuid', $uuid)->exists() ? new_public_id() : $uuid,
'name' => data_get($destination, 'name') ?: 'coolify',
'network' => data_get($destination, 'network') ?: 'coolify',
'server_id' => $server->id,
])->saveQuietly();
}
}
$map[$originalUuid !== '' ? $originalUuid : $model->uuid] = $model->fresh();
}
return $map;
}
/**
* @param list<array<string, mixed>> $variables
*/
private function importServerSharedEnvVars(array $variables, Server $server, int $teamId): void
{
foreach ($variables as $variable) {
SharedEnvironmentVariable::create([
'key' => data_get($variable, 'key'),
'value' => data_get($variable, 'value'),
'is_multiline' => (bool) data_get($variable, 'is_multiline', false),
'is_literal' => (bool) data_get($variable, 'is_literal', false),
'is_shown_once' => (bool) data_get($variable, 'is_shown_once', false),
'comment' => data_get($variable, 'comment'),
'type' => 'server',
'server_id' => $server->id,
'team_id' => $teamId,
]);
}
}
/**
* A team variable that already exists keeps the value of this instance.
*
* @param list<array<string, mixed>> $variables
*/
private function importTeamSharedEnvVars(array $variables, int $teamId): void
{
foreach ($variables as $variable) {
$key = (string) data_get($variable, 'key');
$exists = SharedEnvironmentVariable::query()
->where('type', 'team')
->where('team_id', $teamId)
->where('key', $key)
->exists();
if ($exists) {
$this->importWarnings[] = "Team shared variable {$key} already exists on this instance. It keeps its value here, which can differ from the value on the source instance.";
continue;
}
SharedEnvironmentVariable::create([
'key' => $key,
'value' => data_get($variable, 'value'),
'is_multiline' => (bool) data_get($variable, 'is_multiline', false),
'is_literal' => (bool) data_get($variable, 'is_literal', false),
'is_shown_once' => (bool) data_get($variable, 'is_shown_once', false),
'comment' => data_get($variable, 'comment'),
'type' => 'team',
'team_id' => $teamId,
]);
}
}
/**
* @param array<string, mixed> $payload
* @param array<string, int> $created
*/
private function importProject(array $payload, int $teamId, bool $preserveUuids, array &$created): Project
{
$uuid = $preserveUuids && filled(data_get($payload, 'uuid'))
? (string) data_get($payload, 'uuid')
: new_public_id();
$icon = data_get($payload, 'icon');
$existing = Project::where('uuid', $uuid)->where('team_id', $teamId)->first();
if ($existing) {
$this->importProjectSharedEnvVars(data_get($payload, 'shared_environment_variables', []), $existing, $teamId);
// An icon that the project already has on this instance is kept.
if (is_array($icon) && blank($existing->icon_path)) {
$this->pendingProjectIcons[] = ['project' => $existing, 'icon' => $icon];
}
return $existing;
}
if (Project::where('uuid', $uuid)->exists()) {
$uuid = new_public_id();
}
$project = Project::create([
'uuid' => $uuid,
'name' => data_get($payload, 'name') ?: generate_random_name(),
'description' => data_get($payload, 'description'),
'team_id' => $teamId,
]);
$created['projects']++;
$this->importProjectSharedEnvVars(data_get($payload, 'shared_environment_variables', []), $project, $teamId);
$project = $project->fresh(['environments']);
if (is_array($icon)) {
$this->pendingProjectIcons[] = ['project' => $project, 'icon' => $icon];
}
return $project;
}
/**
* Stores the icons of the imported projects in the icon storage of this instance. Coolify stores every
* icon as a JPEG file and serves it as one, so other content is not stored.
*
* @return list<string> warnings
*/
private function storeProjectIcons(): array
{
$warnings = [];
foreach ($this->pendingProjectIcons as ['project' => $project, 'icon' => $icon]) {
$contents = base64_decode((string) data_get($icon, 'content_base64', ''), true);
$problem = match (true) {
! is_string($contents) || $contents === '' => 'its content is not valid',
strlen($contents) > ServerTransferExporter::MAX_PROJECT_ICON_BYTES => 'it is larger than 2 MB',
(new \finfo(FILEINFO_MIME_TYPE))->buffer($contents) !== 'image/jpeg' => 'it is not a JPEG image',
default => null,
};
if ($problem === null) {
try {
app(ProjectIconStorageService::class)->storeProjectContents($project, $contents);
continue;
} catch (Throwable) {
$problem = 'the icon storage of this instance is not available';
}
}
$warnings[] = "The icon of project {$project->name} was not imported, because {$problem}. Upload it again in the project settings.";
}
$this->pendingProjectIcons = [];
return $warnings;
}
/**
* Manual steps after the transfer, with the URL of this instance in place of {target_url}.
*
* @param array<string, mixed> $bundle
* @return list<string>
*/
private function postTransferChecklist(array $bundle): array
{
$targetUrl = rtrim((string) (instanceSettings()->fqdn ?: config('app.url')), '/');
return array_values(array_unique(array_map(
fn (string $step) => str_replace('{target_url}', $targetUrl, $step),
array_filter((array) data_get($bundle, 'post_transfer_checklist', []), 'is_string'),
)));
}
/**
* The config is imported disabled: an enabled config makes this instance remove every runner container on
* the server that it does not know, also the runners that the source started. The user enables it after
* the GitHub App sends its webhooks here.
*/
private function importGithubRunnerConfig(mixed $payload, Server $server, int $teamId, bool $preserveUuids): void
{
if (! is_array($payload)) {
return;
}
$appUuid = (string) data_get($payload, 'github_app_uuid', '');
$githubApp = $appUuid === '' ? null : ($this->githubAppMap[$appUuid]
?? GithubApp::query()->where('uuid', $appUuid)->where('is_system_wide', true)->first()
?? GithubApp::query()->where('uuid', $appUuid)->where('team_id', $teamId)->first());
if (! $githubApp) {
$this->importWarnings[] = 'The GitHub runner settings of this server were not imported, because their GitHub App was not found on this instance. Set up GitHub runners on the server again.';
return;
}
$validator = Validator::make($payload, [
'labels' => ['required', 'array', 'min:1'],
'labels.*' => ['string', 'regex:/\A[a-z0-9][a-z0-9._-]{0,99}\z/'],
'max_runners' => ['required', 'integer', 'min:1', 'max:32'],
'docker_mode' => ['required', Rule::enum(GithubRunnerDockerMode::class)],
'runner_image' => ['nullable', 'string', 'max:255', new DockerImageFormat],
'cpu_limit' => ['nullable', 'string', 'regex:/\A\d+(\.\d+)?\z/'],
'memory_limit' => ['nullable', 'string', 'regex:/\A\d+[bkmg]?\z/i'],
'capacity_wait_timeout' => ['required', 'integer', 'min:1', 'max:1440'],
'idle_timeout' => ['required', 'integer', 'min:1', 'max:1440'],
'job_timeout' => ['required', 'integer', 'min:1', 'max:7200'],
]);
if ($validator->fails()) {
$this->importWarnings[] = 'The GitHub runner settings of this server were not imported, because they are not valid: '.$validator->errors()->first().' Set up GitHub runners on the server again.';
return;
}
$uuid = (string) data_get($payload, 'uuid', '');
$config = new GithubRunnerConfig;
$config->forceFill([
'server_id' => $server->id,
'github_app_id' => $githubApp->id,
'labels' => array_values($payload['labels']),
'is_enabled' => false,
'is_dedicated' => (bool) data_get($payload, 'is_dedicated', false),
'allow_pull_requests' => (bool) data_get($payload, 'allow_pull_requests', false),
'max_runners' => (int) $payload['max_runners'],
'docker_mode' => GithubRunnerDockerMode::from($payload['docker_mode']),
'runner_image' => data_get($payload, 'runner_image'),
'cpu_limit' => data_get($payload, 'cpu_limit'),
'memory_limit' => data_get($payload, 'memory_limit'),
'capacity_wait_timeout' => (int) $payload['capacity_wait_timeout'],
'idle_timeout' => (int) $payload['idle_timeout'],
'job_timeout' => (int) $payload['job_timeout'],
]);
if ($preserveUuids && $uuid !== '' && ! GithubRunnerConfig::where('uuid', $uuid)->exists()) {
$config->uuid = $uuid;
}
$config->save();
}
/**
* @param list<array<string, mixed>> $variables
*/
private function importProjectSharedEnvVars(array $variables, Project $project, int $teamId): void
{
foreach ($variables as $variable) {
$exists = SharedEnvironmentVariable::query()
->where('type', 'project')
->where('project_id', $project->id)
->where('key', data_get($variable, 'key'))
->exists();
if ($exists) {
continue;
}
SharedEnvironmentVariable::create([
'key' => data_get($variable, 'key'),
'value' => data_get($variable, 'value'),
'is_multiline' => (bool) data_get($variable, 'is_multiline', false),
'is_literal' => (bool) data_get($variable, 'is_literal', false),
'is_shown_once' => (bool) data_get($variable, 'is_shown_once', false),
'comment' => data_get($variable, 'comment'),
'type' => 'project',
'project_id' => $project->id,
'team_id' => $teamId,
]);
}
}
/**
* @param array<string, mixed> $payload
* @param array<string, int> $created
*/
private function importEnvironment(array $payload, Project $project, bool $preserveUuids, array &$created): Environment
{
$uuid = $preserveUuids && filled(data_get($payload, 'uuid'))
? (string) data_get($payload, 'uuid')
: new_public_id();
$byUuid = Environment::where('uuid', $uuid)->where('project_id', $project->id)->first();
if ($byUuid) {
$this->importEnvironmentSharedEnvVars(data_get($payload, 'shared_environment_variables', []), $byUuid, $project->team_id);
return $byUuid;
}
$name = (string) data_get($payload, 'name', 'production');
$byName = $project->environments()->where('name', $name)->first();
if ($byName) {
if ($preserveUuids && $uuid !== '' && ! Environment::where('uuid', $uuid)->exists()) {
$byName->uuid = $uuid;
$byName->save();
}
if (filled(data_get($payload, 'description'))) {
$byName->description = data_get($payload, 'description');
$byName->save();
}
$this->importEnvironmentSharedEnvVars(data_get($payload, 'shared_environment_variables', []), $byName, $project->team_id);
return $byName->fresh();
}
if (Environment::where('uuid', $uuid)->exists()) {
$uuid = new_public_id();
}
$environment = Environment::create([
'uuid' => $uuid,
'name' => $name,
'description' => data_get($payload, 'description'),
'project_id' => $project->id,
]);
$created['environments']++;
$this->importEnvironmentSharedEnvVars(data_get($payload, 'shared_environment_variables', []), $environment, $project->team_id);
return $environment;
}
/**
* @param list<array<string, mixed>> $variables
*/
private function importEnvironmentSharedEnvVars(array $variables, Environment $environment, int $teamId): void
{
foreach ($variables as $variable) {
$exists = SharedEnvironmentVariable::query()
->where('type', 'environment')
->where('environment_id', $environment->id)
->where('key', data_get($variable, 'key'))
->exists();
if ($exists) {
continue;
}
SharedEnvironmentVariable::create([
'key' => data_get($variable, 'key'),
'value' => data_get($variable, 'value'),
'is_multiline' => (bool) data_get($variable, 'is_multiline', false),
'is_literal' => (bool) data_get($variable, 'is_literal', false),
'is_shown_once' => (bool) data_get($variable, 'is_shown_once', false),
'comment' => data_get($variable, 'comment'),
'type' => 'environment',
'environment_id' => $environment->id,
'team_id' => $teamId,
]);
}
}
/**
* Canvas positions are keyed by resource uuid. Resources may get a new uuid on import.
*
* @param array<string, mixed> $positions
*/
private function importCanvasPositions(array $positions, Environment $environment): void
{
$imported = [];
foreach ($positions as $uuid => $position) {
$resource = $this->applicationMap[$uuid] ?? $this->databaseMap[$uuid] ?? $this->serviceMap[$uuid] ?? null;
if ($resource) {
$imported[$resource->uuid] = $position;
}
}
if ($imported === []) {
return;
}
$environment->canvas_positions = array_merge($environment->canvas_positions ?? [], $imported);
$environment->save();
}
/**
* Links between two imported resources of the environment. The user who drew the link is a user of the
* source instance, so created_by stays empty.
*
* @param list<array<string, mixed>> $links
*/
private function importResourceLinks(array $links, Environment $environment, bool $preserveUuids): void
{
foreach ($links as $link) {
$source = $this->linkedResource(data_get($link, 'source'));
$target = $this->linkedResource(data_get($link, 'target'));
if (! $source || ! $target || $source->is($target)
|| (int) $source->environment_id !== $environment->id || (int) $target->environment_id !== $environment->id) {
$this->importWarnings[] = "A link between resources on the canvas of environment {$environment->name} was not imported, because one of its resources was not imported.";
continue;
}
$attributes = [
'environment_id' => $environment->id,
'source_type' => $source->getMorphClass(),
'source_id' => $source->getKey(),
'target_type' => $target->getMorphClass(),
'target_id' => $target->getKey(),
];
if (ResourceLink::query()->where($attributes)->exists()) {
continue;
}
$label = data_get($link, 'label');
$resourceLink = new ResourceLink([
...$attributes,
'label' => is_string($label) && trim($label) !== '' ? mb_substr(trim($label), 0, 64) : null,
'created_by' => null,
]);
$uuid = (string) data_get($link, 'uuid', '');
if ($preserveUuids && $uuid !== '' && ! ResourceLink::where('uuid', $uuid)->exists()) {
$resourceLink->uuid = $uuid;
}
$resourceLink->save();
}
}
/**
* An end of a canvas link: the kind and the original uuid of an imported resource.
*/
private function linkedResource(mixed $end): ?Model
{
$uuid = (string) data_get($end, 'uuid', '');
return match (data_get($end, 'kind')) {
'application' => $this->applicationMap[$uuid] ?? null,
'database' => $this->databaseMap[$uuid] ?? null,
'service' => $this->serviceMap[$uuid] ?? null,
default => null,
};
}
/**
* @param array<string, mixed> $payload
* @param array<string, StandaloneDocker|SwarmDocker> $destinationMap
*/
private function importApplication(
array $payload,
Environment $environment,
array $destinationMap,
bool $preserveUuids,
bool $adoptMode,
int $teamId,
): Application {
$destination = $this->resolveDestination(data_get($payload, 'destination_uuid'), $destinationMap);
$uuid = $preserveUuids && filled(data_get($payload, 'uuid'))
? (string) data_get($payload, 'uuid')
: new_public_id();
$uuid = $this->resolveResourceUuid(Application::class, $uuid, $adoptMode, 'application');
$attributes = (array) data_get($payload, 'attributes', []);
unset($attributes['id'], $attributes['environment_id'], $attributes['destination_id'], $attributes['destination_type'], $attributes['source_id'], $attributes['source_type'], $attributes['private_key_id']);
$attributes = $this->onlyExistingColumns(Application::class, $attributes);
$attributes['uuid'] = $uuid;
$attributes['environment_id'] = $environment->id;
$attributes['destination_id'] = $destination->id;
$attributes['destination_type'] = $destination->getMorphClass();
$name = (string) data_get($attributes, 'name', $uuid);
// Re-link git source
$source = data_get($payload, 'source');
if (is_array($source) && filled(data_get($source, 'uuid'))) {
$sourceUuid = (string) data_get($source, 'uuid');
$sourceType = (string) data_get($source, 'type');
if ($sourceType === 'github_app') {
$gh = $this->githubAppMap[$sourceUuid]
?? GithubApp::query()->where('uuid', $sourceUuid)->where('is_system_wide', true)->first()
?? GithubApp::query()->where('uuid', $sourceUuid)->where('team_id', $teamId)->first();
if ($gh) {
$attributes['source_type'] = GithubApp::class;
$attributes['source_id'] = $gh->id;
}
} elseif ($sourceType === 'gitlab_app') {
$gl = $this->gitlabAppMap[$sourceUuid]
?? GitlabApp::query()->where('uuid', $sourceUuid)->where('is_system_wide', true)->first()
?? GitlabApp::query()->where('uuid', $sourceUuid)->where('team_id', $teamId)->first();
if ($gl) {
$attributes['source_type'] = GitlabApp::class;
$attributes['source_id'] = $gl->id;
}
}
}
$sourceMissing = data_get($payload, 'had_git_source') && ! isset($attributes['source_id']);
if ($sourceMissing || data_get($payload, 'source.system_wide') === true) {
$this->gitSourceApplications[] = $uuid;
}
if ($sourceMissing) {
$sourceLabel = match (data_get($payload, 'source.type')) {
'github_app' => 'GitHub App',
'gitlab_app' => 'GitLab App',
default => 'git source',
};
$this->importWarnings[] = "Application {$name}: its {$sourceLabel} was not found on this instance; set the source again.";
}
// Re-link deploy key
$appKeyUuid = data_get($payload, 'private_key_uuid');
if ($appKeyUuid && isset($this->privateKeyMap[$appKeyUuid])) {
$attributes['private_key_id'] = $this->privateKeyMap[$appKeyUuid]->id;
}
if (data_get($payload, 'had_private_key') && ! isset($attributes['private_key_id'])) {
$this->importWarnings[] = "Application {$name}: its private key was not found on this instance; select the private key again.";
}
if ($adoptMode) {
// Keep runtime status if provided so inventory can match, but default exited if empty.
$attributes['status'] = data_get($attributes, 'status') ?: 'exited';
} else {
$attributes['status'] = 'exited';
}
$application = new Application($attributes);
// Maintenance mode is not fillable.
$application->forceFill(collect($attributes)->only(['is_maintenance_enabled', 'maintenance_page'])->all());
$application->save();
$settings = (array) data_get($payload, 'settings', []);
if ($settings !== [] && $application->settings) {
$application->settings->fill(collect($settings)->except(['id', 'application_id'])->all());
$application->settings->save();
}
$this->importEnvVars(data_get($payload, 'environment_variables', []), $application, false);
$this->importEnvVars(data_get($payload, 'environment_variables_preview', []), $application, true);
$this->importPersistentStorages(data_get($payload, 'persistent_storages', []), $application);
$this->importFileStorages(data_get($payload, 'file_storages', []), $application);
$this->importScheduledTasks(data_get($payload, 'scheduled_tasks', []), $application, $teamId);
$this->importTags(data_get($payload, 'tags', []), $application, $teamId);
$this->importPreviews(data_get($payload, 'previews', []), $application, $preserveUuids, $adoptMode, $teamId);
$this->importSecretManagerLink(data_get($payload, 'secret_manager_link'), $application, "Application {$name}");
return $application;
}
/**
* @param array<string, mixed> $payload
* @param array<string, StandaloneDocker|SwarmDocker> $destinationMap
*/
private function importDatabase(
array $payload,
Environment $environment,
array $destinationMap,
bool $preserveUuids,
bool $adoptMode,
int $teamId = 0,
): ?Model {
$type = (string) data_get($payload, 'type', 'StandalonePostgresql');
$modelClass = self::DATABASE_MODELS[$type] ?? null;
if (! $modelClass) {
throw new RuntimeException("Unsupported database type: {$type}");
}
$destination = $this->resolveDestination(data_get($payload, 'destination_uuid'), $destinationMap);
$uuid = $preserveUuids && filled(data_get($payload, 'uuid'))
? (string) data_get($payload, 'uuid')
: new_public_id();
$uuid = $this->resolveResourceUuid($modelClass, $uuid, $adoptMode, 'database');
$attributes = (array) data_get($payload, 'attributes', []);
unset($attributes['id'], $attributes['environment_id'], $attributes['destination_id'], $attributes['destination_type']);
$attributes = $this->onlyExistingColumns($modelClass, $attributes);
$attributes['uuid'] = $uuid;
$attributes['environment_id'] = $environment->id;
$attributes['destination_id'] = $destination->id;
$attributes['destination_type'] = $destination->getMorphClass();
$attributes['status'] = $adoptMode
? (data_get($attributes, 'status') ?: 'exited')
: 'exited';
// Avoid auto-created default volumes; we restore exported ones.
$database = $modelClass::withoutEvents(function () use ($modelClass, $attributes, $uuid) {
$database = new $modelClass;
$database->forceFill(collect($attributes)->except(['uuid'])->all());
$database->uuid = $uuid;
$database->save();
return $database;
});
$this->importEnvVars(data_get($payload, 'environment_variables', []), $database, false);
$this->importPersistentStorages(data_get($payload, 'persistent_storages', []), $database);
$this->importFileStorages(data_get($payload, 'file_storages', []), $database);
if (method_exists($database, 'tags')) {
$this->importTags(data_get($payload, 'tags', []), $database, $teamId);
}
$this->importScheduledBackups(data_get($payload, 'scheduled_backups', []), $database, $teamId);
$this->importSecretManagerLink(data_get($payload, 'secret_manager_link'), $database, "Database {$database->name}");
return $database;
}
/**
* @param array<string, mixed> $payload
* @param array<string, StandaloneDocker|SwarmDocker> $destinationMap
*/
private function importService(
array $payload,
Environment $environment,
Server $server,
array $destinationMap,
bool $preserveUuids,
bool $adoptMode,
int $teamId = 0,
): Service {
$destination = $this->resolveDestination(data_get($payload, 'destination_uuid'), $destinationMap);
$uuid = $preserveUuids && filled(data_get($payload, 'uuid'))
? (string) data_get($payload, 'uuid')
: new_public_id();
$uuid = $this->resolveResourceUuid(Service::class, $uuid, $adoptMode, 'service');
$attributes = (array) data_get($payload, 'attributes', []);
unset($attributes['id'], $attributes['environment_id'], $attributes['destination_id'], $attributes['destination_type'], $attributes['server_id']);
$attributes = $this->onlyExistingColumns(Service::class, $attributes);
$attributes['uuid'] = $uuid;
$attributes['environment_id'] = $environment->id;
$attributes['server_id'] = $server->id;
$attributes['destination_id'] = $destination->id;
$attributes['destination_type'] = $destination->getMorphClass();
$service = new Service($attributes);
// Maintenance mode and the Coolify default variables switch are not fillable.
$service->forceFill(collect($attributes)->only(['is_maintenance_enabled', 'maintenance_page', 'include_coolify_default_variables'])->all());
$service->save();
$this->importEnvVars(data_get($payload, 'environment_variables', []), $service, false);
$this->importScheduledTasks(data_get($payload, 'scheduled_tasks', []), $service, $teamId);
$this->importTags(data_get($payload, 'tags', []), $service, $teamId);
$this->importSecretManagerLink(data_get($payload, 'secret_manager_link'), $service, "Service {$service->name}");
foreach (data_get($payload, 'applications', []) as $appPayload) {
$serviceApp = $this->importServiceApplication($appPayload, $service, $preserveUuids, $adoptMode);
$this->serviceApplicationMap[(string) data_get($appPayload, 'uuid', $serviceApp->uuid)] = $serviceApp;
}
foreach (data_get($payload, 'databases', []) as $dbPayload) {
$this->importServiceDatabase($dbPayload, $service, $preserveUuids, $adoptMode, $teamId);
}
return $service;
}
/**
* @param array<string, mixed> $appPayload
*/
private function importServiceApplication(array $appPayload, Service $service, bool $preserveUuids, bool $adoptMode): ServiceApplication
{
$appUuid = $preserveUuids && filled(data_get($appPayload, 'uuid'))
? (string) data_get($appPayload, 'uuid')
: new_public_id();
if (ServiceApplication::where('uuid', $appUuid)->exists()) {
$appUuid = new_public_id();
}
$attributes = (array) data_get($appPayload, 'attributes', []);
if ($attributes === []) {
// Back-compat with older thin service-application payloads.
$attributes = collect($appPayload)->except([
'uuid', 'environment_variables', 'persistent_storages', 'file_storages', 'attributes',
])->all();
}
unset($attributes['id'], $attributes['service_id'], $attributes['created_at'], $attributes['updated_at'], $attributes['deleted_at']);
$attributes = $this->onlyExistingColumns(ServiceApplication::class, $attributes);
$attributes['service_id'] = $service->id;
$attributes['name'] = data_get($attributes, 'name') ?: data_get($appPayload, 'name') ?: 'service-app';
$attributes['status'] = $adoptMode
? (data_get($attributes, 'status') ?: data_get($appPayload, 'status') ?: 'running:unknown')
: 'exited:unhealthy';
$serviceApp = new ServiceApplication;
$serviceApp->forceFill($attributes);
$serviceApp->uuid = $appUuid;
$serviceApp->save();
$this->importEnvVars(data_get($appPayload, 'environment_variables', []), $serviceApp, false);
$this->importPersistentStorages(data_get($appPayload, 'persistent_storages', []), $serviceApp);
$this->importFileStorages(data_get($appPayload, 'file_storages', []), $serviceApp);
return $serviceApp;
}
/**
* @param array<string, mixed> $dbPayload
*/
private function importServiceDatabase(array $dbPayload, Service $service, bool $preserveUuids, bool $adoptMode, int $teamId): ServiceDatabase
{
$dbUuid = $preserveUuids && filled(data_get($dbPayload, 'uuid'))
? (string) data_get($dbPayload, 'uuid')
: new_public_id();
if (ServiceDatabase::where('uuid', $dbUuid)->exists()) {
$dbUuid = new_public_id();
}
$attributes = (array) data_get($dbPayload, 'attributes', []);
if ($attributes === []) {
$attributes = collect($dbPayload)->except([
'uuid', 'environment_variables', 'persistent_storages', 'file_storages', 'scheduled_backups', 'attributes',
])->all();
}
unset($attributes['id'], $attributes['service_id'], $attributes['created_at'], $attributes['updated_at'], $attributes['deleted_at']);
$attributes = $this->onlyExistingColumns(ServiceDatabase::class, $attributes);
$attributes['service_id'] = $service->id;
$attributes['name'] = data_get($attributes, 'name') ?: data_get($dbPayload, 'name') ?: 'service-db';
$attributes['status'] = $adoptMode
? (data_get($attributes, 'status') ?: data_get($dbPayload, 'status') ?: 'running:unknown')
: 'exited:unhealthy';
$serviceDb = new ServiceDatabase;
$serviceDb->forceFill($attributes);
$serviceDb->uuid = $dbUuid;
$serviceDb->save();
$this->importPersistentStorages(data_get($dbPayload, 'persistent_storages', []), $serviceDb);
$this->importFileStorages(data_get($dbPayload, 'file_storages', []), $serviceDb);
$this->importScheduledBackups(data_get($dbPayload, 'scheduled_backups', []), $serviceDb, $teamId);
return $serviceDb;
}
/**
* @param list<array<string, mixed>> $variables
*/
private function importEnvVars(array $variables, object $resource, bool $isPreview): void
{
foreach ($variables as $variable) {
EnvironmentVariable::withoutEvents(function () use ($variable, $resource, $isPreview) {
$uuid = filled(data_get($variable, 'uuid')) ? (string) data_get($variable, 'uuid') : new_public_id();
if (EnvironmentVariable::where('uuid', $uuid)->exists()) {
$uuid = new_public_id();
}
$env = new EnvironmentVariable;
$env->forceFill([
'key' => data_get($variable, 'key'),
'value' => data_get($variable, 'value'),
'is_literal' => (bool) data_get($variable, 'is_literal', false),
'is_multiline' => (bool) data_get($variable, 'is_multiline', false),
'is_preview' => $isPreview || (bool) data_get($variable, 'is_preview', false),
'is_runtime' => (bool) data_get($variable, 'is_runtime', true),
'is_buildtime' => (bool) data_get($variable, 'is_buildtime', true),
'is_shown_once' => (bool) data_get($variable, 'is_shown_once', false),
'is_shared' => (bool) data_get($variable, 'is_shared', false),
'is_required' => (bool) data_get($variable, 'is_required', false),
// Exports from older versions have no flag; their values use the old escaping.
'uses_legacy_escaping' => (bool) data_get($variable, 'uses_legacy_escaping', true),
'preserve_compose_null' => (bool) data_get($variable, 'preserve_compose_null', false),
'comment' => data_get($variable, 'comment'),
'order' => data_get($variable, 'order'),
'resourceable_type' => $resource->getMorphClass(),
'resourceable_id' => $resource->id,
]);
// Older bundles have no version; the column default applies.
if (filled(data_get($variable, 'version'))) {
$env->version = (string) data_get($variable, 'version');
}
$env->uuid = $uuid;
$env->save();
});
}
}
/**
* @param list<array<string, mixed>> $volumes
*/
private function importPersistentStorages(array $volumes, object $resource): void
{
foreach ($volumes as $volume) {
$uuid = filled(data_get($volume, 'uuid')) ? (string) data_get($volume, 'uuid') : new_public_id();
if (LocalPersistentVolume::where('uuid', $uuid)->exists()) {
$uuid = new_public_id();
}
$volumeModel = new LocalPersistentVolume;
$volumeModel->forceFill([
'name' => data_get($volume, 'name'),
'mount_path' => data_get($volume, 'mount_path'),
'host_path' => data_get($volume, 'host_path'),
'is_preview_suffix_enabled' => (bool) data_get($volume, 'is_preview_suffix_enabled', false),
// The server keeps its Docker volumes. A bundle without the flag is from a Coolify version that created them without driver options.
'ignores_compose_driver_options' => (bool) data_get($volume, 'ignores_compose_driver_options', true),
'resource_type' => $resource->getMorphClass(),
'resource_id' => $resource->id,
]);
$volumeModel->uuid = $uuid;
$volumeModel->save();
$this->volumeMap[$uuid] = $volumeModel;
if (filled(data_get($volume, 'uuid'))) {
$this->volumeMap[(string) data_get($volume, 'uuid')] = $volumeModel;
}
if (filled(data_get($volume, 'standalone_sqlite_uuid'))) {
$this->pendingSqliteLinks[$volumeModel->id] = [
'volume' => $volumeModel,
'sqlite_uuid' => (string) data_get($volume, 'standalone_sqlite_uuid'),
];
}
}
}
/**
* Connect imported volumes to the imported SQLite database whose data volume they mount.
*
* @return list<string> warnings for volumes whose database is not in the bundle
*/
private function linkSqliteVolumes(): array
{
$warnings = [];
foreach ($this->pendingSqliteLinks as $link) {
$sqlite = $this->databaseMap[$link['sqlite_uuid']] ?? null;
if (! $sqlite instanceof StandaloneSqlite) {
$warnings[] = "Volume {$link['volume']->name} mounts a SQLite database that is not part of this transfer. Coolify does not protect it when that database or its applications are deleted.";
continue;
}
$link['volume']->forceFill(['standalone_sqlite_id' => $sqlite->id])->save();
}
return $warnings;
}
/**
* @param list<array<string, mixed>> $storages
*/
private function importFileStorages(array $storages, object $resource): void
{
foreach ($storages as $storage) {
LocalFileVolume::withoutEvents(function () use ($storage, $resource) {
$isHostFile = (bool) data_get($storage, 'is_host_file', false);
$fsPath = data_get($storage, 'fs_path');
if (! is_string($fsPath)) {
throw new RuntimeException('Invalid imported file storage path.');
}
if ($isHostFile) {
$fsPath = validateHostFileMountPath($fsPath, 'imported host file source path');
}
$chown = data_get($storage, 'chown');
$chmod = data_get($storage, 'chmod');
if (filled($chown) && (! is_string($chown) || ! preg_match('/\A(?:[A-Za-z_][A-Za-z0-9_.-]*|[0-9]+)(?::(?:[A-Za-z_][A-Za-z0-9_.-]*|[0-9]+))?\z/', $chown))) {
throw new RuntimeException('Invalid imported file owner.');
}
if (filled($chmod) && (! is_string($chmod) || ! preg_match('/\A[0-7]{3,4}\z/', $chmod))) {
throw new RuntimeException('Invalid imported file mode.');
}
$uuid = filled(data_get($storage, 'uuid')) ? (string) data_get($storage, 'uuid') : new_public_id();
if (LocalFileVolume::where('uuid', $uuid)->exists()) {
$uuid = new_public_id();
}
// uuid is not fillable and withoutEvents skips BaseModel's creating hook.
$file = new LocalFileVolume;
$file->forceFill([
'fs_path' => $fsPath,
'mount_path' => data_get($storage, 'mount_path'),
'content' => data_get($storage, 'content'),
'is_directory' => (bool) data_get($storage, 'is_directory', false),
'is_host_file' => $isHostFile,
'chown' => $chown,
'chmod' => $chmod,
'is_based_on_git' => (bool) data_get($storage, 'is_based_on_git', false),
'is_preview_suffix_enabled' => (bool) data_get($storage, 'is_preview_suffix_enabled', false),
'resource_type' => $resource->getMorphClass(),
'resource_id' => $resource->id,
]);
$file->uuid = $uuid;
$file->save();
$this->volumeMap[$uuid] = $file;
if (filled(data_get($storage, 'uuid'))) {
$this->volumeMap[(string) data_get($storage, 'uuid')] = $file;
}
});
}
}
/**
* @param list<array<string, mixed>> $tasks
*/
private function importScheduledTasks(array $tasks, Application|Service $resource, int $teamId): void
{
foreach ($tasks as $task) {
$uuid = filled(data_get($task, 'uuid')) ? (string) data_get($task, 'uuid') : new_public_id();
if (ScheduledTask::where('uuid', $uuid)->exists()) {
$uuid = new_public_id();
}
$payload = [
'uuid' => $uuid,
'name' => data_get($task, 'name'),
'command' => data_get($task, 'command'),
'frequency' => data_get($task, 'frequency'),
'container' => data_get($task, 'container'),
'timeout' => data_get($task, 'timeout'),
'enabled' => (bool) data_get($task, 'enabled', true),
'team_id' => $teamId,
];
if ($resource instanceof Application) {
$payload['application_id'] = $resource->id;
} else {
$payload['service_id'] = $resource->id;
}
ScheduledTask::create($payload);
}
}
/**
* @param list<array{uuid?: string, name?: string}> $tags
*/
private function importTags(array $tags, Model $resource, int $teamId): void
{
if ($tags === [] || ! method_exists($resource, 'tags')) {
return;
}
foreach ($tags as $tagPayload) {
$name = strtolower(trim((string) data_get($tagPayload, 'name', '')));
if ($name === '') {
continue;
}
$uuid = filled(data_get($tagPayload, 'uuid')) ? (string) data_get($tagPayload, 'uuid') : new_public_id();
$tag = Tag::query()
->where('team_id', $teamId)
->where('name', $name)
->first();
if (! $tag) {
// Prefer preserved uuid when free; otherwise create with a new one.
if (Tag::where('uuid', $uuid)->exists()) {
$uuid = new_public_id();
}
$tag = new Tag;
$tag->forceFill([
'name' => $name,
'team_id' => $teamId,
]);
$tag->uuid = $uuid;
$tag->save();
}
$resource->tags()->syncWithoutDetaching([$tag->id]);
}
}
/**
* @param list<array<string, mixed>> $backups
*/
private function importScheduledBackups(array $backups, Model $database, int $teamId): void
{
foreach ($backups as $backup) {
$uuid = filled(data_get($backup, 'uuid')) ? (string) data_get($backup, 'uuid') : new_public_id();
if (ScheduledDatabaseBackup::where('uuid', $uuid)->exists()) {
$uuid = new_public_id();
}
$s3Ids = $this->importedBackupS3StorageIds($backup);
$s3Id = $s3Ids[0] ?? null;
$this->warnAboutMissingS3Storage($backup, $s3Id, "database {$database->name}");
$importedBackup = ScheduledDatabaseBackup::create([
'uuid' => $uuid,
'team_id' => $teamId,
'description' => data_get($backup, 'description'),
'enabled' => (bool) data_get($backup, 'enabled', true),
'save_s3' => (bool) data_get($backup, 'save_s3', false) && $s3Id !== null,
'frequency' => data_get($backup, 'frequency'),
'databases_to_backup' => data_get($backup, 'databases_to_backup'),
'dump_all' => (bool) data_get($backup, 'dump_all', false),
'database_backup_retention_amount_locally' => data_get($backup, 'database_backup_retention_amount_locally', 0),
'database_backup_retention_days_locally' => data_get($backup, 'database_backup_retention_days_locally'),
'database_backup_retention_max_storage_locally' => data_get($backup, 'database_backup_retention_max_storage_locally'),
'database_backup_retention_amount_s3' => data_get($backup, 'database_backup_retention_amount_s3'),
'database_backup_retention_days_s3' => data_get($backup, 'database_backup_retention_days_s3'),
'database_backup_retention_max_storage_s3' => data_get($backup, 'database_backup_retention_max_storage_s3'),
'timeout' => data_get($backup, 'timeout'),
'disable_local_backup' => (bool) data_get($backup, 'disable_local_backup', false),
'missing_backup_notification_days' => (int) data_get($backup, 'missing_backup_notification_days', 0),
's3_storage_id' => $s3Id,
'database_type' => $database->getMorphClass(),
'database_id' => $database->id,
]);
$importedBackup->syncS3Storages($s3Ids);
}
}
/**
* @param array<string, mixed> $backup
*/
private function warnAboutMissingS3Storage(array $backup, ?int $s3Id, string $backupOf): void
{
if (! data_get($backup, 'had_s3_storage') || $s3Id !== null) {
return;
}
$this->importWarnings[] = data_get($backup, 'save_s3')
? "Backup of {$backupOf}: S3 upload was turned off because its S3 storage was not found."
: "Backup of {$backupOf}: its S3 storage was not found; select it again.";
}
/**
* Maps the exported S3 destinations of a backup to imported storages, primary first. Older bundles only have
* `s3_storage_uuid`.
*
* @param array<string, mixed> $backup
* @return list<int>
*/
private function importedBackupS3StorageIds(array $backup): array
{
$uuids = collect(data_get($backup, 's3_storage_uuids', []))
->prepend(data_get($backup, 's3_storage_uuid'))
->filter(fn ($uuid): bool => is_string($uuid) && isset($this->s3StorageMap[$uuid]));
return $uuids->map(fn (string $uuid): int => $this->s3StorageMap[$uuid]->id)->unique()->values()->all();
}
/**
* @param array<string, mixed> $payload
*/
private function importGithubApp(array $payload, int $teamId, bool $preserveUuids): GithubApp
{
$uuid = $preserveUuids && filled(data_get($payload, 'uuid'))
? (string) data_get($payload, 'uuid')
: new_public_id();
$existing = GithubApp::where('uuid', $uuid)->where('team_id', $teamId)->first();
if ($existing) {
return $existing;
}
if (GithubApp::where('uuid', $uuid)->exists()) {
$uuid = new_public_id();
}
$privateKeyId = null;
$keyUuid = data_get($payload, 'private_key_uuid');
if ($keyUuid && isset($this->privateKeyMap[$keyUuid])) {
$privateKeyId = $this->privateKeyMap[$keyUuid]->id;
}
$app = new GithubApp;
$app->forceFill([
'team_id' => $teamId,
'private_key_id' => $privateKeyId,
'name' => data_get($payload, 'name') ?: 'Imported GitHub App',
'organization' => data_get($payload, 'organization'),
'api_url' => data_get($payload, 'api_url') ?: 'https://api.github.com',
'html_url' => data_get($payload, 'html_url') ?: 'https://github.com',
'custom_user' => data_get($payload, 'custom_user'),
'custom_port' => data_get($payload, 'custom_port'),
'app_id' => data_get($payload, 'app_id'),
'installation_id' => data_get($payload, 'installation_id'),
'client_id' => data_get($payload, 'client_id'),
'client_secret' => data_get($payload, 'client_secret'),
'webhook_secret' => data_get($payload, 'webhook_secret'),
'is_system_wide' => false,
'is_public' => (bool) data_get($payload, 'is_public', false),
'contents' => data_get($payload, 'contents'),
'metadata' => data_get($payload, 'metadata'),
'pull_requests' => data_get($payload, 'pull_requests'),
'administration' => data_get($payload, 'administration'),
'organization_self_hosted_runners' => data_get($payload, 'organization_self_hosted_runners'),
'actions' => data_get($payload, 'actions'),
'webhook_events' => data_get($payload, 'webhook_events'),
'runner_group_id' => data_get($payload, 'runner_group_id'),
]);
$app->uuid = $uuid;
$app->save();
return $app;
}
/**
* @param array<string, mixed> $payload
*/
private function importGitlabApp(array $payload, int $teamId, bool $preserveUuids): GitlabApp
{
$uuid = $preserveUuids && filled(data_get($payload, 'uuid'))
? (string) data_get($payload, 'uuid')
: new_public_id();
$existing = GitlabApp::where('uuid', $uuid)->where('team_id', $teamId)->first();
if ($existing) {
return $existing;
}
if (GitlabApp::where('uuid', $uuid)->exists()) {
$uuid = new_public_id();
}
$privateKeyId = null;
$keyUuid = data_get($payload, 'private_key_uuid');
if ($keyUuid && isset($this->privateKeyMap[$keyUuid])) {
$privateKeyId = $this->privateKeyMap[$keyUuid]->id;
}
$app = new GitlabApp;
$app->forceFill([
'team_id' => $teamId,
'private_key_id' => $privateKeyId,
'name' => data_get($payload, 'name') ?: 'Imported GitLab App',
'organization' => data_get($payload, 'organization'),
'api_url' => data_get($payload, 'api_url'),
'html_url' => data_get($payload, 'html_url'),
'custom_port' => data_get($payload, 'custom_port'),
'custom_user' => data_get($payload, 'custom_user'),
'is_system_wide' => false,
'is_public' => (bool) data_get($payload, 'is_public', false),
'app_id' => data_get($payload, 'app_id'),
'app_secret' => data_get($payload, 'app_secret'),
'oauth_id' => data_get($payload, 'oauth_id'),
'client_id' => data_get($payload, 'client_id'),
'client_secret' => data_get($payload, 'client_secret'),
'access_token' => data_get($payload, 'access_token'),
'refresh_token' => data_get($payload, 'refresh_token'),
'expires_at' => data_get($payload, 'expires_at'),
'redirect_uri' => data_get($payload, 'redirect_uri'),
'group_name' => data_get($payload, 'group_name'),
'public_key' => data_get($payload, 'public_key'),
'webhook_token' => data_get($payload, 'webhook_token'),
'deploy_key_id' => data_get($payload, 'deploy_key_id'),
]);
$app->uuid = $uuid;
$app->save();
return $app;
}
/**
* @param array<string, mixed> $payload
*/
private function importS3Storage(array $payload, int $teamId, bool $preserveUuids): S3Storage
{
$uuid = $preserveUuids && filled(data_get($payload, 'uuid'))
? (string) data_get($payload, 'uuid')
: new_public_id();
$existing = S3Storage::where('uuid', $uuid)->where('team_id', $teamId)->first();
if ($existing) {
return $existing;
}
// Reuse by endpoint+bucket+key if already present for team
$byIdentity = S3Storage::query()
->where('team_id', $teamId)
->where('endpoint', data_get($payload, 'endpoint'))
->where('bucket', data_get($payload, 'bucket'))
->first();
if ($byIdentity) {
return $byIdentity;
}
if (S3Storage::where('uuid', $uuid)->exists()) {
$uuid = new_public_id();
}
$storage = new S3Storage;
$storage->forceFill([
'team_id' => $teamId,
'name' => data_get($payload, 'name') ?: 'Imported S3',
'description' => data_get($payload, 'description'),
'region' => data_get($payload, 'region'),
'key' => data_get($payload, 'key'),
'secret' => data_get($payload, 'secret'),
'bucket' => data_get($payload, 'bucket'),
'endpoint' => data_get($payload, 'endpoint'),
'is_usable' => (bool) data_get($payload, 'is_usable', true),
]);
$storage->uuid = $uuid;
$storage->save();
return $storage;
}
/**
* @param array<string, mixed> $payload
*/
private function importCloudProviderToken(array $payload, int $teamId, bool $preserveUuids): CloudProviderToken
{
$uuid = $preserveUuids && filled(data_get($payload, 'uuid'))
? (string) data_get($payload, 'uuid')
: new_public_id();
$existing = CloudProviderToken::where('uuid', $uuid)->where('team_id', $teamId)->first();
if ($existing) {
return $existing;
}
if (CloudProviderToken::where('uuid', $uuid)->exists()) {
$uuid = new_public_id();
}
$token = new CloudProviderToken;
$token->forceFill([
'team_id' => $teamId,
'provider' => data_get($payload, 'provider'),
'token' => data_get($payload, 'token'),
'name' => data_get($payload, 'name') ?: 'Imported cloud token',
'description' => data_get($payload, 'description'),
]);
$token->uuid = $uuid;
$token->save();
return $token;
}
/**
* A token with the same UUID in the target team is reused. A UUID that another team uses gets a new UUID.
*
* @param array<string, mixed> $payload
*/
private function importIntegrationToken(array $payload, int $teamId, bool $preserveUuids): IntegrationToken
{
$uuid = $preserveUuids && filled(data_get($payload, 'uuid'))
? (string) data_get($payload, 'uuid')
: new_public_id();
$existing = IntegrationToken::where('uuid', $uuid)->where('team_id', $teamId)->first();
if ($existing) {
return $existing;
}
if (IntegrationToken::where('uuid', $uuid)->exists()) {
$uuid = new_public_id();
}
$token = new IntegrationToken;
$token->forceFill([
'team_id' => $teamId,
'provider' => (string) data_get($payload, 'provider'),
'name' => data_get($payload, 'name') ?: 'Imported integration token',
'token' => (string) data_get($payload, 'token'),
'capabilities' => (array) data_get($payload, 'capabilities', []),
'metadata' => data_get($payload, 'metadata'),
]);
$token->uuid = $uuid;
$token->save();
return $token;
}
/**
* The ID of the imported token, or null with a warning when the bundle names a token that it does not contain.
*/
private function importedIntegrationTokenId(mixed $tokenUuid, string $missingWarning): ?int
{
if (! is_string($tokenUuid) || $tokenUuid === '') {
return null;
}
if (! isset($this->integrationTokenMap[$tokenUuid])) {
$this->importWarnings[] = $missingWarning;
return null;
}
return $this->integrationTokenMap[$tokenUuid]->id;
}
private function importSecretManagerLink(mixed $payload, Model $resource, string $label): void
{
if (! is_array($payload) || ! method_exists($resource, 'secretManagerLink')) {
return;
}
$tokenId = $this->importedIntegrationTokenId(
data_get($payload, 'integration_token_uuid'),
"{$label}: its secret manager token was not found on this instance; link the secret manager source again.",
);
if ($tokenId === null) {
return;
}
$uuid = filled(data_get($payload, 'uuid')) ? (string) data_get($payload, 'uuid') : new_public_id();
if (SecretManagerLink::where('uuid', $uuid)->exists()) {
$uuid = new_public_id();
}
$link = new SecretManagerLink;
$link->forceFill([
'resourceable_type' => $resource->getMorphClass(),
'resourceable_id' => $resource->getKey(),
'integration_token_id' => $tokenId,
'settings' => data_get($payload, 'settings'),
]);
$link->uuid = $uuid;
$link->save();
}
/**
* A zone of the same token and provider zone that the target team already has is reused.
*
* @param list<array<string, mixed>> $zones
*/
private function importDnsZones(array $zones, bool $preserveUuids): void
{
foreach ($zones as $payload) {
$token = $this->integrationTokenMap[(string) data_get($payload, 'integration_token_uuid')] ?? null;
if (! $token) {
continue;
}
$zone = DnsProviderZone::query()
->where('integration_token_id', $token->id)
->where('provider_zone_id', (string) data_get($payload, 'provider_zone_id'))
->first();
if (! $zone) {
$uuid = $preserveUuids && filled(data_get($payload, 'uuid')) ? (string) data_get($payload, 'uuid') : new_public_id();
if (DnsProviderZone::where('uuid', $uuid)->exists()) {
$uuid = new_public_id();
}
$zone = new DnsProviderZone;
$zone->forceFill([
'integration_token_id' => $token->id,
'provider_zone_id' => (string) data_get($payload, 'provider_zone_id'),
'name' => strtolower((string) data_get($payload, 'name')),
'account_id' => data_get($payload, 'account_id'),
'account_name' => data_get($payload, 'account_name'),
]);
$zone->uuid = $uuid;
$zone->save();
}
$this->dnsZoneMap[(string) data_get($payload, 'uuid', $zone->uuid)] = $zone;
}
}
/**
* Imports the managed DNS records and links them to the imported resources. An owned record keeps the ownership
* comment of the source at the provider until adoptManagedDnsRecords() replaces it.
*
* @param list<array<string, mixed>> $records
*/
private function importManagedDnsRecords(array $records, int $teamId, bool $preserveUuids): int
{
$count = 0;
foreach ($records as $payload) {
$name = (string) data_get($payload, 'name');
$zone = $this->dnsZoneMap[(string) data_get($payload, 'dns_provider_zone_uuid')] ?? null;
$resources = collect(data_get($payload, 'references', []))
->map(fn ($reference) => match (data_get($reference, 'kind')) {
'application' => $this->applicationMap[(string) data_get($reference, 'uuid')] ?? null,
'preview' => $this->previewMap[(string) data_get($reference, 'uuid')] ?? null,
'service_application' => $this->serviceApplicationMap[(string) data_get($reference, 'uuid')] ?? null,
default => null,
})
->filter()
->values();
if (! $zone || $resources->isEmpty()) {
$this->importWarnings[] = "DNS record {$name}: its DNS zone or resources were not imported, so this instance does not manage it.";
continue;
}
$record = ManagedDnsRecord::query()
->where('dns_provider_zone_id', $zone->id)
->where('provider_record_id', (string) data_get($payload, 'provider_record_id'))
->first();
if (! $record) {
$uuid = $preserveUuids && filled(data_get($payload, 'uuid')) ? (string) data_get($payload, 'uuid') : new_public_id();
if (ManagedDnsRecord::where('uuid', $uuid)->exists()) {
$uuid = new_public_id();
}
$previousComment = data_get($payload, 'ownership_comment');
// Without the source comment there is no proof of ownership at the provider.
$owned = (bool) data_get($payload, 'owned', false) && is_string($previousComment) && $previousComment !== '';
$record = ManagedDnsRecord::query()->create([
'uuid' => $uuid,
'team_id' => $teamId,
'integration_token_id' => $zone->integration_token_id,
'dns_provider_zone_id' => $zone->id,
'provider_record_id' => (string) data_get($payload, 'provider_record_id'),
'type' => (string) data_get($payload, 'type'),
'name' => $name,
'content' => (string) data_get($payload, 'content'),
'owned' => $owned,
]);
if ($owned && $previousComment !== $record->ownershipComment()) {
$this->pendingDnsAdoptions[] = ['record_id' => $record->id, 'previous_comment' => $previousComment];
}
$count++;
}
foreach ($resources as $resource) {
$record->addReference($resource);
}
}
return $count;
}
/**
* Replaces the ownership comment of the source instance with the comment of this instance at the provider.
* A record that cannot be adopted is no longer owned, so this instance never changes or deletes it.
*
* @return list<string> warnings
*/
private function adoptManagedDnsRecords(): array
{
$warnings = [];
foreach ($this->pendingDnsAdoptions as $adoption) {
$record = ManagedDnsRecord::query()->find($adoption['record_id']);
if (! $record) {
continue;
}
try {
$adopted = app(CloudflareDnsProvider::class)->adoptRecord($record, $adoption['previous_comment']);
} catch (Throwable) {
$adopted = false;
}
if ($adopted) {
continue;
}
$record->forceFill(['owned' => false])->save();
$warnings[] = "DNS record {$record->name}: this instance could not take ownership; it will not change or delete it.";
}
$this->pendingDnsAdoptions = [];
return $warnings;
}
/**
* @param list<array<string, mixed>> $previews
*/
private function importPreviews(array $previews, Application $application, bool $preserveUuids, bool $adoptMode, int $teamId): void
{
foreach ($previews as $previewPayload) {
$uuid = $preserveUuids && filled(data_get($previewPayload, 'uuid'))
? (string) data_get($previewPayload, 'uuid')
: new_public_id();
$preview = ApplicationPreview::withTrashed()->where('uuid', $uuid)->first();
if ($preview && ! $this->previewBelongsToTeam($preview, $teamId)) {
throw ValidationException::withMessages([
'previews' => ['An imported preview UUID conflicts with a preview outside the target team.'],
]);
}
if (! $preview) {
$pullRequestId = data_get($previewPayload, 'pull_request_id');
if ($pullRequestId !== null) {
$preview = ApplicationPreview::withTrashed()
->where('application_id', $application->id)
->where('pull_request_id', $pullRequestId)
->first();
}
}
if ($preview && ApplicationPreview::withTrashed()->where('uuid', $uuid)->where('id', '!=', $preview->id)->exists()) {
$uuid = new_public_id();
} elseif (! $preview && ApplicationPreview::withTrashed()->where('uuid', $uuid)->exists()) {
$uuid = new_public_id();
}
$fqdn = data_get($previewPayload, 'fqdn');
// FQDN is globally unique; only release conflicts owned by the target team.
if (filled($fqdn)) {
$conflicts = ApplicationPreview::withTrashed()
->where('fqdn', $fqdn)
->when($preview, fn ($q) => $q->where('id', '!=', $preview->id))
->get();
foreach ($conflicts as $conflict) {
if (! $this->previewBelongsToTeam($conflict, $teamId)) {
throw ValidationException::withMessages([
'previews' => ['An imported preview domain conflicts with a preview outside the target team.'],
]);
}
}
ApplicationPreview::withoutEvents(function () use ($conflicts) {
$conflicts->each(function (ApplicationPreview $conflict) {
$conflict->fqdn = null;
$conflict->saveQuietly();
});
});
}
$status = $adoptMode
? (data_get($previewPayload, 'status') ?: 'exited')
: 'exited';
$attributes = [
'application_id' => $application->id,
'pull_request_id' => data_get($previewPayload, 'pull_request_id'),
'pull_request_html_url' => data_get($previewPayload, 'pull_request_html_url') ?: '',
'pull_request_issue_comment_id' => data_get($previewPayload, 'pull_request_issue_comment_id'),
'fqdn' => $fqdn,
'status' => $status,
'git_type' => data_get($previewPayload, 'git_type'),
'docker_compose_domains' => data_get($previewPayload, 'docker_compose_domains'),
'docker_registry_image_tag' => data_get($previewPayload, 'docker_registry_image_tag'),
'domain_port_overrides' => data_get($previewPayload, 'domain_port_overrides'),
'max_restart_count' => (int) data_get($previewPayload, 'max_restart_count', 0),
];
if ($preview) {
if ($preview->trashed()) {
$preview->restore();
}
$preview->forceFill($attributes);
if ($preserveUuids && filled(data_get($previewPayload, 'uuid')) && $preview->uuid !== $uuid) {
if (! ApplicationPreview::withTrashed()->where('uuid', $uuid)->where('id', '!=', $preview->id)->exists()) {
$preview->uuid = $uuid;
}
}
$preview->save();
} else {
$preview = new ApplicationPreview;
$preview->forceFill($attributes);
$preview->uuid = $uuid;
$preview->save();
}
$this->previewMap[(string) data_get($previewPayload, 'uuid', $preview->uuid)] = $preview;
// Avoid duplicating volumes when re-importing the same preview.
if ($preview->wasRecentlyCreated || $preview->persistentStorages()->count() === 0) {
$this->importPersistentStorages(data_get($previewPayload, 'persistent_storages', []), $preview);
}
}
}
private function previewBelongsToTeam(ApplicationPreview $preview, int $teamId): bool
{
return Application::withTrashed()
->whereKey($preview->application_id)
->whereRelation('environment.project', 'team_id', $teamId)
->exists();
}
/**
* @param list<array<string, mixed>> $certificates
*/
private function importSslCertificates(array $certificates, Server $server): int
{
$count = 0;
foreach ($certificates as $certPayload) {
$resourceType = null;
$resourceId = null;
$kind = data_get($certPayload, 'resource_kind');
$resourceUuid = data_get($certPayload, 'resource_uuid');
if ($kind === 'application' && $resourceUuid && isset($this->applicationMap[$resourceUuid])) {
$resourceType = $this->applicationMap[$resourceUuid]->getMorphClass();
$resourceId = $this->applicationMap[$resourceUuid]->id;
} elseif ($kind === 'service' && $resourceUuid && isset($this->serviceMap[$resourceUuid])) {
$resourceType = $this->serviceMap[$resourceUuid]->getMorphClass();
$resourceId = $this->serviceMap[$resourceUuid]->id;
} elseif ($resourceUuid && isset($this->databaseMap[$resourceUuid])) {
$db = $this->databaseMap[$resourceUuid];
$resourceType = $db->getMorphClass();
$resourceId = $db->id;
}
SslCertificate::create([
'ssl_certificate' => data_get($certPayload, 'ssl_certificate'),
'ssl_private_key' => data_get($certPayload, 'ssl_private_key'),
'configuration_dir' => data_get($certPayload, 'configuration_dir'),
'mount_path' => data_get($certPayload, 'mount_path'),
'common_name' => data_get($certPayload, 'common_name') ?: 'imported',
'subject_alternative_names' => data_get($certPayload, 'subject_alternative_names'),
'valid_until' => data_get($certPayload, 'valid_until') ?: now()->addYear(),
'is_ca_certificate' => (bool) data_get($certPayload, 'is_ca_certificate', false),
'server_id' => $server->id,
'resource_type' => $resourceType,
'resource_id' => $resourceId,
]);
$count++;
}
return $count;
}
/**
* @param list<array<string, mixed>> $backups
*/
private function importVolumeBackups(array $backups, int $teamId): int
{
$count = 0;
foreach ($backups as $backupPayload) {
$volumeUuid = (string) data_get($backupPayload, 'backupable_uuid', '');
$volume = $this->volumeMap[$volumeUuid] ?? null;
if (! $volume) {
continue;
}
$uuid = filled(data_get($backupPayload, 'uuid'))
? (string) data_get($backupPayload, 'uuid')
: new_public_id();
if (ScheduledVolumeBackup::where('uuid', $uuid)->exists()) {
$uuid = new_public_id();
}
$s3Ids = $this->importedBackupS3StorageIds($backupPayload);
$s3Id = $s3Ids[0] ?? null;
$this->warnAboutMissingS3Storage($backupPayload, $s3Id, 'volume '.($volume->name ?? $volume->mount_path));
$importedBackup = ScheduledVolumeBackup::create([
'uuid' => $uuid,
'backupable_type' => $volume->getMorphClass(),
'backupable_id' => $volume->id,
'team_id' => $teamId,
's3_storage_id' => $s3Id,
'frequency' => data_get($backupPayload, 'frequency'),
'enabled' => (bool) data_get($backupPayload, 'enabled', true),
'save_s3' => (bool) data_get($backupPayload, 'save_s3', false) && $s3Id !== null,
'disable_local_backup' => (bool) data_get($backupPayload, 'disable_local_backup', false),
'stop_during_backup' => (bool) data_get($backupPayload, 'stop_during_backup', false),
'retention_amount_locally' => data_get($backupPayload, 'retention_amount_locally'),
'retention_days_locally' => data_get($backupPayload, 'retention_days_locally'),
'retention_max_storage_locally' => data_get($backupPayload, 'retention_max_storage_locally'),
'retention_amount_s3' => data_get($backupPayload, 'retention_amount_s3'),
'retention_days_s3' => data_get($backupPayload, 'retention_days_s3'),
'retention_max_storage_s3' => data_get($backupPayload, 'retention_max_storage_s3'),
'timeout' => data_get($backupPayload, 'timeout'),
'missing_backup_notification_days' => (int) data_get($backupPayload, 'missing_backup_notification_days', 0),
]);
$importedBackup->syncS3Storages($s3Ids);
$count++;
}
return $count;
}
/**
* @param array<string, StandaloneDocker|SwarmDocker> $destinationMap
*/
private function resolveDestination(?string $uuid, array $destinationMap): StandaloneDocker|SwarmDocker
{
if ($uuid && isset($destinationMap[$uuid])) {
return $destinationMap[$uuid];
}
$first = reset($destinationMap);
if ($first instanceof StandaloneDocker || $first instanceof SwarmDocker) {
return $first;
}
throw new RuntimeException('No destination available for imported resource.');
}
/**
* @param class-string $modelClass
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
private function onlyExistingColumns(string $modelClass, array $attributes): array
{
/** @var Model $model */
$model = new $modelClass;
$columns = Schema::getColumnListing($model->getTable());
return collect($attributes)
->only($columns)
->all();
}
}