diff --git a/.gitignore b/.gitignore index 086b946d0..460009bd9 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,6 @@ ref .dev/lima/ssh.config .dev/lima/ssh_key .dev/lima/hosts + +# Multi-instance local Coolify env files (scripts/dev-instances) +.dev-instances/ diff --git a/AGENTS.md b/AGENTS.md index 566d79777..076c30b48 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,9 +18,17 @@ Docker Compose-based dev setup with services: coolify (app), postgres, redis, so # Start dev environment (uses docker-compose.dev.yml) spin up # or: docker compose -f docker-compose.dev.yml up -d spin down # stop services + +# Two local Coolify instances (isolated stacks; server transfer / multi-control-plane) +./scripts/dev-instances up # a:8000 + b:8001 (uses npm run build for CSS/JS) +./scripts/dev-instances up a --with vite # HMR only when starting a single instance +./scripts/dev-instances urls +./scripts/dev-instances down +# Compose: docker-compose.dev-multi.yml Env: .dev-instances/{a,b}.env (gitignored) +# Note: dual Vite HMR is unsupported (shared public/hot); multi-instance always uses public/build. ``` -The app runs at `localhost:8000` by default. Vite dev server on port 5173. +The app runs at `localhost:8000` by default. Instance **b** is on `8001` (db `5433`, redis `6380`, …); see `./scripts/dev-instances`. ## Common Commands diff --git a/app/Actions/Server/ValidateServer.php b/app/Actions/Server/ValidateServer.php index 378998fe7..9bb15c205 100644 --- a/app/Actions/Server/ValidateServer.php +++ b/app/Actions/Server/ValidateServer.php @@ -25,6 +25,15 @@ class ValidateServer public function handle(Server $server) { + if (! $server->canBeValidated()) { + $this->error = 'This server was transferred to another Coolify instance and cannot be revalidated here.'; + $server->update([ + 'validation_logs' => $this->error, + 'is_validating' => false, + ]); + throw new \Exception($this->error); + } + $server->update([ 'validation_logs' => null, ]); diff --git a/app/Http/Controllers/Api/ServerTransferController.php b/app/Http/Controllers/Api/ServerTransferController.php new file mode 100644 index 000000000..5c4d67511 --- /dev/null +++ b/app/Http/Controllers/Api/ServerTransferController.php @@ -0,0 +1,510 @@ + []]], + tags: ['Servers'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['target_url', 'target_token'], + properties: [ + new OA\Property(property: 'target_url', type: 'string', example: 'https://coolify-b.example.com'), + new OA\Property(property: 'target_token', type: 'string', description: 'API token on the target instance (root or write)'), + new OA\Property(property: 'write_remote', type: 'boolean', default: false), + new OA\Property(property: 'rebind_sentinel', type: 'boolean', default: true), + new OA\Property(property: 'preserve_uuids', type: 'boolean', default: true), + new OA\Property(property: 'adopt_mode', type: 'boolean', default: true), + ] + ) + ), + responses: [ + new OA\Response(response: 200, description: 'Migrated'), + new OA\Response(response: 403, description: 'Missing sensitive permission'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 422, description: 'Validation or remote import failed'), + ] + )] + public function migrate(Request $request, string $uuid): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + if (! $this->canReadSensitive($request)) { + return response()->json([ + 'message' => 'Migrating a server requires a token with read:sensitive (or root) ability and an admin/owner team role.', + ], 403); + } + + $server = Server::whereTeamId($teamId)->whereUuid($uuid)->first(); + if (! $server) { + return response()->json(['message' => 'Server not found.'], 404); + } + + $this->authorize('update', $server); + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $validator = customApiValidator($request->all(), [ + 'target_url' => 'required|string|url', + 'target_token' => 'required|string', + 'write_remote' => 'boolean|nullable', + 'rebind_sentinel' => 'boolean|nullable', + 'preserve_uuids' => 'boolean|nullable', + 'adopt_mode' => 'boolean|nullable', + ]); + $allowedFields = ['target_url', 'target_token', 'write_remote', 'rebind_sentinel', 'preserve_uuids', 'adopt_mode']; + $extraFields = array_diff(array_keys($request->all()), $allowedFields); + if ($validator->fails() || $extraFields !== []) { + $errors = $validator->errors(); + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + try { + $result = $this->migrator->migrate( + server: $server, + targetUrl: $request->string('target_url')->toString(), + targetToken: $request->string('target_token')->toString(), + writeRemote: $request->boolean('write_remote', false), + rebindSentinel: $request->boolean('rebind_sentinel', true), + preserveUuids: $request->boolean('preserve_uuids', true), + adoptMode: $request->boolean('adopt_mode', true), + ); + } catch (Throwable $e) { + return response()->json(['message' => $e->getMessage()], 422); + } + + auditLog('api.server.migrate', [ + 'team_id' => $teamId, + 'server_uuid' => $server->uuid, + 'export_id' => $result['export_id'], + 'target_url' => $result['target_url'], + ]); + + return response()->json($result); + } + + #[OA\Get( + summary: 'Export server transfer bundle', + description: 'Export a server and all resources hosted on it as a versioned transfer bundle for moving between Coolify instances. Requires read:sensitive.', + path: '/servers/{uuid}/export', + operationId: 'export-server-transfer-bundle', + security: [['bearerAuth' => []]], + tags: ['Servers'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'encrypt', in: 'query', required: false, description: 'If true and passphrase is provided, return an encrypted envelope.', schema: new OA\Schema(type: 'boolean')), + new OA\Parameter(name: 'passphrase', in: 'query', required: false, description: 'Passphrase used when encrypt=true.', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 200, description: 'Transfer bundle'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'Missing sensitive permission'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function export(Request $request, string $uuid): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + if (! $this->canReadSensitive($request)) { + return response()->json([ + 'message' => 'Exporting a server requires a token with read:sensitive (or root) ability and an admin/owner team role.', + ], 403); + } + + $server = Server::whereTeamId($teamId)->whereUuid($uuid)->first(); + if (! $server) { + return response()->json(['message' => 'Server not found.'], 404); + } + + $this->authorize('view', $server); + + try { + $bundle = $this->exporter->export($server, includeSensitive: true); + } catch (Throwable $e) { + return response()->json(['message' => $e->getMessage()], 422); + } + + auditLog('api.server.export', [ + 'team_id' => $teamId, + 'server_uuid' => $server->uuid, + 'export_id' => data_get($bundle, 'export_id'), + ]); + + if ($request->boolean('encrypt') && $request->filled('passphrase')) { + return response()->json( + ServerTransferBundle::encryptWithPassphrase($bundle, $request->string('passphrase')->toString()) + ); + } + + return response()->json($bundle); + } + + #[OA\Post( + summary: 'Import server transfer bundle', + description: 'Import a server transfer bundle into this Coolify instance (adopt mode by default).', + path: '/servers/import', + operationId: 'import-server-transfer-bundle', + security: [['bearerAuth' => []]], + tags: ['Servers'], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'bundle', type: 'object', description: 'Plain or encrypted transfer bundle'), + new OA\Property(property: 'passphrase', type: 'string', nullable: true), + new OA\Property(property: 'dry_run', type: 'boolean', default: false), + new OA\Property(property: 'preserve_uuids', type: 'boolean', default: true), + new OA\Property(property: 'adopt_mode', type: 'boolean', default: true, description: 'Import without forcing redeploy; keep statuses for adoption'), + new OA\Property(property: 'claim', type: 'boolean', default: true, description: 'Automatically claim the host for this instance after import'), + new OA\Property(property: 'write_remote', type: 'boolean', default: false, description: 'When claiming, write ownership file on the host via SSH'), + new OA\Property(property: 'rebind_sentinel', type: 'boolean', default: true, description: 'When claiming, rebind Sentinel to this instance'), + ] + ) + ), + responses: [ + new OA\Response(response: 200, description: 'Dry-run result'), + new OA\Response(response: 201, description: 'Imported'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 422, description: 'Validation failed'), + ] + )] + public function import(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $this->authorize('create', Server::class); + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $validator = customApiValidator($request->all(), [ + 'bundle' => 'required|array', + 'passphrase' => 'string|nullable', + 'dry_run' => 'boolean|nullable', + 'preserve_uuids' => 'boolean|nullable', + 'adopt_mode' => 'boolean|nullable', + 'claim' => 'boolean|nullable', + 'write_remote' => 'boolean|nullable', + 'rebind_sentinel' => 'boolean|nullable', + ]); + $allowedFields = ['bundle', 'passphrase', 'dry_run', 'preserve_uuids', 'adopt_mode', 'claim', 'write_remote', 'rebind_sentinel']; + $extraFields = array_diff(array_keys($request->all()), $allowedFields); + if ($validator->fails() || $extraFields !== []) { + $errors = $validator->errors(); + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + $bundle = $request->input('bundle', []); + if (data_get($bundle, 'encrypted')) { + if (! $request->filled('passphrase')) { + return response()->json(['message' => 'Passphrase is required for encrypted bundles.'], 422); + } + try { + $bundle = ServerTransferBundle::decryptWithPassphrase($bundle, $request->string('passphrase')->toString()); + } catch (Throwable $e) { + return response()->json(['message' => $e->getMessage()], 422); + } + } + + try { + $result = $this->importer->import( + bundle: $bundle, + teamId: $teamId, + dryRun: $request->boolean('dry_run', false), + preserveUuids: $request->boolean('preserve_uuids', true), + adoptMode: $request->boolean('adopt_mode', true), + claim: $request->boolean('claim', true), + writeRemote: $request->boolean('write_remote', false), + rebindSentinel: $request->boolean('rebind_sentinel', true), + ); + } catch (Throwable $e) { + $status = $e instanceof ValidationException ? 422 : 422; + $payload = ['message' => $e->getMessage()]; + if ($e instanceof ValidationException) { + $payload['errors'] = $e->errors(); + } + + return response()->json($payload, $status); + } + + auditLog('api.server.import', [ + 'team_id' => $teamId, + 'server_uuid' => $result['server_uuid'], + 'export_id' => $result['export_id'], + 'dry_run' => $result['dry_run'], + ]); + + return response()->json($result, $result['dry_run'] ? 200 : 201); + } + + #[OA\Post( + summary: 'Claim imported server', + description: 'Claim a managed host for this instance: write ownership file and rebind Sentinel.', + path: '/servers/{uuid}/claim', + operationId: 'claim-server', + security: [['bearerAuth' => []]], + tags: ['Servers'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + requestBody: new OA\RequestBody( + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'write_remote', type: 'boolean', default: true), + new OA\Property(property: 'rebind_sentinel', type: 'boolean', default: true), + ] + ) + ), + responses: [ + new OA\Response(response: 200, description: 'Claim result'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function claim(Request $request, string $uuid): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $server = Server::whereTeamId($teamId)->whereUuid($uuid)->first(); + if (! $server) { + return response()->json(['message' => 'Server not found.'], 404); + } + + $this->authorize('update', $server); + + $validator = customApiValidator($request->all(), [ + 'write_remote' => 'boolean|nullable', + 'rebind_sentinel' => 'boolean|nullable', + ]); + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + try { + $result = $this->claimer->claim( + $server, + writeRemote: $request->boolean('write_remote', true), + rebindSentinel: $request->boolean('rebind_sentinel', true), + ); + } catch (Throwable $e) { + return response()->json(['message' => $e->getMessage()], 422); + } + + auditLog('api.server.claim', [ + 'team_id' => $teamId, + 'server_uuid' => $server->uuid, + 'claim_written' => $result['claim_written'], + ]); + + return response()->json($result); + } + + #[OA\Post( + summary: 'Mark server transferred', + description: 'Source-instance step: disable automations after a successful export/import handoff.', + path: '/servers/{uuid}/transfer/complete', + operationId: 'complete-server-transfer', + security: [['bearerAuth' => []]], + tags: ['Servers'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + requestBody: new OA\RequestBody( + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'export_id', type: 'string', nullable: true), + new OA\Property(property: 'target_instance_url', type: 'string', nullable: true), + ] + ) + ), + responses: [ + new OA\Response(response: 200, description: 'Marked transferred'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function complete(Request $request, string $uuid): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $server = Server::whereTeamId($teamId)->whereUuid($uuid)->first(); + if (! $server) { + return response()->json(['message' => 'Server not found.'], 404); + } + + $this->authorize('update', $server); + + $validator = customApiValidator($request->all(), [ + 'export_id' => 'string|nullable', + 'target_instance_url' => 'string|nullable', + ]); + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + try { + $result = $this->claimer->markTransferred( + $server, + exportId: $request->input('export_id'), + targetInstanceUrl: $request->input('target_instance_url'), + ); + } catch (Throwable $e) { + return response()->json(['message' => $e->getMessage()], 422); + } + + auditLog('api.server.transfer_complete', [ + 'team_id' => $teamId, + 'server_uuid' => $server->uuid, + 'export_id' => $request->input('export_id'), + ]); + + return response()->json($result); + } + + #[OA\Post( + summary: 'Write transfer bundle to server mailbox', + description: 'Write an export bundle to /data/coolify/exports on the managed host for air-gapped import.', + path: '/servers/{uuid}/export/mailbox', + operationId: 'export-server-transfer-mailbox', + security: [['bearerAuth' => []]], + tags: ['Servers'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), + ], + requestBody: new OA\RequestBody( + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'passphrase', type: 'string', nullable: true), + ] + ) + ), + responses: [ + new OA\Response(response: 200, description: 'Mailbox write result'), + new OA\Response(response: 403, description: 'Missing sensitive permission'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ] + )] + public function writeMailbox(Request $request, string $uuid): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + if (! $this->canReadSensitive($request)) { + return response()->json([ + 'message' => 'Writing a transfer mailbox requires read:sensitive (or root) ability and an admin/owner team role.', + ], 403); + } + + $server = Server::whereTeamId($teamId)->whereUuid($uuid)->first(); + if (! $server) { + return response()->json(['message' => 'Server not found.'], 404); + } + + $this->authorize('view', $server); + + try { + $bundle = $this->exporter->export($server, includeSensitive: true); + $result = $this->claimer->writeMailbox( + $server, + $bundle, + $request->filled('passphrase') ? $request->string('passphrase')->toString() : null, + ); + } catch (Throwable $e) { + return response()->json(['message' => $e->getMessage()], 422); + } + + auditLog('api.server.export_mailbox', [ + 'team_id' => $teamId, + 'server_uuid' => $server->uuid, + 'export_id' => data_get($bundle, 'export_id'), + 'path' => $result['path'], + ]); + + return response()->json([ + 'export_id' => data_get($bundle, 'export_id'), + 'path' => $result['path'], + 'written' => $result['written'], + 'message' => $result['written'] + ? 'Transfer bundle written to server mailbox.' + : 'Failed to write mailbox on remote host.', + ], $result['written'] ? 200 : 422); + } + + private function canReadSensitive(Request $request): bool + { + return (bool) $request->attributes->get('can_read_sensitive', false); + } +} diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index 256a07eb4..d50a5226a 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -970,6 +970,12 @@ class ServersController extends Controller } $this->authorize('update', $server); + if (! $server->canBeValidated()) { + return response()->json([ + 'message' => 'This server was transferred to another Coolify instance and cannot be revalidated here.', + ], 422); + } + $validator = customApiValidator($request->all(), [ 'install' => 'boolean', ]); diff --git a/app/Jobs/ValidateAndInstallServerJob.php b/app/Jobs/ValidateAndInstallServerJob.php index ee8cf2797..af2588dda 100644 --- a/app/Jobs/ValidateAndInstallServerJob.php +++ b/app/Jobs/ValidateAndInstallServerJob.php @@ -33,6 +33,19 @@ class ValidateAndInstallServerJob implements ShouldBeEncrypted, ShouldQueue public function handle(): void { try { + if (! $this->server->canBeValidated()) { + $message = 'This server was transferred to another Coolify instance and cannot be revalidated here.'; + $this->server->update([ + 'validation_logs' => $message, + 'is_validating' => false, + ]); + Log::warning('ValidateAndInstallServer: blocked for transferred server', [ + 'server_id' => $this->server->id, + ]); + + return; + } + // Mark validation as in progress $this->server->update(['is_validating' => true]); diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 1090e9892..9678cb8d7 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -327,6 +327,15 @@ class Show extends Component { try { $this->authorize('update', $this->server); + if (! $this->server->canBeValidated()) { + $this->dispatch( + 'error', + 'Cannot revalidate', + 'This server was transferred to another Coolify instance. Manage it from the target instance instead.' + ); + + return; + } if ($this->server->vultr_instance_id) { $status = $this->server->refreshVultrState(); $this->server->refresh(); diff --git a/app/Livewire/Server/Transfer.php b/app/Livewire/Server/Transfer.php new file mode 100644 index 000000000..0573aba0f --- /dev/null +++ b/app/Livewire/Server/Transfer.php @@ -0,0 +1,198 @@ + */ + public array $lastWarnings = []; + + public ?string $lastResultJson = null; + + public function mount(string $server_uuid): void + { + $this->ensureDevelopmentAvailability(); + + try { + $this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail(); + $this->authorize('view', $this->server); + $this->exportId = data_get($this->server->server_metadata, 'transfer.export_id'); + } catch (Throwable $e) { + handleError($e, $this); + $this->redirect(route('server.index'), navigate: true); + } + } + + public function getTransferStatusProperty(): ?string + { + return data_get($this->server->fresh()->server_metadata, 'transfer.status'); + } + + public function getIsLocalhostProperty(): bool + { + return (int) $this->server->id === 0; + } + + public function migrateServer(ServerTransferMigrator $migrator): void + { + $this->ensureDevelopmentAvailability(); + + try { + $this->authorize('update', $this->server); + if ($this->isLocalhost) { + throw new \RuntimeException('The Coolify host (localhost) cannot be transferred.'); + } + + $result = $migrator->migrate( + server: $this->server, + targetUrl: $this->targetUrl, + targetToken: $this->targetToken, + writeRemote: $this->writeRemote, + ); + + $this->server->refresh(); + $this->exportId = $result['export_id'] ?? $this->exportId; + $this->lastWarnings = array_values((array) data_get($result, 'warnings', [])); + // Never echo the target token in the result dump. + $safe = $result; + unset($safe['target_token']); + $this->lastResultJson = json_encode($safe, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + $this->targetToken = ''; + $this->dispatch('success', $result['message'] ?? 'Server transferred.'); + } catch (Throwable $e) { + handleError($e, $this); + } + } + + public function exportBundle(ServerTransferExporter $exporter) + { + $this->ensureDevelopmentAvailability(); + + try { + $this->authorize('view', $this->server); + if ($this->isLocalhost) { + throw new \RuntimeException('The Coolify host (localhost) cannot be transferred.'); + } + + $bundle = $exporter->export($this->server, includeSensitive: true); + $this->exportId = data_get($bundle, 'export_id'); + $this->lastWarnings = array_values((array) data_get($bundle, 'warnings', [])); + $this->lastResultJson = null; + + $payload = $bundle; + $fileName = 'server-transfer-'.$this->server->uuid.'.json'; + if ($this->encryptBundle) { + if (blank($this->passphrase)) { + throw new \RuntimeException('Passphrase is required to encrypt the bundle.'); + } + $payload = ServerTransferBundle::encryptWithPassphrase($bundle, $this->passphrase); + $fileName = 'server-transfer-'.$this->server->uuid.'.encrypted.json'; + } + + $json = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if ($json === false) { + throw new \RuntimeException('Failed to encode transfer bundle.'); + } + + $this->dispatch('success', 'Transfer bundle ready for download.'); + + return response()->streamDownload(function () use ($json) { + echo $json; + }, $fileName, [ + 'Content-Type' => 'application/json', + ]); + } catch (Throwable $e) { + return handleError($e, $this); + } + } + + public function completeTransfer(ServerTransferClaimer $claimer): void + { + $this->ensureDevelopmentAvailability(); + + try { + $this->authorize('update', $this->server); + if ($this->isLocalhost) { + throw new \RuntimeException('The Coolify host cannot be marked as transferred.'); + } + + $result = $claimer->markTransferred( + $this->server, + exportId: $this->exportId ?: data_get($this->server->server_metadata, 'transfer.export_id'), + targetInstanceUrl: filled($this->targetUrl) ? rtrim($this->targetUrl, '/') : null, + ); + $this->server->refresh(); + $this->lastResultJson = json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + $this->dispatch('success', $result['message'] ?? 'Server marked as transferred.'); + } catch (Throwable $e) { + handleError($e, $this); + } + } + + public function claimServer(ServerTransferClaimer $claimer): void + { + $this->ensureDevelopmentAvailability(); + + try { + $this->authorize('update', $this->server); + if ($this->isLocalhost) { + throw new \RuntimeException('The Coolify host cannot be claimed.'); + } + + $result = $claimer->claim( + $this->server, + writeRemote: $this->writeRemoteOnClaim, + rebindSentinel: $this->rebindSentinelOnClaim, + ); + $this->server->refresh(); + $this->lastResultJson = json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + $this->lastWarnings = []; + $this->dispatch('success', $result['message'] ?? 'Server claimed.'); + } catch (Throwable $e) { + handleError($e, $this); + } + } + + public function render() + { + return view('livewire.server.transfer'); + } + + private function ensureDevelopmentAvailability(): void + { + abort_unless(isDev(), 404); + } +} diff --git a/app/Livewire/Server/TransferImport.php b/app/Livewire/Server/TransferImport.php new file mode 100644 index 000000000..db8999c26 --- /dev/null +++ b/app/Livewire/Server/TransferImport.php @@ -0,0 +1,147 @@ +|null */ + public ?array $lastResult = null; + + /** @var list */ + public array $lastWarnings = []; + + public ?string $importedServerUuid = null; + + public function mount(): void + { + $this->ensureDevelopmentAvailability(); + $this->authorize('create', Server::class); + } + + public function updatedBundleFile(): void + { + $this->ensureDevelopmentAvailability(); + + if (! $this->bundleFile) { + return; + } + try { + $contents = $this->bundleFile->get(); + if (! is_string($contents) || blank($contents)) { + throw new \RuntimeException('Uploaded file is empty.'); + } + $this->bundleJson = $contents; + $this->dispatch('success', 'Bundle file loaded into the form.'); + } catch (Throwable $e) { + handleError($e, $this); + } + } + + /** + * @return array + */ + private function resolveBundle(): array + { + $raw = trim($this->bundleJson); + if ($raw === '') { + throw new \RuntimeException('Paste a transfer bundle JSON or upload a file.'); + } + + $decoded = json_decode($raw, true); + if (! is_array($decoded)) { + throw new \RuntimeException('Bundle is not valid JSON.'); + } + + if (data_get($decoded, 'encrypted')) { + if (blank($this->passphrase)) { + throw new \RuntimeException('Passphrase is required for encrypted bundles.'); + } + + return ServerTransferBundle::decryptWithPassphrase($decoded, $this->passphrase); + } + + return $decoded; + } + + public function dryRun(ServerTransferImporter $importer): void + { + $this->ensureDevelopmentAvailability(); + $this->runImport($importer, dryRun: true); + } + + public function importBundle(ServerTransferImporter $importer): void + { + $this->ensureDevelopmentAvailability(); + $this->runImport($importer, dryRun: false); + } + + private function runImport(ServerTransferImporter $importer, bool $dryRun): void + { + try { + $this->authorize('create', Server::class); + $teamId = currentTeam()->id; + $bundle = $this->resolveBundle(); + + $result = $importer->import( + bundle: $bundle, + teamId: $teamId, + dryRun: $dryRun, + preserveUuids: $this->preserveUuids, + adoptMode: $this->adoptMode, + claim: ! $dryRun, + writeRemote: $this->writeRemote, + rebindSentinel: true, + ); + + $this->lastResult = $result; + $this->lastWarnings = array_values((array) data_get($result, 'warnings', [])); + $this->importedServerUuid = $dryRun ? null : data_get($result, 'server_uuid'); + + if ($dryRun) { + $this->dispatch('success', 'Dry run completed — nothing was written.'); + } elseif (data_get($result, 'claimed')) { + $this->dispatch('success', 'Server imported and claimed for this instance.'); + } else { + $this->dispatch('success', 'Server imported. Claim did not complete — check warnings or re-claim from the server Transfer page.'); + } + } catch (Throwable $e) { + handleError($e, $this); + } + } + + public function render() + { + return view('livewire.server.transfer-import'); + } + + private function ensureDevelopmentAvailability(): void + { + abort_unless(isDev(), 404); + } +} diff --git a/app/Livewire/Server/ValidateAndInstall.php b/app/Livewire/Server/ValidateAndInstall.php index 9e6108de0..c39f868ba 100644 --- a/app/Livewire/Server/ValidateAndInstall.php +++ b/app/Livewire/Server/ValidateAndInstall.php @@ -53,6 +53,21 @@ class ValidateAndInstall extends Component public function init(int $data = 0) { + if (! $this->server->canBeValidated()) { + $this->error = 'This server was transferred to another Coolify instance and cannot be revalidated here.'; + $this->server->update([ + 'validation_logs' => $this->error, + 'is_validating' => false, + ]); + $this->dispatch( + 'error', + 'Cannot revalidate', + $this->error + ); + + return; + } + $this->isInstalling = false; $this->uptime = null; $this->supported_os_type = null; diff --git a/app/Models/Server.php b/app/Models/Server.php index 3acaf3507..619382dfd 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -862,8 +862,29 @@ $schema://$host { return $this->settings->force_disabled; } + /** + * Server was migrated away from this Coolify instance (source side). + * Must not be revalidated or re-enabled as a live managed host. + */ + public function isTransferredAway(): bool + { + return data_get($this->server_metadata, 'transfer.status') === 'transferred'; + } + + /** + * Whether this server may be validated / installed against from this instance. + */ + public function canBeValidated(): bool + { + return ! $this->isTransferredAway(); + } + public function forceEnableServer() { + if ($this->isTransferredAway()) { + return; + } + $this->settings->force_disabled = false; $this->settings->save(); } diff --git a/app/Services/ServerTransfer/ServerTransferBundle.php b/app/Services/ServerTransfer/ServerTransferBundle.php new file mode 100644 index 000000000..cbc159403 --- /dev/null +++ b/app/Services/ServerTransfer/ServerTransferBundle.php @@ -0,0 +1,181 @@ + $payload + */ + public static function wrap(array $payload): array + { + return array_merge([ + 'schema_version' => self::SCHEMA_VERSION, + 'exported_at' => now()->toIso8601String(), + 'export_id' => new_public_id(), + ], $payload); + } + + /** + * @param array $bundle + * @return array{valid: bool, errors: list, warnings: list} + */ + public static function validate(array $bundle): array + { + $errors = []; + $warnings = []; + + if (! isset($bundle['schema_version'])) { + $errors[] = 'Missing schema_version.'; + } elseif ((int) $bundle['schema_version'] !== self::SCHEMA_VERSION) { + $errors[] = 'Unsupported schema_version '.$bundle['schema_version'].'. Expected '.self::SCHEMA_VERSION.'.'; + } + + if (! isset($bundle['export_id']) || ! is_string($bundle['export_id']) || $bundle['export_id'] === '') { + $errors[] = 'Missing export_id.'; + } + + if (! isset($bundle['server']) || ! is_array($bundle['server'])) { + $errors[] = 'Missing server payload.'; + } else { + foreach (['uuid', 'name', 'ip', 'port', 'user'] as $field) { + if (! array_key_exists($field, $bundle['server'])) { + $errors[] = "Missing server.{$field}."; + } + } + } + + if (! isset($bundle['private_key']) || ! is_array($bundle['private_key'])) { + $errors[] = 'Missing private_key payload.'; + } else { + if (blank(data_get($bundle, 'private_key.private_key'))) { + $errors[] = 'Missing private_key.private_key material.'; + } + } + + if (! isset($bundle['destinations']) || ! is_array($bundle['destinations'])) { + $errors[] = 'Missing destinations array.'; + } elseif (count($bundle['destinations']) === 0) { + $warnings[] = 'Bundle has no destinations; a default destination will be used.'; + } + + if (! isset($bundle['projects']) || ! is_array($bundle['projects'])) { + $errors[] = 'Missing projects array.'; + } + + return [ + 'valid' => $errors === [], + 'errors' => $errors, + 'warnings' => $warnings, + ]; + } + + /** + * @param array $bundle + */ + public static function assertValid(array $bundle): void + { + $result = self::validate($bundle); + if (! $result['valid']) { + throw ValidationException::withMessages([ + 'bundle' => $result['errors'], + ]); + } + } + + /** + * Encrypt an entire bundle for mailbox/API transport with a user passphrase. + * + * @param array $bundle + * @return array{encrypted: true, schema_version: int, payload: string} + */ + public static function encryptWithPassphrase(array $bundle, string $passphrase): array + { + if ($passphrase === '') { + throw new RuntimeException('Passphrase must not be empty.'); + } + + $json = json_encode($bundle, JSON_THROW_ON_ERROR); + $key = hash('sha256', $passphrase, true); + $iv = random_bytes(16); + $cipher = openssl_encrypt($json, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv); + if ($cipher === false) { + throw new RuntimeException('Failed to encrypt transfer bundle.'); + } + + $mac = hash_hmac('sha256', $iv.$cipher, $key); + + return [ + 'encrypted' => true, + 'schema_version' => self::SCHEMA_VERSION, + 'payload' => base64_encode($iv.$mac.$cipher), + ]; + } + + /** + * @param array $encrypted + * @return array + */ + public static function decryptWithPassphrase(array $encrypted, string $passphrase): array + { + if (! data_get($encrypted, 'encrypted')) { + throw new RuntimeException('Bundle is not encrypted.'); + } + + $raw = base64_decode((string) data_get($encrypted, 'payload'), true); + if ($raw === false || strlen($raw) < 48) { + throw new RuntimeException('Invalid encrypted payload.'); + } + + $key = hash('sha256', $passphrase, true); + $iv = substr($raw, 0, 16); + $mac = substr($raw, 16, 64); + $cipher = substr($raw, 80); + $expectedMac = hash_hmac('sha256', $iv.$cipher, $key); + if (! hash_equals($expectedMac, $mac)) { + throw new RuntimeException('Invalid passphrase or corrupted bundle.'); + } + + $json = openssl_decrypt($cipher, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv); + if ($json === false) { + throw new RuntimeException('Failed to decrypt transfer bundle.'); + } + + /** @var array $bundle */ + $bundle = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + + return $bundle; + } + + /** + * Optionally re-seal secrets with Laravel's app key for intermediate storage. + * Prefer passphrase encryption for cross-instance transfers. + * + * @param array $bundle + */ + public static function sealWithAppKey(array $bundle): string + { + return Crypt::encryptString(json_encode($bundle, JSON_THROW_ON_ERROR)); + } + + /** + * @return array + */ + public static function unsealWithAppKey(string $sealed): array + { + /** @var array $bundle */ + $bundle = json_decode(Crypt::decryptString($sealed), true, 512, JSON_THROW_ON_ERROR); + + return $bundle; + } +} diff --git a/app/Services/ServerTransfer/ServerTransferClaimer.php b/app/Services/ServerTransfer/ServerTransferClaimer.php new file mode 100644 index 000000000..d1d984a19 --- /dev/null +++ b/app/Services/ServerTransfer/ServerTransferClaimer.php @@ -0,0 +1,248 @@ +, + * sentinel_rebound: bool, + * claim_written: bool, + * message: string + * } + */ + public function claim(Server $server, bool $writeRemote = true, bool $rebindSentinel = true): array + { + if ($server->id === 0) { + throw new RuntimeException('Cannot claim the Coolify host itself.'); + } + + $instanceUrl = rtrim((string) (instanceSettings()->fqdn ?: config('app.url')), '/'); + if ($instanceUrl === '') { + throw new RuntimeException('Instance URL (FQDN or APP_URL) must be set before claiming a server.'); + } + + $exportId = data_get($server->server_metadata, 'transfer.export_id'); + + $claim = [ + 'instance_url' => $instanceUrl, + 'server_uuid' => $server->uuid, + 'team_id' => $server->team_id, + 'claimed_at' => now()->toIso8601String(), + 'export_id' => $exportId, + 'schema_version' => ServerTransferBundle::SCHEMA_VERSION, + ]; + + $result = DB::transaction(function () use ($server, $claim, $rebindSentinel, $instanceUrl) { + $server = Server::query()->with('settings')->lockForUpdate()->findOrFail($server->id); + + $sentinelRebound = false; + if ($rebindSentinel && $server->settings) { + $server->settings->sentinel_custom_url = $instanceUrl; + $server->settings->ensureValidSentinelToken(); + // Leave sentinel disabled until operator enables metrics; endpoint is ready. + $server->settings->save(); + $sentinelRebound = true; + } + + $metadata = $server->server_metadata ?? []; + $metadata['transfer'] = array_merge((array) data_get($metadata, 'transfer', []), [ + 'status' => 'claimed', + 'claimed_at' => $claim['claimed_at'], + 'claim' => $claim, + 'claim_written' => false, + 'sentinel_rebound' => $sentinelRebound, + ]); + $server->server_metadata = $metadata; + $server->save(); + + return [ + 'server_uuid' => $server->uuid, + 'claim' => $claim, + 'sentinel_rebound' => $sentinelRebound, + 'claim_written' => false, + ]; + }); + + // Remote host I/O is outside the transaction (cannot be rolled back with DB rows). + $claimWritten = false; + if ($writeRemote) { + $claimWritten = $this->writeClaimFile($server, $claim); + if ($claimWritten) { + $this->persistClaimWritten($server); + } + } + + $result['claim_written'] = $claimWritten; + $result['message'] = $claimWritten + ? 'Server claimed. Ownership file written and Sentinel rebound to this instance.' + : 'Server claimed in Coolify. Remote ownership file was not written (SSH unavailable or skipped).'; + + return $result; + } + + /** + * Mark a server as transferred away from this instance (source side). + * Disables automations to prevent dual management. + * + * All database writes run in one transaction so partial disable state cannot stick on failure. + * Local SSH key cache cleanup runs after commit (filesystem; not transactional). + * + * @return array{server_uuid: string, message: string} + */ + public function markTransferred(Server $server, ?string $exportId = null, ?string $targetInstanceUrl = null): array + { + if ($server->id === 0) { + throw new RuntimeException('Cannot transfer the Coolify host itself.'); + } + + $result = DB::transaction(function () use ($server, $exportId, $targetInstanceUrl) { + $server = Server::query()->with('settings')->lockForUpdate()->findOrFail($server->id); + + if ($server->settings) { + $server->settings->force_disabled = true; + $server->settings->is_sentinel_enabled = false; + $server->settings->save(); + } + + $metadata = $server->server_metadata ?? []; + $metadata['transfer'] = array_merge((array) data_get($metadata, 'transfer', []), [ + 'status' => 'transferred', + 'export_id' => $exportId ?? data_get($metadata, 'transfer.export_id'), + 'target_instance_url' => $targetInstanceUrl, + 'transferred_at' => now()->toIso8601String(), + ]); + $server->server_metadata = $metadata; + $server->save(); + + return [ + 'server_uuid' => $server->uuid, + 'message' => 'Server marked as transferred. Automations disabled on this instance.', + ]; + }); + + $this->clearLocalSshArtifacts($server); + + return $result; + } + + private function persistClaimWritten(Server $server): void + { + DB::transaction(function () use ($server) { + $server = Server::query()->lockForUpdate()->find($server->id); + if (! $server) { + return; + } + + $metadata = $server->server_metadata ?? []; + data_set($metadata, 'transfer.claim_written', true); + $server->server_metadata = $metadata; + $server->save(); + }); + } + + /** + * Best-effort local SSH mux/key cleanup after transfer (filesystem; not part of DB rollback). + * forceDisableServer is idempotent for force_disabled and also clears cached SSH key material. + */ + private function clearLocalSshArtifacts(Server $server): void + { + try { + $server->refresh(); + $server->forceDisableServer(); + } catch (Throwable $e) { + Log::warning('Failed to clear local SSH artifacts after transfer', [ + 'server_uuid' => $server->uuid, + 'error' => $e->getMessage(), + ]); + } + } + + /** + * Write export bundle to the managed host mailbox (air-gapped transfer). + * + * @param array $bundle + * @return array{path: string, written: bool} + */ + public function writeMailbox(Server $server, array $bundle, ?string $passphrase = null): array + { + ServerTransferBundle::assertValid($bundle); + + $exportId = (string) data_get($bundle, 'export_id', new_public_id()); + $filename = "server-transfer-{$exportId}.coolify.json"; + $path = ServerTransferBundle::MAILBOX_DIR.'/'.$filename; + + $payload = $passphrase + ? ServerTransferBundle::encryptWithPassphrase($bundle, $passphrase) + : $bundle; + + $json = json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + $b64 = base64_encode($json); + + $written = false; + try { + instant_remote_process([ + 'mkdir -p '.escapeshellarg(ServerTransferBundle::MAILBOX_DIR), + 'echo '.escapeshellarg($b64).' | base64 -d > '.escapeshellarg($path), + 'chmod 600 '.escapeshellarg($path), + 'chown 9999:root '.escapeshellarg($path).' || true', + ], $server, true); + $written = true; + } catch (Throwable $e) { + Log::warning('Failed to write server transfer mailbox', [ + 'server_uuid' => $server->uuid, + 'error' => $e->getMessage(), + ]); + if (! app()->runningUnitTests()) { + throw $e; + } + } + + return [ + 'path' => $path, + 'written' => $written, + ]; + } + + /** + * @param array $claim + */ + private function writeClaimFile(Server $server, array $claim): bool + { + $json = json_encode($claim, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + $b64 = base64_encode($json); + $path = ServerTransferBundle::CLAIM_PATH; + + try { + instant_remote_process([ + 'mkdir -p /data/coolify', + 'echo '.escapeshellarg($b64).' | base64 -d > '.escapeshellarg($path), + 'chmod 600 '.escapeshellarg($path), + 'chown 9999:root '.escapeshellarg($path).' || true', + ], $server, true); + + return true; + } catch (Throwable $e) { + Log::warning('Failed to write instance claim file', [ + 'server_uuid' => $server->uuid, + 'error' => $e->getMessage(), + ]); + + return false; + } + } +} diff --git a/app/Services/ServerTransfer/ServerTransferExporter.php b/app/Services/ServerTransfer/ServerTransferExporter.php new file mode 100644 index 000000000..bd67f8e39 --- /dev/null +++ b/app/Services/ServerTransfer/ServerTransferExporter.php @@ -0,0 +1,1255 @@ + + */ + private const SKIP_SERVER_SETTINGS = [ + 'id', + 'server_id', + 'created_at', + 'updated_at', + 'is_reachable', + 'is_usable', + 'force_disabled', + 'sentinel_token', + 'sentinel_custom_url', + 'is_sentinel_enabled', + 'is_sentinel_debug_enabled', + ]; + + /** + * Application attributes that are relational FKs remapped on import. + * + * @var list + */ + private const SKIP_APPLICATION_ATTRS = [ + 'id', + 'created_at', + 'updated_at', + 'deleted_at', + 'environment_id', + 'destination_id', + 'destination_type', + 'source_id', + 'source_type', + 'private_key_id', + 'additional_servers_count', + 'additional_networks_count', + 'server_status', + ]; + + /** + * @return array + */ + public function export(Server $server, bool $includeSensitive = true): array + { + if ($server->id === 0) { + throw new RuntimeException('The Coolify host (localhost) cannot be transferred between instances.'); + } + + $server->loadMissing(['settings', 'privateKey', 'standaloneDockers', 'swarmDockers', 'cloudProviderToken', 'sslCertificates']); + + $privateKey = $server->privateKey; + if (! $privateKey instanceof PrivateKey) { + throw new RuntimeException('Server has no private key to export.'); + } + + if (! $includeSensitive) { + throw new RuntimeException('Server transfer export requires sensitive data access (read:sensitive).'); + } + + // applications() also includes multi-destination attachments on this server; + // unique by id so a multi-dest app is only considered once for validation/export. + $applications = $server->applications()->unique('id')->values(); + $databases = collect($server->databases())->unique(fn ($db) => $db::class.':'.$db->id)->values(); + $services = $server->services()->get()->unique('id')->values(); + + $this->assertNoAdditionalDestinations($server, $applications); + + $projectIds = $this->collectProjectIds($applications, $databases, $services); + $projects = Project::query()->whereIn('id', $projectIds)->orderBy('name')->get(); + + $destinationUuidByKey = $this->destinationUuidMap($server); + $dependencies = $this->collectDependencies($server, $applications, $databases, $services); + + $sourceInstanceUrl = rtrim((string) (instanceSettings()->fqdn ?: config('app.url')), '/'); + $warnings = $this->buildWarnings($applications, $dependencies, $sourceInstanceUrl); + + $payload = ServerTransferBundle::wrap([ + 'source_instance' => [ + 'url' => $sourceInstanceUrl, + 'name' => config('app.name'), + ], + 'warnings' => $warnings, + // Back-compat: single server key + 'private_key' => $this->exportPrivateKey($privateKey), + // All keys needed by server, apps, and git sources + 'private_keys' => $dependencies['private_keys']->map(fn (PrivateKey $key) => $this->exportPrivateKey($key))->values()->all(), + 'github_apps' => $dependencies['github_apps']->map(fn (GithubApp $app) => $this->exportGithubApp($app))->values()->all(), + 'gitlab_apps' => $dependencies['gitlab_apps']->map(fn (GitlabApp $app) => $this->exportGitlabApp($app))->values()->all(), + 's3_storages' => $dependencies['s3_storages']->map(fn (S3Storage $storage) => $this->exportS3Storage($storage))->values()->all(), + 'cloud_provider_tokens' => $dependencies['cloud_provider_tokens']->map(fn (CloudProviderToken $token) => $this->exportCloudProviderToken($token))->values()->all(), + 'ssl_certificates' => $this->exportSslCertificates($server), + 'volume_backups' => $this->exportVolumeBackups($applications, $databases, $services), + 'server' => $this->exportServer($server), + 'destinations' => $this->exportDestinations($server), + 'shared_environment_variables' => [ + 'server' => $this->exportSharedEnvVars( + SharedEnvironmentVariable::query() + ->where('type', 'server') + ->where('server_id', $server->id) + ->get() + ), + ], + 'projects' => $projects->map(function (Project $project) use ($server, $destinationUuidByKey, $applications, $databases, $services) { + return $this->exportProject($project, $server, $destinationUuidByKey, $applications, $databases, $services); + })->values()->all(), + ]); + + return $payload; + } + + /** + * @param Collection $applications + * @param Collection $databases + * @param Collection $services + * @return array{ + * private_keys: Collection, + * github_apps: Collection, + * gitlab_apps: Collection, + * s3_storages: Collection, + * cloud_provider_tokens: Collection + * } + */ + private function collectDependencies(Server $server, Collection $applications, Collection $databases, Collection $services): array + { + $privateKeys = collect([$server->privateKey])->filter(); + $githubApps = collect(); + $gitlabApps = collect(); + $s3Ids = collect(); + + foreach ($applications as $application) { + if ($application->private_key_id) { + $key = PrivateKey::find($application->private_key_id); + if ($key) { + $privateKeys->push($key); + } + } + + if ($application->source_type === GithubApp::class && $application->source_id !== null) { + $gh = GithubApp::find($application->source_id); + // System-wide GitHub Apps are instance-owned; do not copy them. + // Application export still records source.uuid so import can re-link + // to an existing system-wide app on the target instance. + if ($gh && ! $gh->is_system_wide) { + $githubApps->push($gh); + if ($gh->private_key_id) { + $pk = PrivateKey::find($gh->private_key_id); + if ($pk) { + $privateKeys->push($pk); + } + } + } + } + + if ($application->source_type === GitlabApp::class && $application->source_id !== null) { + $gl = GitlabApp::find($application->source_id); + // System-wide GitLab Apps are instance-owned; re-link by UUID on import. + if ($gl && ! $gl->is_system_wide) { + $gitlabApps->push($gl); + if ($gl->private_key_id) { + $pk = PrivateKey::find($gl->private_key_id); + if ($pk) { + $privateKeys->push($pk); + } + } + } + } + } + + $collectVolumeS3 = function ($resource) use (&$s3Ids): void { + if (method_exists($resource, 'persistentStorages')) { + foreach ($resource->persistentStorages as $volume) { + foreach ($volume->scheduledBackups as $vb) { + if ($vb->s3_storage_id) { + $s3Ids->push($vb->s3_storage_id); + } + } + } + } + if (method_exists($resource, 'fileStorages')) { + foreach ($resource->fileStorages as $file) { + foreach ($file->scheduledBackups as $vb) { + if ($vb->s3_storage_id) { + $s3Ids->push($vb->s3_storage_id); + } + } + } + } + }; + + foreach ($databases as $database) { + if (method_exists($database, 'scheduledBackups')) { + foreach ($database->scheduledBackups as $backup) { + if ($backup->s3_storage_id) { + $s3Ids->push($backup->s3_storage_id); + } + } + } + $database->loadMissing(['persistentStorages.scheduledBackups', 'fileStorages.scheduledBackups']); + $collectVolumeS3($database); + } + + foreach ($applications as $application) { + $application->loadMissing(['persistentStorages.scheduledBackups', 'fileStorages.scheduledBackups', 'previews.persistentStorages.scheduledBackups']); + $collectVolumeS3($application); + foreach ($application->previews as $preview) { + $collectVolumeS3($preview); + } + } + + foreach ($services as $service) { + $service->loadMissing([ + 'applications.environment_variables', + 'applications.persistentStorages.scheduledBackups', + 'applications.fileStorages.scheduledBackups', + 'databases.persistentStorages.scheduledBackups', + 'databases.fileStorages.scheduledBackups', + 'databases.scheduledBackups', + ]); + foreach ($service->applications as $serviceApp) { + $collectVolumeS3($serviceApp); + } + foreach ($service->databases as $serviceDb) { + foreach ($serviceDb->scheduledBackups as $backup) { + if ($backup->s3_storage_id) { + $s3Ids->push($backup->s3_storage_id); + } + } + $collectVolumeS3($serviceDb); + } + } + + $cloudTokens = collect(); + if ($server->cloud_provider_token_id) { + $token = CloudProviderToken::find($server->cloud_provider_token_id); + if ($token) { + $cloudTokens->push($token); + } + } + + return [ + 'private_keys' => $privateKeys->unique('id')->values(), + 'github_apps' => $githubApps->unique('id')->values(), + 'gitlab_apps' => $gitlabApps->unique('id')->values(), + 's3_storages' => S3Storage::query()->whereIn('id', $s3Ids->unique()->filter()->all())->get(), + 'cloud_provider_tokens' => $cloudTokens->unique('id')->values(), + ]; + } + + /** + * @param Collection $applications + * @param array $dependencies + * @return list + */ + private function buildWarnings(Collection $applications, array $dependencies, string $sourceInstanceUrl): array + { + $warnings = []; + + if ($dependencies['github_apps']->isNotEmpty()) { + $warnings[] = 'Team-scoped GitHub Apps were exported with credentials (system-wide apps are skipped). After import, update each GitHub App webhook URL (and setup/callback URL if used) to this Coolify instance so push/PR automations keep working. ' + ."Webhook path: {$sourceInstanceUrl}/webhooks/source/github/events " + .'(replace host with the target instance FQDN after import). ' + .'Also reinstall/refresh the GitHub App installation if installation_id is invalid on the new host.'; + } + + $usesSystemWideGithub = $applications->contains(function (Application $app) { + if ($app->source_type !== GithubApp::class || $app->source_id === null) { + return false; + } + $gh = GithubApp::find($app->source_id); + + return $gh?->is_system_wide === true; + }); + if ($usesSystemWideGithub) { + $warnings[] = 'Some applications use a system-wide GitHub App, which is not exported. On import, Coolify will re-link to a matching system-wide GitHub App on the target instance (by UUID) if one exists.'; + } + + $usesSystemWideGitlab = $applications->contains(function (Application $app) { + if ($app->source_type !== GitlabApp::class || $app->source_id === null) { + return false; + } + $gl = GitlabApp::find($app->source_id); + + return $gl?->is_system_wide === true; + }); + if ($usesSystemWideGitlab) { + $warnings[] = 'Some applications use a system-wide GitLab App, which is not exported. On import, Coolify will re-link to a matching system-wide GitLab App on the target instance (by UUID) if one exists.'; + } + + if ($dependencies['gitlab_apps']->isNotEmpty()) { + $warnings[] = 'Team-scoped GitLab Apps/OAuth sources were exported (system-wide apps are skipped). After import, update GitLab webhook URLs to the target Coolify instance ' + ."({$sourceInstanceUrl}/webhooks/source/gitlab/events — use the new FQDN) and refresh OAuth tokens if needed."; + } + + if ($dependencies['s3_storages']->isNotEmpty()) { + $warnings[] = 'S3 storage credentials were exported. Confirm endpoint access from the target instance and that bucket policies still apply.'; + } + + return $warnings; + } + + /** + * @return array + */ + private function exportPrivateKey(PrivateKey $key): array + { + try { + $material = $key->private_key; + } catch (\Throwable $e) { + throw new RuntimeException( + "Cannot decrypt private key \"{$key->name}\" (uuid={$key->uuid}). " + .'This instance APP_KEY does not match the key that encrypted the data ' + .'(common after regenerating .dev-instances/*.env). ' + .'Restore the original APP_KEY or re-save the private key material. ' + .'Original error: '.$e->getMessage(), + previous: $e + ); + } + + return [ + 'uuid' => $key->uuid, + 'name' => $key->name, + 'description' => $key->description, + 'private_key' => $material, + 'is_git_related' => (bool) $key->is_git_related, + 'fingerprint' => $key->fingerprint, + ]; + } + + /** + * @return array + */ + private function exportServer(Server $server): array + { + $settings = $server->settings; + $settingsPayload = []; + if ($settings) { + foreach ($settings->getAttributes() as $key => $value) { + if (in_array($key, self::SKIP_SERVER_SETTINGS, true)) { + continue; + } + // Read through cast so encrypted fields are plaintext for re-encryption on import. + $settingsPayload[$key] = $settings->{$key}; + } + } + + return [ + 'uuid' => $server->uuid, + 'name' => $server->name, + 'description' => $server->description, + 'ip' => (string) $server->ip, + 'port' => (int) $server->port, + 'user' => (string) $server->user, + 'proxy' => $server->proxy?->toArray() ?? [], + 'is_build_server' => (bool) $server->is_build_server, + 'cloud_provider_token_uuid' => $server->cloudProviderToken?->uuid, + 'settings' => $settingsPayload, + ]; + } + + /** + * @return list> + */ + private function exportDestinations(Server $server): array + { + $items = []; + foreach ($server->standaloneDockers as $destination) { + $items[] = [ + 'uuid' => $destination->uuid, + 'name' => $destination->name, + 'network' => $destination->network, + 'type' => 'standalone', + ]; + } + foreach ($server->swarmDockers as $destination) { + $items[] = [ + 'uuid' => $destination->uuid, + 'name' => $destination->name, + 'network' => $destination->network, + 'type' => 'swarm', + ]; + } + + return $items; + } + + /** + * @param Collection $applications + * @param Collection $databases + * @param Collection $services + * @param array $destinationUuidByKey + * @return array + */ + private function exportProject( + Project $project, + Server $server, + array $destinationUuidByKey, + Collection $applications, + Collection $databases, + Collection $services, + ): array { + $environments = $project->environments()->orderBy('name')->get(); + + return [ + 'uuid' => $project->uuid, + 'name' => $project->name, + 'description' => $project->description, + 'shared_environment_variables' => $this->exportSharedEnvVars( + SharedEnvironmentVariable::query() + ->where('type', 'project') + ->where('project_id', $project->id) + ->get() + ), + 'environments' => $environments->map(function (Environment $environment) use ($destinationUuidByKey, $applications, $databases, $services) { + $envApps = $applications->where('environment_id', $environment->id)->values(); + $envDbs = $databases->where('environment_id', $environment->id)->values(); + $envServices = $services->where('environment_id', $environment->id)->values(); + + return [ + 'uuid' => $environment->uuid, + 'name' => $environment->name, + 'description' => $environment->description, + 'shared_environment_variables' => $this->exportSharedEnvVars( + SharedEnvironmentVariable::query() + ->where('type', 'environment') + ->where('environment_id', $environment->id) + ->get() + ), + 'applications' => $envApps->map(fn (Application $app) => $this->exportApplication($app, $destinationUuidByKey))->all(), + 'databases' => $envDbs->map(fn (Model $db) => $this->exportDatabase($db, $destinationUuidByKey))->all(), + 'services' => $envServices->map(fn (Service $service) => $this->exportService($service, $destinationUuidByKey))->all(), + ]; + })->values()->all(), + ]; + } + + /** + * @param array $destinationUuidByKey + * @return array + */ + private function exportApplication(Application $application, array $destinationUuidByKey): array + { + $application->loadMissing([ + 'settings', + 'environment_variables', + 'environment_variables_preview', + 'persistentStorages', + 'fileStorages', + 'scheduled_tasks', + 'tags', + 'previews.persistentStorages', + 'source', + 'private_key', + ]); + + $columns = Schema::getColumnListing($application->getTable()); + $attrs = []; + foreach ($application->getFillable() as $field) { + if (in_array($field, self::SKIP_APPLICATION_ATTRS, true)) { + continue; + } + if (! in_array($field, $columns, true)) { + continue; + } + $attrs[$field] = $application->{$field}; + } + + // Ensure hidden encrypted fields are included as plaintext. + foreach ([ + 'http_basic_auth_password', + 'manual_webhook_secret_github', + 'manual_webhook_secret_gitlab', + 'manual_webhook_secret_bitbucket', + 'manual_webhook_secret_gitea', + 'dockerfile', + 'docker_compose', + 'docker_compose_raw', + 'custom_labels', + ] as $hidden) { + if (in_array($hidden, $columns, true)) { + $attrs[$hidden] = $application->{$hidden}; + } + } + + $settings = []; + if ($application->settings) { + foreach ($application->settings->getFillable() as $field) { + if (in_array($field, ['application_id', 'id'], true)) { + continue; + } + $settings[$field] = $application->settings->{$field}; + } + } + + $destinationKey = $application->destination_type.':'.$application->destination_id; + + $source = null; + if ($application->source_type === GithubApp::class && $application->source) { + $source = ['type' => 'github_app', 'uuid' => $application->source->uuid]; + } elseif ($application->source_type === GitlabApp::class && $application->source) { + $source = ['type' => 'gitlab_app', 'uuid' => $application->source->uuid]; + } + + return [ + 'type' => 'application', + 'uuid' => $application->uuid, + 'destination_uuid' => $destinationUuidByKey[$destinationKey] ?? null, + 'attributes' => $attrs, + 'settings' => $settings, + 'environment_variables' => $this->exportEnvVars($application->environment_variables()->get()), + 'environment_variables_preview' => $this->exportEnvVars($application->environment_variables_preview()->get()), + 'persistent_storages' => $this->exportPersistentStorages($application->persistentStorages()->get()), + 'file_storages' => $this->exportFileStorages($application->fileStorages()->get()), + 'scheduled_tasks' => $this->exportScheduledTasks($application->scheduled_tasks()->get()), + 'tags' => $this->exportTags($application->tags), + 'previews' => $this->exportPreviews($application->previews), + 'source' => $source, + 'private_key_uuid' => $application->private_key?->uuid, + 'had_git_source' => filled($application->source_id), + 'had_private_key' => filled($application->private_key_id), + ]; + } + + /** + * @param array $destinationUuidByKey + * @return array + */ + private function exportDatabase(Model $database, array $destinationUuidByKey): array + { + $with = ['environment_variables', 'persistentStorages', 'fileStorages']; + if (method_exists($database, 'tags')) { + $with[] = 'tags'; + } + if (method_exists($database, 'scheduledBackups')) { + $with[] = 'scheduledBackups.s3'; + } + $database->loadMissing($with); + + $columns = Schema::getColumnListing($database->getTable()); + $attrs = []; + foreach ($database->getFillable() as $field) { + if (in_array($field, ['id', 'created_at', 'updated_at', 'deleted_at', 'environment_id', 'destination_id', 'destination_type'], true)) { + continue; + } + if (! in_array($field, $columns, true)) { + continue; + } + $attrs[$field] = $database->{$field}; + } + + // Secrets are often $hidden and sometimes omitted from $fillable (e.g. redis_password). + foreach ($columns as $column) { + if (array_key_exists($column, $attrs)) { + continue; + } + if (str_contains($column, 'password') || str_ends_with($column, '_password')) { + $attrs[$column] = $database->{$column}; + } + } + + $destinationKey = $database->destination_type.':'.$database->destination_id; + + return [ + 'type' => class_basename($database), + 'model' => $database::class, + 'uuid' => $database->uuid, + 'destination_uuid' => $destinationUuidByKey[$destinationKey] ?? null, + 'attributes' => $attrs, + 'environment_variables' => method_exists($database, 'environment_variables') + ? $this->exportEnvVars($database->environment_variables()->get()) + : [], + 'persistent_storages' => method_exists($database, 'persistentStorages') + ? $this->exportPersistentStorages($database->persistentStorages()->get()) + : [], + 'file_storages' => method_exists($database, 'fileStorages') + ? $this->exportFileStorages($database->fileStorages()->get()) + : [], + 'tags' => method_exists($database, 'tags') + ? $this->exportTags($database->tags) + : [], + 'scheduled_backups' => method_exists($database, 'scheduledBackups') + ? $this->exportScheduledBackups($database->scheduledBackups) + : [], + ]; + } + + /** + * @param array $destinationUuidByKey + * @return array + */ + private function exportService(Service $service, array $destinationUuidByKey): array + { + $service->loadMissing([ + 'environment_variables', + 'applications.environment_variables', + 'applications.persistentStorages', + 'applications.fileStorages', + 'databases.persistentStorages', + 'databases.fileStorages', + 'databases.scheduledBackups.s3', + 'scheduled_tasks', + 'tags', + ]); + + $columns = Schema::getColumnListing($service->getTable()); + $attrs = []; + foreach ($service->getFillable() as $field) { + if (in_array($field, ['id', 'created_at', 'updated_at', 'deleted_at', 'environment_id', 'destination_id', 'destination_type', 'server_id'], true)) { + continue; + } + if (! in_array($field, $columns, true)) { + continue; + } + $attrs[$field] = $service->{$field}; + } + + // Hidden compose fields + foreach (['docker_compose', 'docker_compose_raw'] as $hidden) { + if (in_array($hidden, $columns, true)) { + $attrs[$hidden] = $service->{$hidden}; + } + } + + $destinationKey = $service->destination_type.':'.$service->destination_id; + + return [ + 'type' => 'service', + 'uuid' => $service->uuid, + 'destination_uuid' => $destinationUuidByKey[$destinationKey] ?? null, + 'attributes' => $attrs, + 'environment_variables' => $this->exportEnvVars($service->environment_variables()->get()), + 'scheduled_tasks' => $this->exportScheduledTasks($service->scheduled_tasks()->get()), + 'tags' => $this->exportTags($service->tags), + 'applications' => $service->applications + ->map(fn ($app) => $this->exportServiceApplication($app)) + ->values() + ->all(), + 'databases' => $service->databases + ->map(fn ($db) => $this->exportServiceDatabase($db)) + ->values() + ->all(), + ]; + } + + /** + * @return array + */ + private function exportServiceApplication(Model $app): array + { + $columns = Schema::getColumnListing($app->getTable()); + $attrs = []; + foreach ($app->getFillable() as $field) { + if (in_array($field, ['id', 'created_at', 'updated_at', 'deleted_at', 'service_id'], true)) { + continue; + } + if (! in_array($field, $columns, true)) { + continue; + } + $attrs[$field] = $app->{$field}; + } + + return [ + 'uuid' => $app->uuid, + 'attributes' => $attrs, + // Flat fields kept for older bundles / readability + 'name' => $app->name, + 'fqdn' => $app->fqdn, + 'image' => $app->image ?? null, + 'ports' => $app->ports ?? null, + 'exposes' => $app->exposes ?? null, + 'exclude_from_status' => (bool) ($app->exclude_from_status ?? false), + 'required_fqdn' => (bool) ($app->required_fqdn ?? false), + 'human_name' => $app->human_name ?? null, + 'description' => $app->description ?? null, + 'is_log_drain_enabled' => (bool) ($app->is_log_drain_enabled ?? false), + 'is_include_timestamps' => (bool) ($app->is_include_timestamps ?? false), + 'is_gzip_enabled' => (bool) ($app->is_gzip_enabled ?? true), + 'is_stripprefix_enabled' => (bool) ($app->is_stripprefix_enabled ?? true), + 'environment_variables' => method_exists($app, 'environment_variables') + ? $this->exportEnvVars($app->environment_variables()->get()) + : [], + 'persistent_storages' => method_exists($app, 'persistentStorages') + ? $this->exportPersistentStorages($app->persistentStorages) + : [], + 'file_storages' => method_exists($app, 'fileStorages') + ? $this->exportFileStorages($app->fileStorages) + : [], + ]; + } + + /** + * @return array + */ + private function exportServiceDatabase(Model $db): array + { + $columns = Schema::getColumnListing($db->getTable()); + $attrs = []; + foreach ($db->getFillable() as $field) { + if (in_array($field, ['id', 'created_at', 'updated_at', 'deleted_at', 'service_id'], true)) { + continue; + } + if (! in_array($field, $columns, true)) { + continue; + } + $attrs[$field] = $db->{$field}; + } + + return [ + 'uuid' => $db->uuid, + 'attributes' => $attrs, + 'name' => $db->name, + 'human_name' => $db->human_name ?? null, + 'description' => $db->description ?? null, + 'image' => $db->image ?? null, + 'ports' => $db->ports ?? null, + 'exposes' => $db->exposes ?? null, + 'exclude_from_status' => (bool) ($db->exclude_from_status ?? false), + 'is_log_drain_enabled' => (bool) ($db->is_log_drain_enabled ?? false), + 'is_include_timestamps' => (bool) ($db->is_include_timestamps ?? false), + 'is_gzip_enabled' => (bool) ($db->is_gzip_enabled ?? true), + 'is_stripprefix_enabled' => (bool) ($db->is_stripprefix_enabled ?? true), + 'public_port' => $db->public_port ?? null, + 'public_port_timeout' => $db->public_port_timeout ?? null, + 'is_public' => (bool) ($db->is_public ?? false), + 'custom_type' => $db->custom_type ?? null, + 'persistent_storages' => method_exists($db, 'persistentStorages') + ? $this->exportPersistentStorages($db->persistentStorages) + : [], + 'file_storages' => method_exists($db, 'fileStorages') + ? $this->exportFileStorages($db->fileStorages) + : [], + 'scheduled_backups' => method_exists($db, 'scheduledBackups') + ? $this->exportScheduledBackups($db->scheduledBackups) + : [], + ]; + } + + /** + * Multi-server / multi-destination applications cannot be transferred safely: + * the bundle is per-server and additional destinations may live on other hosts. + * + * @param Collection $applications + */ + private function assertNoAdditionalDestinations(Server $server, Collection $applications): void + { + $involvesThisServer = DB::table('additional_destinations') + ->where('server_id', $server->id) + ->exists(); + + $appIds = $applications->pluck('id')->filter()->all(); + $involvesTheseApps = $appIds !== [] && DB::table('additional_destinations') + ->whereIn('application_id', $appIds) + ->exists(); + + if ($involvesThisServer || $involvesTheseApps) { + throw new RuntimeException( + 'This server cannot be transferred because one or more applications use additional destinations (multi-server / multi-destination deploy). ' + .'Remove all additional destinations from those applications, then retry the export.' + ); + } + } + + /** + * @param Collection|iterable $variables + * @return list> + */ + private function exportSharedEnvVars(iterable $variables): array + { + $out = []; + foreach ($variables as $variable) { + // Skip auto COOLIFY_SERVER_* — recreated on server create. + if (in_array($variable->key, ['COOLIFY_SERVER_UUID', 'COOLIFY_SERVER_NAME'], true)) { + continue; + } + $out[] = [ + 'key' => $variable->key, + 'value' => $variable->value, + 'is_multiline' => (bool) $variable->is_multiline, + 'is_literal' => (bool) $variable->is_literal, + 'is_shown_once' => (bool) $variable->is_shown_once, + 'comment' => $variable->comment, + ]; + } + + return $out; + } + + /** + * @param Collection|iterable $variables + * @return list> + */ + private function exportEnvVars(iterable $variables): array + { + $out = []; + foreach ($variables as $variable) { + $out[] = [ + 'uuid' => $variable->uuid, + 'key' => $variable->key, + 'value' => $variable->value, + 'is_literal' => (bool) $variable->is_literal, + 'is_multiline' => (bool) $variable->is_multiline, + 'is_preview' => (bool) $variable->is_preview, + 'is_runtime' => (bool) $variable->is_runtime, + 'is_buildtime' => (bool) $variable->is_buildtime, + 'is_shown_once' => (bool) $variable->is_shown_once, + 'is_shared' => (bool) $variable->is_shared, + 'is_required' => (bool) ($variable->is_required ?? false), + 'comment' => $variable->comment, + 'order' => $variable->order, + ]; + } + + return $out; + } + + /** + * @param Collection|iterable $volumes + * @return list> + */ + private function exportPersistentStorages(iterable $volumes): array + { + $out = []; + foreach ($volumes as $volume) { + $out[] = [ + 'uuid' => $volume->uuid, + 'name' => $volume->name, + 'mount_path' => $volume->mount_path, + 'host_path' => $volume->host_path, + 'is_preview_suffix_enabled' => (bool) ($volume->is_preview_suffix_enabled ?? false), + ]; + } + + return $out; + } + + /** + * @param Collection|iterable $storages + * @return list> + */ + private function exportFileStorages(iterable $storages): array + { + $out = []; + foreach ($storages as $storage) { + $out[] = [ + 'uuid' => $storage->uuid, + 'fs_path' => $storage->fs_path, + 'mount_path' => $storage->mount_path, + 'content' => $storage->content, + 'is_directory' => (bool) $storage->is_directory, + 'is_host_file' => (bool) $storage->is_host_file, + 'chown' => $storage->chown, + 'chmod' => $storage->chmod, + 'is_based_on_git' => (bool) ($storage->is_based_on_git ?? false), + 'is_preview_suffix_enabled' => (bool) ($storage->is_preview_suffix_enabled ?? false), + ]; + } + + return $out; + } + + /** + * @param Collection|iterable $tasks + * @return list> + */ + private function exportScheduledTasks(iterable $tasks): array + { + $out = []; + foreach ($tasks as $task) { + $out[] = [ + 'uuid' => $task->uuid, + 'name' => $task->name, + 'command' => $task->command, + 'frequency' => $task->frequency, + 'container' => $task->container, + 'timeout' => $task->timeout, + 'enabled' => (bool) ($task->enabled ?? true), + ]; + } + + return $out; + } + + /** + * @param Collection|iterable $tags + * @return list + */ + private function exportTags(iterable $tags): array + { + $out = []; + foreach ($tags as $tag) { + $out[] = [ + 'uuid' => $tag->uuid, + 'name' => $tag->name, + ]; + } + + return $out; + } + + /** + * @param Collection|iterable $backups + * @return list> + */ + private function exportScheduledBackups(iterable $backups): array + { + $out = []; + foreach ($backups as $backup) { + $out[] = [ + 'uuid' => $backup->uuid, + 'description' => $backup->description, + 'enabled' => (bool) $backup->enabled, + 'save_s3' => (bool) $backup->save_s3, + 'frequency' => $backup->frequency, + 'databases_to_backup' => $backup->databases_to_backup, + 'dump_all' => (bool) ($backup->dump_all ?? false), + 'database_backup_retention_amount_locally' => $backup->database_backup_retention_amount_locally, + 'database_backup_retention_days_locally' => $backup->database_backup_retention_days_locally, + 'database_backup_retention_max_storage_locally' => $backup->database_backup_retention_max_storage_locally, + 'database_backup_retention_amount_s3' => $backup->database_backup_retention_amount_s3, + 'database_backup_retention_days_s3' => $backup->database_backup_retention_days_s3, + 'database_backup_retention_max_storage_s3' => $backup->database_backup_retention_max_storage_s3, + 'timeout' => $backup->timeout, + 'disable_local_backup' => (bool) ($backup->disable_local_backup ?? false), + 's3_storage_uuid' => $backup->s3?->uuid, + 'had_s3_storage' => filled($backup->s3_storage_id), + ]; + } + + return $out; + } + + /** + * @return array + */ + private function exportGithubApp(GithubApp $app): array + { + return [ + 'uuid' => $app->uuid, + 'name' => $app->name, + 'organization' => $app->organization, + 'api_url' => $app->api_url, + 'html_url' => $app->html_url, + 'custom_user' => $app->custom_user, + 'custom_port' => $app->custom_port, + 'app_id' => $app->app_id, + 'installation_id' => $app->installation_id, + 'client_id' => $app->client_id, + 'client_secret' => $app->client_secret, + 'webhook_secret' => $app->webhook_secret, + 'is_system_wide' => false, + 'is_public' => (bool) $app->is_public, + 'contents' => $app->contents, + 'metadata' => $app->metadata, + 'pull_requests' => $app->pull_requests, + 'administration' => $app->administration, + 'private_key_uuid' => $app->privateKey?->uuid, + 'webhook_url_hint' => '/webhooks/source/github/events', + ]; + } + + /** + * @return array + */ + private function exportGitlabApp(GitlabApp $app): array + { + return [ + 'uuid' => $app->uuid, + 'name' => $app->name, + 'organization' => $app->organization, + 'api_url' => $app->api_url, + 'html_url' => $app->html_url, + 'custom_port' => $app->custom_port, + 'custom_user' => $app->custom_user, + 'is_system_wide' => false, + 'is_public' => (bool) $app->is_public, + 'app_id' => $app->app_id, + 'app_secret' => $app->app_secret, + 'oauth_id' => $app->oauth_id, + 'client_id' => $app->client_id, + 'client_secret' => $app->client_secret, + 'access_token' => $app->access_token, + 'refresh_token' => $app->refresh_token, + 'expires_at' => $app->expires_at, + 'redirect_uri' => $app->redirect_uri, + 'group_name' => $app->group_name, + 'public_key' => $app->public_key, + 'webhook_token' => $app->webhook_token, + 'deploy_key_id' => $app->deploy_key_id, + 'private_key_uuid' => $app->privateKey?->uuid, + 'webhook_url_hint' => '/webhooks/source/gitlab/events', + ]; + } + + /** + * @return array + */ + private function exportS3Storage(S3Storage $storage): array + { + return [ + 'uuid' => $storage->uuid, + 'name' => $storage->name, + 'description' => $storage->description, + 'region' => $storage->region, + 'key' => $storage->key, + 'secret' => $storage->secret, + 'bucket' => $storage->bucket, + 'endpoint' => $storage->endpoint, + 'is_usable' => (bool) $storage->is_usable, + ]; + } + + /** + * @return array + */ + private function exportCloudProviderToken(CloudProviderToken $token): array + { + return [ + 'uuid' => $token->uuid, + 'provider' => $token->provider, + 'token' => $token->token, + 'name' => $token->name, + 'description' => $token->description, + ]; + } + + /** + * @return list> + */ + private function exportSslCertificates(Server $server): array + { + $out = []; + foreach ($server->sslCertificates as $cert) { + $resourceUuid = null; + $resourceKind = null; + if ($cert->resource_type && $cert->resource_id) { + $resource = $cert->resource_type::find($cert->resource_id); + if ($resource && isset($resource->uuid)) { + $resourceUuid = $resource->uuid; + $resourceKind = match (true) { + $resource instanceof Application => 'application', + $resource instanceof Service => 'service', + default => class_basename($resource), + }; + } + } + + $out[] = [ + 'ssl_certificate' => $cert->ssl_certificate, + 'ssl_private_key' => $cert->ssl_private_key, + 'configuration_dir' => $cert->configuration_dir, + 'mount_path' => $cert->mount_path, + 'common_name' => $cert->common_name, + 'subject_alternative_names' => $cert->subject_alternative_names, + 'valid_until' => optional($cert->valid_until)?->toIso8601String(), + 'is_ca_certificate' => (bool) $cert->is_ca_certificate, + 'resource_kind' => $resourceKind, + 'resource_uuid' => $resourceUuid, + ]; + } + + return $out; + } + + /** + * @param Collection|iterable $previews + * @return list> + */ + private function exportPreviews(iterable $previews): array + { + $out = []; + foreach ($previews as $preview) { + $out[] = [ + 'uuid' => $preview->uuid, + 'pull_request_id' => $preview->pull_request_id, + 'pull_request_html_url' => $preview->pull_request_html_url, + 'pull_request_issue_comment_id' => $preview->pull_request_issue_comment_id, + 'fqdn' => $preview->fqdn, + 'status' => $preview->status, + 'git_type' => $preview->git_type, + 'docker_compose_domains' => $preview->docker_compose_domains, + 'docker_registry_image_tag' => $preview->docker_registry_image_tag, + 'persistent_storages' => method_exists($preview, 'persistentStorages') + ? $this->exportPersistentStorages($preview->persistentStorages) + : [], + ]; + } + + return $out; + } + + /** + * @param Collection $applications + * @param Collection $databases + * @param Collection $services + * @return list> + */ + private function exportVolumeBackups(Collection $applications, Collection $databases, Collection $services): array + { + $out = []; + $seen = []; + + $collectFrom = function ($resource) use (&$out, &$seen) { + $storages = collect(); + if (method_exists($resource, 'persistentStorages')) { + $storages = $storages->merge($resource->persistentStorages); + } + if (method_exists($resource, 'fileStorages')) { + $storages = $storages->merge($resource->fileStorages); + } + foreach ($storages as $storage) { + if (! method_exists($storage, 'scheduledBackups')) { + continue; + } + foreach ($storage->scheduledBackups as $backup) { + if (isset($seen[$backup->id])) { + continue; + } + $seen[$backup->id] = true; + $kind = $storage instanceof LocalFileVolume ? 'file_volume' : 'persistent_volume'; + $out[] = [ + 'uuid' => $backup->uuid, + 'backupable_kind' => $kind, + 'backupable_uuid' => $storage->uuid, + 'frequency' => $backup->frequency, + 'enabled' => (bool) $backup->enabled, + 'save_s3' => (bool) $backup->save_s3, + 'disable_local_backup' => (bool) $backup->disable_local_backup, + 'stop_during_backup' => (bool) ($backup->stop_during_backup ?? false), + 'retention_amount_locally' => $backup->retention_amount_locally, + 'retention_days_locally' => $backup->retention_days_locally, + 'retention_max_storage_locally' => $backup->retention_max_storage_locally, + 'retention_amount_s3' => $backup->retention_amount_s3, + 'retention_days_s3' => $backup->retention_days_s3, + 'retention_max_storage_s3' => $backup->retention_max_storage_s3, + 'timeout' => $backup->timeout, + 's3_storage_uuid' => $backup->s3?->uuid, + 'had_s3_storage' => filled($backup->s3_storage_id), + ]; + } + } + }; + + foreach ($applications as $application) { + $application->loadMissing([ + 'persistentStorages.scheduledBackups.s3', + 'fileStorages.scheduledBackups.s3', + 'previews.persistentStorages.scheduledBackups.s3', + ]); + $collectFrom($application); + foreach ($application->previews as $preview) { + $collectFrom($preview); + } + } + foreach ($databases as $database) { + if (method_exists($database, 'loadMissing')) { + $database->loadMissing([ + 'persistentStorages.scheduledBackups.s3', + 'fileStorages.scheduledBackups.s3', + ]); + } + $collectFrom($database); + } + foreach ($services as $service) { + $service->loadMissing([ + 'applications.persistentStorages.scheduledBackups.s3', + 'applications.fileStorages.scheduledBackups.s3', + 'databases.persistentStorages.scheduledBackups.s3', + 'databases.fileStorages.scheduledBackups.s3', + ]); + foreach ($service->applications as $serviceApp) { + $collectFrom($serviceApp); + } + foreach ($service->databases as $serviceDb) { + $collectFrom($serviceDb); + } + } + + return $out; + } + + /** + * @param Collection $applications + * @param Collection $databases + * @param Collection $services + * @return list + */ + private function collectProjectIds(Collection $applications, Collection $databases, Collection $services): array + { + $environmentIds = $applications->pluck('environment_id') + ->merge($databases->pluck('environment_id')) + ->merge($services->pluck('environment_id')) + ->filter() + ->unique() + ->values(); + + if ($environmentIds->isEmpty()) { + return []; + } + + return Environment::query() + ->whereIn('id', $environmentIds) + ->pluck('project_id') + ->unique() + ->values() + ->all(); + } + + /** + * @return array + */ + private function destinationUuidMap(Server $server): array + { + $map = []; + foreach ($server->standaloneDockers as $destination) { + $map[StandaloneDocker::class.':'.$destination->id] = $destination->uuid; + } + foreach ($server->swarmDockers as $destination) { + $map[SwarmDocker::class.':'.$destination->id] = $destination->uuid; + } + + return $map; + } +} diff --git a/app/Services/ServerTransfer/ServerTransferImporter.php b/app/Services/ServerTransfer/ServerTransferImporter.php new file mode 100644 index 000000000..b341725f9 --- /dev/null +++ b/app/Services/ServerTransfer/ServerTransferImporter.php @@ -0,0 +1,1604 @@ + + */ + 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, + ]; + + /** @var array */ + private array $privateKeyMap = []; + + /** @var array */ + private array $githubAppMap = []; + + /** @var array */ + private array $gitlabAppMap = []; + + /** @var array */ + private array $s3StorageMap = []; + + /** @var array */ + private array $cloudTokenMap = []; + + /** @var array */ + private array $volumeMap = []; + + /** @var array */ + private array $applicationMap = []; + + /** @var array */ + private array $serviceMap = []; + + /** @var array */ + private array $databaseMap = []; + + /** + * @param array $bundle + * @return array{ + * dry_run: bool, + * warnings: list, + * server_uuid: string|null, + * private_key_uuid: string|null, + * created: array, + * preserved_uuids: bool, + * export_id: string|null, + * claimed: bool, + * claim: array|null + * } + */ + public function import( + array $bundle, + int $teamId, + bool $dryRun = false, + bool $preserveUuids = true, + bool $adoptMode = true, + bool $claim = true, + bool $writeRemote = false, + bool $rebindSentinel = true, + ): array { + ServerTransferBundle::assertValid($bundle); + + $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'); + + $uuidConflict = Server::withTrashed()->where('uuid', $serverUuid)->exists() && $preserveUuids; + $existingIp = Server::where('ip', $serverIp)->first(); + $ipConflict = $existingIp !== null; + + if (! $dryRun && $uuidConflict) { + throw ValidationException::withMessages([ + 'server.uuid' => ["A server with UUID {$serverUuid} already exists on this instance. Delete/transfer it first, or import with preserve_uuids=false."], + ]); + } + + if (! $dryRun && $ipConflict) { + throw ValidationException::withMessages([ + 'server.ip' => ["A server with IP/domain {$serverIp} already exists (uuid={$existingIp->uuid}). Complete handoff on the source instance first."], + ]); + } + + if ($uuidConflict) { + $warnings[] = "A server with UUID {$serverUuid} already exists on this instance."; + } + if ($ipConflict) { + $warnings[] = "A server with IP/domain {$serverIp} already exists (uuid={$existingIp->uuid})."; + } + + $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, + '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['ssl_certificates'] = count(data_get($bundle, 'ssl_certificates', [])); + $created['volume_backups'] = count(data_get($bundle, 'volume_backups', [])); + + // Append transfer hints for the target instance FQDN. + $targetUrl = rtrim((string) (instanceSettings()->fqdn ?: config('app.url')), '/'); + if (count(data_get($bundle, 'github_apps', [])) > 0) { + $warnings[] = "After import, set GitHub App webhook URL to {$targetUrl}/webhooks/source/github/events (and update setup/callback URLs in GitHub if needed) so automations work on this instance."; + } + if (count(data_get($bundle, 'gitlab_apps', [])) > 0) { + $warnings[] = "After import, set GitLab webhook URL to {$targetUrl}/webhooks/source/gitlab/events so automations work on this instance."; + } + + 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, + ]; + } + + $result = DB::transaction(function () use ($bundle, $teamId, $preserveUuids, $adoptMode, $warnings, &$created) { + $this->privateKeyMap = []; + $this->githubAppMap = []; + $this->gitlabAppMap = []; + $this->s3StorageMap = []; + $this->cloudTokenMap = []; + $this->volumeMap = []; + $this->applicationMap = []; + $this->serviceMap = []; + $this->databaseMap = []; + + // 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']++; + } + + $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->importServerSharedEnvVars(data_get($bundle, 'shared_environment_variables.server', []), $server, $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']++; + } + } + } + + $created['ssl_certificates'] = $this->importSslCertificates(data_get($bundle, 'ssl_certificates', []), $server); + $created['volume_backups'] = $this->importVolumeBackups(data_get($bundle, 'volume_backups', []), $teamId); + + $targetUrl = rtrim((string) (instanceSettings()->fqdn ?: config('app.url')), '/'); + if ($created['github_apps'] > 0) { + $warnings[] = "GitHub Apps imported. Update webhook URL to {$targetUrl}/webhooks/source/github/events (and setup/callback URLs in GitHub) so automations work on this instance."; + } + if ($created['gitlab_apps'] > 0) { + $warnings[] = "GitLab sources imported. Update webhook URL to {$targetUrl}/webhooks/source/gitlab/events so automations work on this instance."; + } + + $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, + ]; + $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, + ]; + }); + + // Claim after the import transaction commits so host/SSH work cannot roll back DB rows. + if ($claim && filled(data_get($result, 'server_uuid'))) { + $server = Server::where('uuid', $result['server_uuid'])->where('team_id', $teamId)->first(); + if ($server) { + try { + $claimResult = app(ServerTransferClaimer::class)->claim( + $server, + writeRemote: $writeRemote, + rebindSentinel: $rebindSentinel, + ); + $result['claimed'] = true; + $result['claim'] = $claimResult; + if (! data_get($claimResult, 'claim_written') && $writeRemote) { + $result['warnings'][] = 'Server imported and claimed in Coolify, but the remote ownership file was not written (SSH unavailable).'; + } + } catch (Throwable $e) { + $result['claimed'] = false; + $result['claim'] = null; + $result['warnings'][] = 'Server imported, but automatic claim failed: '.$e->getMessage(); + } + } + } + + return $result; + } + + /** + * @param array $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) { + $existing = PrivateKey::query()->where('fingerprint', $fingerprint)->first(); + if ($existing) { + if ((int) $existing->team_id !== $teamId) { + throw ValidationException::withMessages([ + 'private_key' => ['This SSH private key already exists on another team on this instance.'], + ]); + } + + 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 $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, + ]); + $server->uuid = $uuid; + $server->save(); + + if ($server->settings && data_get($payload, 'is_build_server')) { + $server->settings->is_build_server = true; + $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', + ]) + ->all(); + $server->settings->fill($safe); + // 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> $destinations + * @return array 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> $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, + ]); + } + } + + /** + * @param array $payload + * @param array $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(); + + $existing = Project::where('uuid', $uuid)->where('team_id', $teamId)->first(); + if ($existing) { + $this->importProjectSharedEnvVars(data_get($payload, 'shared_environment_variables', []), $existing, $teamId); + + 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); + + return $project->fresh(['environments']); + } + + /** + * @param list> $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 $payload + * @param array $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> $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, + ]); + } + } + + /** + * @param array $payload + * @param array $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(); + + if (Application::withTrashed()->where('uuid', $uuid)->exists()) { + $uuid = new_public_id(); + } + + $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(); + + // 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; + } + } + } + + // 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 ($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 = Application::create($attributes); + + $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); + + return $application; + } + + /** + * @param array $payload + * @param array $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] + ?? (is_string(data_get($payload, 'model')) && class_exists((string) data_get($payload, 'model')) + ? (string) data_get($payload, 'model') + : null); + + if (! $modelClass || ! class_exists($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(); + + if ($modelClass::withTrashed()->where('uuid', $uuid)->exists()) { + $uuid = new_public_id(); + } + + $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); + + return $database; + } + + /** + * @param array $payload + * @param array $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(); + + if (Service::withTrashed()->where('uuid', $uuid)->exists()) { + $uuid = new_public_id(); + } + + $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 = Service::create($attributes); + + $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); + + foreach (data_get($payload, 'applications', []) as $appPayload) { + $this->importServiceApplication($appPayload, $service, $preserveUuids, $adoptMode); + } + + foreach (data_get($payload, 'databases', []) as $dbPayload) { + $this->importServiceDatabase($dbPayload, $service, $preserveUuids, $adoptMode, $teamId); + } + + return $service; + } + + /** + * @param array $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 $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> $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), + 'comment' => data_get($variable, 'comment'), + 'order' => data_get($variable, 'order'), + 'resourceable_type' => $resource->getMorphClass(), + 'resourceable_id' => $resource->id, + ]); + $env->uuid = $uuid; + $env->save(); + }); + } + } + + /** + * @param list> $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), + '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; + } + } + } + + /** + * @param list> $storages + */ + private function importFileStorages(array $storages, object $resource): void + { + foreach ($storages as $storage) { + LocalFileVolume::withoutEvents(function () use ($storage, $resource) { + $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' => data_get($storage, 'fs_path'), + 'mount_path' => data_get($storage, 'mount_path'), + 'content' => data_get($storage, 'content'), + 'is_directory' => (bool) data_get($storage, 'is_directory', false), + 'is_host_file' => (bool) data_get($storage, 'is_host_file', false), + 'chown' => data_get($storage, 'chown'), + 'chmod' => data_get($storage, '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> $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 $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> $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(); + } + + $s3Id = null; + $s3Uuid = data_get($backup, 's3_storage_uuid'); + if ($s3Uuid && isset($this->s3StorageMap[$s3Uuid])) { + $s3Id = $this->s3StorageMap[$s3Uuid]->id; + } + + 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), + 's3_storage_id' => $s3Id, + 'database_type' => $database->getMorphClass(), + 'database_id' => $database->id, + ]); + } + } + + /** + * @param array $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'), + ]); + $app->uuid = $uuid; + $app->save(); + + return $app; + } + + /** + * @param array $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 $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 $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; + } + + /** + * @param list> $previews + */ + private function importPreviews(array $previews, Application $application, bool $preserveUuids, bool $adoptMode): 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) { + $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; free it from other rows (including soft-deleted leftovers). + if (filled($fqdn)) { + ApplicationPreview::withoutEvents(function () use ($fqdn, $preview) { + ApplicationPreview::withTrashed() + ->where('fqdn', $fqdn) + ->when($preview, fn ($q) => $q->where('id', '!=', $preview->id)) + ->get() + ->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'), + ]; + + 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(); + } + + // Avoid duplicating volumes when re-importing the same preview. + if ($preview->wasRecentlyCreated || $preview->persistentStorages()->count() === 0) { + $this->importPersistentStorages(data_get($previewPayload, 'persistent_storages', []), $preview); + } + } + } + + /** + * @param list> $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> $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(); + } + + $s3Id = null; + $s3Uuid = data_get($backupPayload, 's3_storage_uuid'); + if ($s3Uuid && isset($this->s3StorageMap[$s3Uuid])) { + $s3Id = $this->s3StorageMap[$s3Uuid]->id; + } + + 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'), + ]); + $count++; + } + + return $count; + } + + /** + * @param array $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 $attributes + * @return array + */ + 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(); + } +} diff --git a/app/Services/ServerTransfer/ServerTransferMigrator.php b/app/Services/ServerTransfer/ServerTransferMigrator.php new file mode 100644 index 000000000..f74dc8cc3 --- /dev/null +++ b/app/Services/ServerTransfer/ServerTransferMigrator.php @@ -0,0 +1,178 @@ +, + * complete: array, + * warnings: list, + * message: string + * } + */ + public function migrate( + Server $server, + string $targetUrl, + string $targetToken, + bool $writeRemote = false, + bool $rebindSentinel = true, + bool $preserveUuids = true, + bool $adoptMode = true, + ): array { + if ($server->id === 0) { + throw new RuntimeException('The Coolify host (localhost) cannot be transferred between instances.'); + } + + $targetUrl = $this->normalizeTargetUrl($targetUrl); + $token = $this->normalizeToken($targetToken); + + $bundle = $this->exporter->export($server, includeSensitive: true); + $exportId = data_get($bundle, 'export_id'); + $warnings = array_values((array) data_get($bundle, 'warnings', [])); + + // Target import+claim runs in its own DB transaction on the remote instance. + // Source DB is unchanged until markTransferred below — so a failed remote import leaves source intact. + $importBody = $this->postImportToTarget( + targetUrl: $targetUrl, + token: $token, + bundle: $bundle, + writeRemote: $writeRemote, + rebindSentinel: $rebindSentinel, + preserveUuids: $preserveUuids, + adoptMode: $adoptMode, + ); + + if (is_array(data_get($importBody, 'warnings'))) { + $warnings = array_values(array_unique(array_merge($warnings, $importBody['warnings']))); + } + + // Cross-instance 2PC is impossible: if complete fails after a successful import, the target + // already owns the server. Surface that clearly so the operator can retry complete only. + try { + $complete = $this->claimer->markTransferred( + $server, + exportId: is_string($exportId) ? $exportId : null, + targetInstanceUrl: $targetUrl, + ); + } catch (Throwable $e) { + throw new RuntimeException( + "Server was imported on {$targetUrl}, but this instance could not mark it as transferred: {$e->getMessage()}. ". + 'Retry complete (API: POST /api/v1/servers/{uuid}/complete) so automations stay disabled here. Do not re-import on the target.', + previous: $e + ); + } + + return [ + 'server_uuid' => $server->uuid, + 'export_id' => is_string($exportId) ? $exportId : null, + 'target_url' => $targetUrl, + 'import' => $importBody, + 'complete' => $complete, + 'warnings' => $warnings, + 'message' => 'Server migrated to '.$targetUrl.'. Automations disabled on this instance.', + ]; + } + + private function normalizeTargetUrl(string $targetUrl): string + { + $targetUrl = rtrim(trim($targetUrl), '/'); + if ($targetUrl === '' || ! filter_var($targetUrl, FILTER_VALIDATE_URL)) { + throw new RuntimeException('A valid target instance URL is required (e.g. http://localhost:8001).'); + } + + // From inside Docker, localhost is this container — use the host gateway for peer instances. + if (file_exists('/.dockerenv') || is_file('/run/.containerenv')) { + $targetUrl = (string) preg_replace( + '#^(https?://)(localhost|127\.0\.0\.1)(?=[:/]|$)#i', + '$1host.docker.internal', + $targetUrl + ); + } + + return $targetUrl; + } + + private function normalizeToken(string $targetToken): string + { + $token = trim($targetToken); + if (str_starts_with(strtolower($token), 'bearer ')) { + $token = trim(substr($token, 7)); + } + if ($token === '') { + throw new RuntimeException('A target instance API token is required (root or write + create servers).'); + } + + return $token; + } + + /** + * @param array $bundle + * @return array + */ + private function postImportToTarget( + string $targetUrl, + string $token, + array $bundle, + bool $writeRemote, + bool $rebindSentinel, + bool $preserveUuids, + bool $adoptMode, + ): array { + $importUrl = $targetUrl.'/api/v1/servers/import'; + + try { + $response = Http::timeout(120) + ->acceptJson() + ->withToken($token) + ->asJson() + ->post($importUrl, [ + 'bundle' => $bundle, + 'dry_run' => false, + 'preserve_uuids' => $preserveUuids, + 'adopt_mode' => $adoptMode, + 'claim' => true, + 'write_remote' => $writeRemote, + 'rebind_sentinel' => $rebindSentinel, + ]); + } catch (ConnectionException $e) { + throw new RuntimeException("Could not reach target instance at {$importUrl}: {$e->getMessage()}", previous: $e); + } catch (Throwable $e) { + throw new RuntimeException("Transfer to target failed: {$e->getMessage()}", previous: $e); + } + + $importBody = $response->json(); + if (! is_array($importBody)) { + $importBody = ['raw' => $response->body()]; + } + + if (! $response->successful()) { + $message = data_get($importBody, 'message') + ?? data_get($importBody, 'errors') + ?? $response->body(); + if (is_array($message)) { + $message = json_encode($message); + } + throw new RuntimeException("Target import failed (HTTP {$response->status()}): {$message}"); + } + + return $importBody; + } +} diff --git a/docker-compose.dev-multi.yml b/docker-compose.dev-multi.yml new file mode 100644 index 000000000..a25b52373 --- /dev/null +++ b/docker-compose.dev-multi.yml @@ -0,0 +1,225 @@ +# Multi-instance Coolify (isolated project/network/volumes per instance). +# +# ./scripts/dev-instances up # a + b +# ./scripts/dev-instances urls +# ./scripts/dev-instances down +# +# Manual: +# docker compose -p coolify-a -f docker-compose.dev-multi.yml --env-file .dev-instances/a.env up -d +# +# Optional profiles: vite, mailpit, minio, testing-host +# ./scripts/dev-instances up a --with vite mailpit +# +# Ports (a / b): app 8000/8001, db 5432/5433, redis 6379/6380, soketi 6001/6011 + +services: + coolify: + image: coolify:dev + pull_policy: never + build: + context: . + dockerfile: ./docker/development/Dockerfile + args: + - USER_ID=${USERID:-1000} + - GROUP_ID=${GROUPID:-1000} + - COOLIFY_FLUX_VERSION=${COOLIFY_FLUX_VERSION:-nightly} + - COOLIFY_FLUX_CHECKSUM=${COOLIFY_FLUX_CHECKSUM:-unknown} + - COOLIFY_CLI_VERSION=${COOLIFY_CLI_VERSION:-nightly} + - COOLIFY_CLI_CHECKSUM=${COOLIFY_CLI_CHECKSUM:-unknown} + ports: + - "${APP_PORT:-8000}:8080" + - "${FORWARD_FLUX_PORT:-6443}:6443" + extra_hosts: + - "host.docker.internal:host-gateway" + environment: + AUTORUN_ENABLED: "${AUTORUN_ENABLED:-false}" + APP_NAME: "${APP_NAME:-Coolify}" + APP_URL: "${APP_URL:-http://localhost:8000}" + APP_KEY: "${APP_KEY:-}" + APP_ENV: "${APP_ENV:-local}" + APP_DEBUG: "${APP_DEBUG:-true}" + DB_CONNECTION: pgsql + DB_HOST: postgres + DB_PORT: 5432 + DB_DATABASE: "${DB_DATABASE:-coolify}" + DB_USERNAME: "${DB_USERNAME:-coolify}" + DB_PASSWORD: "${DB_PASSWORD:-password}" + REDIS_HOST: redis + REDIS_PORT: 6379 + REDIS_PASSWORD: "${REDIS_PASSWORD:-null}" + COOLIFY_CONTAINER_ROLE: all + PUSHER_HOST: "" + PUSHER_PORT: "" + PUSHER_SCHEME: http + PUSHER_APP_ID: "${PUSHER_APP_ID:-coolify}" + PUSHER_APP_KEY: "${PUSHER_APP_KEY:-coolify}" + PUSHER_APP_SECRET: "${PUSHER_APP_SECRET:-coolify}" + BROADCAST_CONNECTION: pusher + QUEUE_CONNECTION: redis + CACHE_STORE: redis + SESSION_DRIVER: redis + healthcheck: + test: curl -sf http://127.0.0.1:8080/api/health || exit 1 + interval: 5s + retries: 10 + timeout: 2s + volumes: + - .:/var/www/html/:cached + - backups_data:/var/www/html/storage/app/backups + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + networks: + - coolify + + postgres: + image: postgres:15-alpine + pull_policy: always + ports: + - "${FORWARD_DB_PORT:-5432}:5432" + environment: + POSTGRES_USER: "${DB_USERNAME:-coolify}" + POSTGRES_PASSWORD: "${DB_PASSWORD:-password}" + POSTGRES_DB: "${DB_DATABASE:-coolify}" + POSTGRES_HOST_AUTH_METHOD: "trust" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 5s + retries: 10 + timeout: 2s + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - coolify + + redis: + image: redis:7-alpine + pull_policy: always + ports: + - "${FORWARD_REDIS_PORT:-6379}:6379" + healthcheck: + test: redis-cli ping + interval: 5s + retries: 10 + timeout: 2s + volumes: + - redis_data:/data + networks: + - coolify + + soketi: + image: coolify-realtime:dev + pull_policy: never + build: + context: . + dockerfile: ./docker/coolify-realtime/Dockerfile + ports: + - "${FORWARD_SOKETI_PORT:-6001}:6001" + - "${FORWARD_SOKETI_PORT_ALT:-6002}:6002" + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + - ./storage:/var/www/html/storage + - ./docker/coolify-realtime/terminal-server.js:/terminal/terminal-server.js + - ./docker/coolify-realtime/terminal-utils.js:/terminal/terminal-utils.js + environment: + SOKETI_DEBUG: "false" + SOKETI_DEFAULT_APP_ID: "${PUSHER_APP_ID:-coolify}" + SOKETI_DEFAULT_APP_KEY: "${PUSHER_APP_KEY:-coolify}" + SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET:-coolify}" + SOKETI_HOST: "0.0.0.0" + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:6001/ready && curl -fsS http://127.0.0.1:6002/ready || exit 1"] + interval: 5s + retries: 10 + timeout: 2s + entrypoint: ["/bin/sh", "/soketi-entrypoint.sh"] + networks: + - coolify + + vite: + profiles: ["vite"] + image: node:24-alpine + pull_policy: always + working_dir: /var/www/html + environment: + VITE_HOST: localhost + VITE_PORT: "${VITE_PORT:-5173}" + ports: + - "${VITE_PORT:-5173}:${VITE_PORT:-5173}" + volumes: + - .:/var/www/html/:cached + command: sh -c "npm install && npm run dev -- --port ${VITE_PORT:-5173} --host" + networks: + - coolify + + testing-host: + profiles: ["testing-host"] + image: coolify-testing-host:dev + pull_policy: never + build: + context: . + dockerfile: ./docker/testing-host/Dockerfile + init: true + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - coolify_data:/data/coolify + - backups_data:/data/coolify/backups + - postgres_data:/data/coolify/_volumes/database + - redis_data:/data/coolify/_volumes/redis + - minio_data:/data/coolify/_volumes/minio + networks: + - coolify + + mailpit: + profiles: ["mailpit"] + image: axllent/mailpit:latest + pull_policy: always + ports: + - "${FORWARD_MAILPIT_PORT:-1025}:1025" + - "${FORWARD_MAILPIT_DASHBOARD_PORT:-8025}:8025" + networks: + - coolify + + minio: + profiles: ["minio"] + image: coollabsio/maxio:latest + pull_policy: always + ports: + - "${FORWARD_MINIO_PORT:-9000}:9000" + - "${FORWARD_MINIO_PORT_CONSOLE:-9001}:9001" + environment: + MINIO_ACCESS_KEY: minioadmin + MINIO_SECRET_KEY: minioadmin + volumes: + - minio_data:/data + networks: + - coolify + + minio-init: + profiles: ["minio"] + image: minio/mc:latest + pull_policy: always + restart: "no" + depends_on: + - minio + entrypoint: > + /bin/sh -c " + until mc alias set local http://minio:9000 minioadmin minioadmin 2>/dev/null; do sleep 2; done; + mc mb local/local --ignore-existing; + " + networks: + - coolify + +volumes: + backups_data: + postgres_data: + redis_data: + coolify_data: + minio_data: + +networks: + coolify: + driver: bridge diff --git a/docs/dev-only-features.md b/docs/dev-only-features.md index 2b7a67cb4..58813f0c2 100644 --- a/docs/dev-only-features.md +++ b/docs/dev-only-features.md @@ -21,3 +21,20 @@ Resource migration is under development and is not available in production. Before promoting this feature, remove all three runtime gates together and update the tests and this document in the same change. + +## Server transfer between Coolify instances + +Server transfer is under development and is not available in production. + +- **UI:** Transfer and import links are rendered only when `isDev()` returns + `true`, and the transfer Livewire components return `404 Not Found` outside + development mode. Public Livewire actions repeat the check before doing work. +- **API:** Every endpoint handled by `ServerTransferController` returns `404 Not + Found` unless `isDev()` returns `true`. +- **Tests:** Development-mode behavior and production isolation for all API + endpoints, UI routes, and navigation links are covered by + `tests/Feature/Api/ServerTransferApiTest.php` and + `tests/Feature/Livewire/ServerTransferUiTest.php`. + +Before promoting this feature, remove the API and UI runtime gates together and +update the tests and this document in the same change. diff --git a/resources/views/components/server/sidebar.blade.php b/resources/views/components/server/sidebar.blade.php index 1d6f07310..d0254054b 100644 --- a/resources/views/components/server/sidebar.blade.php +++ b/resources/views/components/server/sidebar.blade.php @@ -141,6 +141,14 @@ ['label' => 'Terminal Access', 'route' => 'server.security.terminal-access', 'active' => request()->routeIs('server.security.terminal-access'), 'icon' => 'browser-terminal', 'navigate' => false], ], ], + [ + 'label' => 'Transfer', + 'route' => 'server.transfer', + 'active' => $activeMenu === 'transfer', + 'icon' => 'arrow-right', + 'group' => 'Operations', + 'visible' => isDev() && ! $server->isLocalhost() && auth()->user()?->can('view', $server), + ], [ 'label' => 'Danger', 'route' => 'server.delete', diff --git a/resources/views/livewire/server/index.blade.php b/resources/views/livewire/server/index.blade.php index 9faae18c5..1054ec57f 100644 --- a/resources/views/livewire/server/index.blade.php +++ b/resources/views/livewire/server/index.blade.php @@ -5,27 +5,48 @@

Servers

- @can('createAnyResource') - - - New server - - @endcan +
+ @if (isDev()) + @can('create', App\Models\Server::class) + + + Import transfer + + + @endcan + @endif + @can('createAnyResource') + + + New server + + @endcan +
@php $serverRows = $servers->map(function ($server) { + $isTransferredAway = $server->isTransferredAway(); $isReady = $server->settings->is_reachable && $server->settings->is_usable - && ! $server->settings->force_disabled; + && ! $server->settings->force_disabled + && ! $isTransferredAway; + + $status = match (true) { + $isTransferredAway => 'Transferred away', + $server->settings->force_disabled => 'Disabled', + $isReady => 'Ready', + default => 'Validation required', + }; return [ 'uuid' => $server->uuid, 'name' => $server->name, 'description' => $server->description ?: 'No description', 'href' => route('server.show', ['server_uuid' => $server->uuid]), - 'status' => $isReady ? 'Ready' : ($server->settings->force_disabled ? 'Disabled' : 'Validation required'), + 'status' => $status, 'statusType' => $isReady ? 'success' : 'error', 'ready' => $isReady, ]; diff --git a/resources/views/livewire/server/show.blade.php b/resources/views/livewire/server/show.blade.php index de1358957..3bf29a918 100644 --- a/resources/views/livewire/server/show.blade.php +++ b/resources/views/livewire/server/show.blade.php @@ -89,8 +89,12 @@ @endif @endif - + @if ($server->isTransferredAway()) + + @else + + @endif
@@ -103,7 +107,9 @@ {{ $server->name }}

- @if ($server->isFunctional()) + @if ($server->isTransferredAway()) + This server was migrated away from this Coolify instance and cannot be managed here. + @elseif ($server->isFunctional()) The server is reachable, validated, and ready to host resources. @else Validate the SSH connection before using this server. @@ -206,6 +212,14 @@ + @if ($server->isTransferredAway()) + + This server was migrated away from this Coolify instance. It cannot be revalidated or + managed here. Use the target instance, or delete this server when you no longer need the + archive. + + @endif + @if ($this->limaStartCommand) + + Import server transfer | Coolify + +

+
+

Import server transfer

+ + Back to servers + +
+
+ Paste or upload a transfer bundle exported from another Coolify instance. This creates the server and its + resources under the current team and claims the host for this instance + (control-plane only — host data stays on the machine). +
+ +
+ + + +
+ + + +
+
+ + Dry run + Checking… + + + Import server + Importing… + +
+
+ + @if (count($lastWarnings) > 0) +
+
Warnings
+
    + @foreach ($lastWarnings as $warning) +
  • {{ $warning }}
  • + @endforeach +
+
+ @endif + + @if ($lastResult) +
+
+ {{ data_get($lastResult, 'dry_run') ? 'Dry-run result' : 'Import result' }} +
+ @if ($importedServerUuid) +
+ Server UUID: {{ $importedServerUuid }} + @if (data_get($lastResult, 'claimed')) + Claimed + @endif + + Open server + + + Transfer details + +
+ @endif +
{{ json_encode($lastResult, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}
+
+ @endif +
+
diff --git a/resources/views/livewire/server/transfer.blade.php b/resources/views/livewire/server/transfer.blade.php new file mode 100644 index 000000000..648046812 --- /dev/null +++ b/resources/views/livewire/server/transfer.blade.php @@ -0,0 +1,146 @@ +
+ + {{ data_get_str($server, 'name')->limit(10) }} > Transfer | Coolify + + + + +
+ + +
+ @if ($this->isLocalhost) + + + The Coolify host (localhost) cannot be transferred between instances. + + + @else + + + + @if ($exportId) + export {{ $exportId }} + @endif + + +
+
+

Transfer to another instance +

+

+ Enter the target Coolify URL and an API token from that instance (root recommended). + This exports the server, imports and claims it on the target, then disables automations + here. +

+
+
+ + + +
+
+ + Transfer server + Transferring… + +
+ @if (count($lastWarnings) > 0) + +
    + @foreach ($lastWarnings as $warning) +
  • {{ $warning }}
  • + @endforeach +
+
+ @endif + @if ($lastResultJson) +
+
Result
+
{{ $lastResultJson }}
+
+ @endif +
+
+ + +
+ + +
+
+

Download bundle

+

+ Manual / air-gapped transfer. Import on the target via Servers → Import transfer. +

+
+ + +
+
+ + Download JSON + Exporting… + + + Import page (this instance) + +
+
+ +
+

Complete only

+

+ Disable automations after a manual import on the target. +

+ + Mark transferred & disable automations + +
+ +
+

Re-claim only

+

+ Retry claim on this instance (after a local import). +

+
+ + +
+ + Re-claim + +
+
+
+
+ @endif +
+
+
diff --git a/routes/api.php b/routes/api.php index 1a01725f5..4a7a71afe 100644 --- a/routes/api.php +++ b/routes/api.php @@ -25,6 +25,7 @@ use App\Http\Controllers\Api\ServerLogDrainsController; use App\Http\Controllers\Api\ServerProxyController; use App\Http\Controllers\Api\ServersController; use App\Http\Controllers\Api\ServerSentinelController; +use App\Http\Controllers\Api\ServerTransferController; use App\Http\Controllers\Api\ServiceApplicationsController; use App\Http\Controllers\Api\ServiceDatabasesController; use App\Http\Controllers\Api\ServicesController; @@ -189,6 +190,12 @@ Route::group([ Route::post('/servers/{uuid}/proxy/restart', [ServerProxyController::class, 'restart'])->middleware(['api.ability:write']); Route::post('/servers', [ServersController::class, 'create_server'])->middleware(['api.ability:write']); + Route::post('/servers/import', [ServerTransferController::class, 'import'])->middleware(['api.ability:write']); + Route::get('/servers/{uuid}/export', [ServerTransferController::class, 'export'])->middleware(['api.ability:read']); + Route::post('/servers/{uuid}/export/mailbox', [ServerTransferController::class, 'writeMailbox'])->middleware(['api.ability:write']); + Route::post('/servers/{uuid}/claim', [ServerTransferController::class, 'claim'])->middleware(['api.ability:write']); + Route::post('/servers/{uuid}/transfer/complete', [ServerTransferController::class, 'complete'])->middleware(['api.ability:write']); + Route::post('/servers/{uuid}/migrate', [ServerTransferController::class, 'migrate'])->middleware(['api.ability:write']); Route::patch('/servers/{uuid}', [ServersController::class, 'update_server'])->middleware(['api.ability:write']); Route::delete('/servers/{uuid}', [ServersController::class, 'delete_server'])->middleware(['api.ability:write']); diff --git a/routes/web.php b/routes/web.php index 40869b4b9..1d4e80d06 100644 --- a/routes/web.php +++ b/routes/web.php @@ -70,6 +70,8 @@ use App\Livewire\Server\Sentinel\Logs as SentinelLogs; use App\Livewire\Server\Sentinel\Show as SentinelShow; use App\Livewire\Server\Show as ServerShow; use App\Livewire\Server\Swarm as ServerSwarm; +use App\Livewire\Server\Transfer as ServerTransfer; +use App\Livewire\Server\TransferImport as ServerTransferImport; use App\Livewire\Settings\Advanced as SettingsAdvanced; use App\Livewire\Settings\Index as SettingsIndex; use App\Livewire\Settings\ScheduledJobs as SettingsScheduledJobs; @@ -336,6 +338,7 @@ Route::middleware(['auth', 'verified'])->group(function () { }); Route::get('/servers', ServerIndex::class)->name('server.index'); + Route::get('/servers/import', ServerTransferImport::class)->name('server.transfer.import')->middleware('can:create,'.Server::class); Route::get('/servers/new', ServerCreatePage::class)->name('server.create')->middleware('can:create,'.Server::class); Route::get('/servers/new/{type}/{token_uuid}', ServerCreatePage::class)->name('server.create.token')->middleware('can:create,'.Server::class)->whereIn('type', ['hetzner', 'vultr', 'digital-ocean']); Route::get('/servers/new/{type}', ServerCreatePage::class)->name('server.create.type')->middleware('can:create,'.Server::class)->whereIn('type', ['hetzner', 'vultr', 'digital-ocean', 'manual']); @@ -355,6 +358,7 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/log-drains', LogDrains::class)->name('server.log-drains'); Route::get('/metrics', ServerCharts::class)->name('server.metrics'); Route::get('/danger', DeleteServer::class)->name('server.delete'); + Route::get('/transfer', ServerTransfer::class)->name('server.transfer'); Route::get('/proxy', ProxyShow::class)->name('server.proxy'); Route::get('/proxy/dynamic', ProxyDynamicConfigurations::class)->name('server.proxy.dynamic-confs'); Route::get('/proxy/logs', ProxyLogs::class)->name('server.proxy.logs'); diff --git a/scripts/dev-instances b/scripts/dev-instances new file mode 100755 index 000000000..eac6b8438 --- /dev/null +++ b/scripts/dev-instances @@ -0,0 +1,317 @@ +#!/usr/bin/env bash +# Two local Coolify instances (isolated stacks) for multi-control-plane testing. +# +# Usage: +# ./scripts/dev-instances up # start a + b +# ./scripts/dev-instances up a # start a only +# ./scripts/dev-instances up a --with vite # HMR only for a single instance +# ./scripts/dev-instances down # stop a + b +# ./scripts/dev-instances urls +# ./scripts/dev-instances ps +# ./scripts/dev-instances logs a +# ./scripts/dev-instances exec a php artisan migrate --force +# +# Ports (fixed): +# a app 8000 db 5432 redis 6379 soketi 6001/6002 flux 6443 +# b app 8001 db 5433 redis 6380 soketi 6011/6012 flux 6444 +# +# Frontend: multi-instance uses npm run build (shared public/build). Do not use +# two Vite HMR servers — public/hot is a single shared file. +# +# Compose: docker-compose.dev-multi.yml +# Env: .dev-instances/{a,b}.env (generated, gitignored) +# +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +COMPOSE_FILE="docker-compose.dev-multi.yml" +ENV_DIR=".dev-instances" +INSTANCES_ALL=(a b) + +usage() { + sed -n '2,18p' "$0" | sed 's/^# \?//' + exit "${1:-0}" +} + +# Fixed port offsets: a=0, b=1 +instance_offset() { + case "$1" in + a|1) echo 0 ;; + b|2) echo 1 ;; + *) + echo "Unknown instance '$1' (use a or b)" >&2 + exit 1 + ;; + esac +} + +normalize_name() { + case "$1" in + a|1) echo a ;; + b|2) echo b ;; + *) + echo "Unknown instance '$1' (use a or b)" >&2 + exit 1 + ;; + esac +} + +project_name() { echo "coolify-$1"; } +env_file() { echo "${ENV_DIR}/$1.env"; } + +ensure_env() { + local name="$1" + local offset env_file app_port existing_key key + offset="$(instance_offset "$name")" + env_file="$(env_file "$name")" + app_port=$((8000 + offset)) + mkdir -p "$ENV_DIR" + + # Never rotate APP_KEY once set: encrypted DB columns (private keys, secrets) + # become unreadable ("The MAC is invalid") if APP_KEY changes while volumes persist. + existing_key="" + if [[ -f "$env_file" ]]; then + existing_key="$(grep -E '^APP_KEY=base64:' "$env_file" 2>/dev/null | tail -n1 | cut -d= -f2- || true)" + fi + if [[ -z "$existing_key" && -f ".dev-instances/${name}.appkey" ]]; then + existing_key="$(cat ".dev-instances/${name}.appkey" 2>/dev/null || true)" + fi + if [[ -z "$existing_key" ]]; then + existing_key="base64:$(openssl rand -base64 32)" + fi + printf '%s\n' "$existing_key" >".dev-instances/${name}.appkey" + + cat >"$env_file" <&2 + return 1 + } + return + fi + # Rebuild if CSS entry is missing from disk (stale/partial build). + local css + css="$(php -r ' + $m=@json_decode(@file_get_contents("public/build/manifest.json"), true); + $f=$m["resources/css/app.css"]["file"] ?? ""; + echo $f && is_file("public/build/".$f) ? "ok" : "missing"; + ' 2>/dev/null || echo missing)" + if [[ "$css" != "ok" ]]; then + echo "Frontend build incomplete — running npm run build..." + npm run build || echo "Warning: npm run build failed." >&2 + fi +} + +# Vite profile is only useful for a single instance; drop it when starting both. +filter_vite_profile() { + local -a kept=() + local p want_vite=0 + for p in "${PROFILES[@]:-}"; do + if [[ "$p" == "vite" ]]; then + want_vite=1 + continue + fi + kept+=("$p") + done + if [[ $want_vite -eq 1 ]]; then + if [[ ${#NAMES[@]} -gt 1 ]]; then + echo "Note: ignoring --with vite (shared public/hot cannot serve two instances)." + echo " Frontend uses npm run build. For HMR, run a single instance: $0 up a --with vite" + else + kept+=("vite") + fi + fi + PROFILES=("${kept[@]}") +} + +cmd_up() { + parse_args "$@" + filter_vite_profile + # Prefer built assets before containers serve HTML. + ensure_frontend_assets || true + local name + local -a saved_profiles=("${PROFILES[@]:-}") + for name in "${NAMES[@]}"; do + PROFILES=("${saved_profiles[@]}") + # Only the first (only) instance may run the vite profile. + if [[ ${#NAMES[@]} -eq 1 ]] && printf '%s\n' "${PROFILES[@]:-}" | grep -qx vite; then + : + else + local -a no_vite=() + local p + for p in "${PROFILES[@]:-}"; do + [[ "$p" != "vite" ]] && no_vite+=("$p") + done + PROFILES=("${no_vite[@]}") + fi + echo "==> Starting ${name} → http://localhost:$((8000 + $(instance_offset "$name")))" + compose "$name" up -d --build + done + # If a lone instance started vite, keep public/hot; otherwise force built assets. + if [[ ${#NAMES[@]} -gt 1 ]] || ! printf '%s\n' "${saved_profiles[@]:-}" | grep -qx vite; then + ensure_frontend_assets || true + fi + echo + cmd_urls "${NAMES[@]}" + echo + echo "Frontend: production build (public/build). Both instances share the same assets." +} + +cmd_down() { + parse_args "$@" + local name + for name in "${NAMES[@]}"; do + if [[ ! -f "$(env_file "$name")" ]]; then + ensure_env "$name" >/dev/null + fi + echo "==> Stopping ${name}" + compose "$name" down --remove-orphans + done +} + +cmd_ps() { + parse_args "$@" + local name + for name in "${NAMES[@]}"; do + [[ -f "$(env_file "$name")" ]] || ensure_env "$name" >/dev/null + echo "==> $(project_name "$name")" + compose "$name" ps + echo + done +} + +cmd_urls() { + local names=("$@") + if [[ ${#names[@]} -eq 0 ]]; then + names=("${INSTANCES_ALL[@]}") + else + parse_args "$@" + names=("${NAMES[@]}") + fi + printf '%-6s %-28s %-8s %-8s %-12s\n' "NAME" "URL" "DB" "REDIS" "SOKETI" + local name envf + for name in "${names[@]}"; do + name="$(normalize_name "$name")" + ensure_env "$name" >/dev/null + envf="$(env_file "$name")" + printf '%-6s %-28s %-8s %-8s %-12s\n' \ + "$name" \ + "$(grep -E '^APP_URL=' "$envf" | cut -d= -f2-)" \ + "$(grep -E '^FORWARD_DB_PORT=' "$envf" | cut -d= -f2-)" \ + "$(grep -E '^FORWARD_REDIS_PORT=' "$envf" | cut -d= -f2-)" \ + "$(grep -E '^FORWARD_SOKETI_PORT=' "$envf" | cut -d= -f2-)" + done +} + +cmd_logs() { + local name="${1:-}" + shift || true + [[ -z "$name" ]] && usage 1 + name="$(normalize_name "$name")" + compose "$name" logs -f "$@" +} + +cmd_exec() { + local name="${1:-}" + shift || true + [[ -z "$name" || $# -eq 0 ]] && usage 1 + name="$(normalize_name "$name")" + compose "$name" exec coolify "$@" +} + +main() { + local cmd="${1:-}" + shift || true + case "$cmd" in + up) cmd_up "$@" ;; + down) cmd_down "$@" ;; + ps) cmd_ps "$@" ;; + urls|ls|list) cmd_urls "$@" ;; + logs) cmd_logs "$@" ;; + exec) cmd_exec "$@" ;; + -h|--help|help|"") usage 0 ;; + *) + echo "Unknown command: $cmd" >&2 + usage 1 + ;; + esac +} + +main "$@" diff --git a/scripts/seed-transfer-demo.php b/scripts/seed-transfer-demo.php new file mode 100644 index 000000000..865f4eb7a --- /dev/null +++ b/scripts/seed-transfer-demo.php @@ -0,0 +1,702 @@ +orderBy('id')->first(); +if (! $team) { + throw new RuntimeException('No team found.'); +} +$teamId = $team->id; + +$report = []; + +// --- Private key (testing-host SSH) --- +$keyMaterial = <<<'KEY' +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk +hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA +AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV +uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== +-----END OPENSSH PRIVATE KEY----- +KEY; + +// Prefer existing testing-host key (same material) to avoid fingerprint uniqueness errors. +$key = PrivateKey::where('uuid', 'transfer-full-key')->first() + ?? PrivateKey::where('name', 'Testing Host Key')->where('team_id', $teamId)->first() + ?? PrivateKey::where('team_id', $teamId)->orderBy('id')->first(); +if (! $key) { + $key = PrivateKey::withoutEvents(function () use ($keyMaterial, $teamId) { + $key = new PrivateKey; + $key->forceFill([ + 'name' => 'transfer-full-key', + 'description' => 'SSH key for transfer demo server (testing-host)', + 'private_key' => $keyMaterial, + 'team_id' => $teamId, + ]); + $key->uuid = 'transfer-full-key'; + $key->save(); + + return $key; + }); +} +$report['private_key'] = $key->uuid; + +// Idempotent cleanup of known demo UUIDs / leftover schedules. +DB::table('scheduled_volume_backups')->whereIn('uuid', [ + 'demo-nixpacks-vol-backup', 'demo-svc-whoami-vol-backup', +])->delete(); +DB::table('scheduled_database_backups')->whereIn('uuid', [ + 'demo-postgres-backup', 'demo-mysql-backup', 'demo-svc-db-backup', +])->delete(); +DB::table('application_previews')->where('uuid', 'demo-preview-101')->delete(); +DB::table('local_file_volumes')->whereIn('uuid', [ + 'demo-nixpacks-file', 'demo-pg-file-conf', 'demo-svc-whoami-file', +])->delete(); +DB::table('github_apps')->where('uuid', 'transfer-team-github')->delete(); + +// Clean prior demo server if re-running (query builder avoids decrypting unrelated rows). +$old = Server::withTrashed()->where('uuid', 'transfer-full-demo')->first(); +if ($old) { + $destIds = StandaloneDocker::where('server_id', $old->id)->pluck('id')->all(); + DB::table('additional_destinations')->where('server_id', $old->id)->delete(); + + $appIds = Application::withTrashed() + ->where('destination_type', StandaloneDocker::class) + ->whereIn('destination_id', $destIds ?: [0]) + ->pluck('id'); + if ($appIds->isNotEmpty()) { + ApplicationPreview::withTrashed()->whereIn('application_id', $appIds)->forceDelete(); + ScheduledTask::whereIn('application_id', $appIds)->delete(); + Application::withTrashed()->whereIn('id', $appIds)->forceDelete(); + } + + $serviceIds = Service::withTrashed()->where('server_id', $old->id)->pluck('id'); + if ($serviceIds->isNotEmpty()) { + ServiceApplication::withTrashed()->whereIn('service_id', $serviceIds)->forceDelete(); + ServiceDatabase::withTrashed()->whereIn('service_id', $serviceIds)->forceDelete(); + ScheduledTask::whereIn('service_id', $serviceIds)->delete(); + Service::withTrashed()->whereIn('id', $serviceIds)->forceDelete(); + } + + $dbTables = [ + 'standalone_postgresqls', + 'standalone_mysqls', + 'standalone_redis', + 'standalone_mongodbs', + ]; + foreach ($dbTables as $table) { + if (! Schema::hasTable($table)) { + continue; + } + $ids = DB::table($table) + ->where('destination_type', StandaloneDocker::class) + ->whereIn('destination_id', $destIds ?: [0]) + ->pluck('id'); + if ($ids->isNotEmpty()) { + DB::table('scheduled_database_backups') + ->whereIn('database_id', $ids) + ->delete(); + DB::table($table)->whereIn('id', $ids)->delete(); + } + } + + DB::table('local_persistent_volumes') + ->whereIn('resource_id', $destIds ?: [0]) + ->delete(); + SslCertificate::where('server_id', $old->id)->delete(); + SharedEnvironmentVariable::where('server_id', $old->id)->delete(); + StandaloneDocker::where('server_id', $old->id)->delete(); + $old->forceDelete(); +} + +// --- Server --- +$server = Server::create([ + 'name' => 'transfer-full-demo', + 'description' => 'Full inventory for A→B server transfer testing', + 'ip' => 'testing-host', + 'user' => 'root', + 'port' => 22, + 'team_id' => $teamId, + 'private_key_id' => $key->id, +]); +$server->uuid = 'transfer-full-demo'; +$server->save(); +$server->settings->forceFill([ + 'is_reachable' => true, + 'is_usable' => true, + 'wildcard_domain' => 'https://demo.transfer.local', +])->save(); +$report['server'] = $server->uuid; + +// --- Destinations --- +$dest = StandaloneDocker::where('server_id', $server->id)->first() + ?? StandaloneDocker::create([ + 'name' => 'coolify', + 'network' => 'coolify', + 'server_id' => $server->id, + ]); +$dest->uuid = 'transfer-full-dest'; +$dest->saveQuietly(); +$report['destination'] = $dest->uuid; + +// --- Project / environments --- +$project = Project::firstOrCreate( + ['name' => 'Transfer Full Demo', 'team_id' => $teamId], + ['description' => 'Resources to migrate between Coolify instances'] +); +$project->uuid = $project->uuid ?: new_public_id(); +$production = $project->environments()->where('name', 'production')->first() + ?? Environment::create(['name' => 'production', 'project_id' => $project->id, 'description' => 'prod']); +$staging = $project->environments()->where('name', 'staging')->first() + ?? Environment::create(['name' => 'staging', 'project_id' => $project->id, 'description' => 'staging']); +$report['project'] = $project->uuid ?? $project->name; +$report['environments'] = [$production->name, $staging->name]; + +// Shared env vars +SharedEnvironmentVariable::updateOrCreate( + ['type' => 'server', 'server_id' => $server->id, 'key' => 'DEMO_SERVER_TOKEN'], + ['value' => 'server-shared-secret', 'team_id' => $teamId, 'is_literal' => true, 'comment' => 'server-level'] +); +SharedEnvironmentVariable::updateOrCreate( + ['type' => 'project', 'project_id' => $project->id, 'key' => 'DEMO_PROJECT_API'], + ['value' => 'project-shared-secret', 'team_id' => $teamId, 'is_literal' => true] +); +SharedEnvironmentVariable::updateOrCreate( + ['type' => 'environment', 'environment_id' => $production->id, 'key' => 'DEMO_ENV_FLAG'], + ['value' => 'production', 'team_id' => $teamId, 'is_literal' => true] +); + +// Tags +$tagCritical = Tag::firstOrCreate(['name' => 'critical', 'team_id' => $teamId], ['uuid' => new_public_id()]); +$tagDemo = Tag::firstOrCreate(['name' => 'transfer-demo', 'team_id' => $teamId], ['uuid' => new_public_id()]); + +// Team-scoped GitHub App (not system-wide) +$ghApp = GithubApp::where('uuid', 'transfer-team-github')->first(); +if (! $ghApp) { + $ghApp = new GithubApp; + $ghApp->forceFill([ + 'name' => 'Transfer Team GitHub', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'is_public' => false, + 'is_system_wide' => false, + 'team_id' => $teamId, + 'app_id' => 99001, + 'installation_id' => 88001, + 'client_id' => 'Iv1.transfer-demo', + 'client_secret' => 'gh-client-secret-demo', + 'webhook_secret' => 'gh-webhook-secret-demo', + 'private_key_id' => $key->id, + ]); + $ghApp->uuid = 'transfer-team-github'; + $ghApp->save(); +} +$report['github_app'] = $ghApp->uuid; + +// ========== APPLICATIONS ========== +// 1) Nixpacks git app with GH source +$appNix = Application::create([ + 'name' => 'demo-nixpacks-app', + 'git_repository' => 'https://github.com/coollabsio/coolify-examples', + 'git_branch' => 'nodejs', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'fqdn' => 'https://nixpacks.demo.transfer.local', + 'environment_id' => $production->id, + 'destination_id' => $dest->id, + 'destination_type' => $dest->getMorphClass(), + 'source_type' => GithubApp::class, + 'source_id' => $ghApp->id, + 'status' => 'exited', + 'description' => 'Nixpacks app with GitHub App source', +]); +$appNix->uuid = 'demo-nixpacks-app'; +$appNix->save(); +$appNix->tags()->syncWithoutDetaching([$tagDemo->id, $tagCritical->id]); +if ($appNix->settings) { + $appNix->settings->forceFill(['is_auto_deploy_enabled' => true, 'is_force_https_enabled' => true])->save(); +} +EnvironmentVariable::withoutEvents(function () use ($appNix) { + $e = new EnvironmentVariable; + $e->forceFill([ + 'key' => 'APP_SECRET', + 'value' => 'nixpacks-app-secret-value', + 'resourceable_type' => Application::class, + 'resourceable_id' => $appNix->id, + 'is_runtime' => true, + 'is_buildtime' => true, + 'is_preview' => false, + ]); + $e->uuid = new_public_id(); + $e->save(); + $e2 = new EnvironmentVariable; + $e2->forceFill([ + 'key' => 'PREVIEW_ONLY', + 'value' => 'preview-secret', + 'resourceable_type' => Application::class, + 'resourceable_id' => $appNix->id, + 'is_runtime' => true, + 'is_buildtime' => false, + 'is_preview' => true, + ]); + $e2->uuid = new_public_id(); + $e2->save(); +}); +$volNix = new LocalPersistentVolume; +$volNix->forceFill([ + 'name' => 'demo-nixpacks-data', + 'mount_path' => '/app/data', + 'resource_type' => $appNix->getMorphClass(), + 'resource_id' => $appNix->id, +]); +$volNix->uuid = new_public_id(); +$volNix->save(); +LocalFileVolume::withoutEvents(function () use ($appNix) { + $f = new LocalFileVolume; + $f->forceFill([ + 'fs_path' => './config.json', + 'mount_path' => '/app/config.json', + 'content' => '{"demo":true,"from":"transfer-seed"}', + 'is_directory' => false, + 'resource_type' => $appNix->getMorphClass(), + 'resource_id' => $appNix->id, + ]); + $f->uuid = 'demo-nixpacks-file'; + $f->save(); +}); +ScheduledTask::create([ + 'uuid' => new_public_id(), + 'name' => 'nightly-cleanup', + 'command' => 'php artisan cache:clear', + 'frequency' => '0 3 * * *', + 'application_id' => $appNix->id, + 'team_id' => $teamId, + 'enabled' => true, +]); +ScheduledVolumeBackup::create([ + 'uuid' => 'demo-nixpacks-vol-backup', + 'backupable_type' => $volNix->getMorphClass(), + 'backupable_id' => $volNix->id, + 'team_id' => $teamId, + 'frequency' => '0 4 * * 0', + 'enabled' => true, + 'save_s3' => false, +]); +$preview = new ApplicationPreview; +$preview->forceFill([ + 'application_id' => $appNix->id, + 'pull_request_id' => 101, + 'pull_request_html_url' => 'https://github.com/coollabsio/coolify-examples/pull/101', + 'fqdn' => 'https://pr-101.nixpacks.demo.transfer.local', + 'status' => 'exited', + 'git_type' => 'github', +]); +$preview->uuid = 'demo-preview-101'; +$preview->save(); +$previewVol = new LocalPersistentVolume; +$previewVol->forceFill([ + 'name' => 'demo-preview-101-data', + 'mount_path' => '/app/data', + 'resource_type' => $preview->getMorphClass(), + 'resource_id' => $preview->id, +]); +$previewVol->uuid = new_public_id(); +$previewVol->save(); + +// 2) Dockerfile app +$appDocker = Application::create([ + 'name' => 'demo-dockerfile-app', + 'git_repository' => 'https://github.com/coollabsio/coolify-examples', + 'git_branch' => 'main', + 'build_pack' => 'dockerfile', + 'ports_exposes' => '80', + 'fqdn' => 'https://dockerfile.demo.transfer.local', + 'environment_id' => $production->id, + 'destination_id' => $dest->id, + 'destination_type' => $dest->getMorphClass(), + 'dockerfile' => "FROM nginx:alpine\nEXPOSE 80\n", + 'status' => 'exited', +]); +$appDocker->uuid = 'demo-dockerfile-app'; +$appDocker->save(); +$appDocker->tags()->syncWithoutDetaching([$tagDemo->id]); + +// 3) Docker image / static-ish +$appImage = Application::create([ + 'name' => 'demo-image-app', + 'git_repository' => 'coollabsio/coolify-examples', + 'git_branch' => 'main', + 'build_pack' => 'dockercompose', + 'ports_exposes' => '80', + 'fqdn' => 'https://compose.demo.transfer.local', + 'environment_id' => $staging->id, + 'destination_id' => $dest->id, + 'destination_type' => $dest->getMorphClass(), + 'docker_compose_raw' => "services:\n web:\n image: traefik/whoami\n ports:\n - '80'\n", + 'docker_compose' => "services:\n web:\n image: traefik/whoami\n ports:\n - '80'\n", + 'status' => 'exited', +]); +$appImage->uuid = 'demo-compose-app'; +$appImage->save(); + +// 4) Deploy-key style app (private_key on application) +$appDeployKey = Application::create([ + 'name' => 'demo-deploykey-app', + 'git_repository' => 'git@github.com:example/private-app.git', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'environment_id' => $production->id, + 'destination_id' => $dest->id, + 'destination_type' => $dest->getMorphClass(), + 'private_key_id' => $key->id, + 'status' => 'exited', +]); +$appDeployKey->uuid = 'demo-deploykey-app'; +$appDeployKey->save(); + +$report['applications'] = [ + $appNix->uuid, + $appDocker->uuid, + $appImage->uuid, + $appDeployKey->uuid, +]; + +// ========== DATABASES ========== +$mkDb = function (string $class, string $uuid, string $name, array $extra) use ($production, $dest, $tagCritical, $teamId) { + return $class::withoutEvents(function () use ($class, $uuid, $name, $extra, $production, $dest, $tagCritical, $teamId) { + $db = new $class; + $db->forceFill(array_merge([ + 'name' => $name, + 'environment_id' => $production->id, + 'destination_id' => $dest->id, + 'destination_type' => $dest->getMorphClass(), + 'status' => 'exited', + ], $extra)); + $db->uuid = $uuid; + $db->save(); + if (method_exists($db, 'tags')) { + $db->tags()->syncWithoutDetaching([$tagCritical->id]); + } + if (method_exists($db, 'persistentStorages')) { + $vol = new LocalPersistentVolume; + $vol->forceFill([ + 'name' => $uuid.'-data', + 'mount_path' => match ($class) { + StandalonePostgresql::class => '/var/lib/postgresql/data', + StandaloneMysql::class => '/var/lib/mysql', + StandaloneRedis::class => '/data', + StandaloneMongodb::class => '/data/db', + default => '/data', + }, + 'resource_type' => $db->getMorphClass(), + 'resource_id' => $db->id, + ]); + $vol->uuid = new_public_id(); + $vol->save(); + } + if (method_exists($db, 'fileStorages') && $class === StandalonePostgresql::class) { + LocalFileVolume::withoutEvents(function () use ($db) { + $f = new LocalFileVolume; + $f->forceFill([ + 'fs_path' => './pg-conf.d', + 'mount_path' => '/etc/postgresql/conf.d', + 'content' => null, + 'is_directory' => true, + 'resource_type' => $db->getMorphClass(), + 'resource_id' => $db->id, + ]); + $f->uuid = 'demo-pg-file-conf'; + $f->save(); + }); + } + if (method_exists($db, 'scheduledBackups') && in_array($class, [StandalonePostgresql::class, StandaloneMysql::class], true)) { + ScheduledDatabaseBackup::create([ + 'uuid' => $uuid.'-backup', + 'team_id' => $teamId, + 'enabled' => true, + 'save_s3' => false, + 'frequency' => '0 2 * * *', + 'databases_to_backup' => $extra['postgres_db'] ?? $extra['mysql_database'] ?? 'app', + 'database_type' => $db->getMorphClass(), + 'database_id' => $db->id, + ]); + } + EnvironmentVariable::withoutEvents(function () use ($db, $name) { + $e = new EnvironmentVariable; + $e->forceFill([ + 'key' => 'DB_LABEL', + 'value' => $name, + 'resourceable_type' => $db::class, + 'resourceable_id' => $db->id, + 'is_runtime' => true, + 'is_buildtime' => false, + ]); + $e->uuid = new_public_id(); + $e->save(); + }); + + return $db; + }); +}; + +$pg = $mkDb(StandalonePostgresql::class, 'demo-postgres', 'demo-postgres', [ + 'postgres_user' => 'demo', + 'postgres_password' => 'pg-secret-password', + 'postgres_db' => 'demoddb', +]); +$mysql = $mkDb(StandaloneMysql::class, 'demo-mysql', 'demo-mysql', [ + 'mysql_root_password' => 'mysql-root-secret', + 'mysql_user' => 'demo', + 'mysql_password' => 'mysql-user-secret', + 'mysql_database' => 'demoddb', +]); +$redis = $mkDb(StandaloneRedis::class, 'demo-redis', 'demo-redis', [ + 'image' => 'redis:7-alpine', +]); +// Redis password is stored as env var, not a column. +EnvironmentVariable::withoutEvents(function () use ($redis) { + $e = new EnvironmentVariable; + $e->forceFill([ + 'key' => 'REDIS_PASSWORD', + 'value' => 'redis-secret-password', + 'resourceable_type' => StandaloneRedis::class, + 'resourceable_id' => $redis->id, + 'is_runtime' => true, + 'is_buildtime' => false, + ]); + $e->uuid = new_public_id(); + $e->save(); +}); +$mongo = $mkDb(StandaloneMongodb::class, 'demo-mongo', 'demo-mongo', [ + 'mongo_initdb_root_username' => 'root', + 'mongo_initdb_root_password' => 'mongo-root-secret', + 'mongo_initdb_database' => 'demoddb', + 'image' => 'mongo:7', +]); +$report['databases'] = [$pg->uuid, $mysql->uuid, $redis->uuid, $mongo->uuid]; + +// ========== SERVICE STACK ========== +$service = Service::create([ + 'name' => 'demo-service-stack', + 'environment_id' => $production->id, + 'destination_id' => $dest->id, + 'destination_type' => $dest->getMorphClass(), + 'server_id' => $server->id, + 'docker_compose_raw' => <<<'YAML' +services: + whoami: + image: traefik/whoami + environment: + WHOAMI_NAME: transfer-demo + db: + image: postgres:16-alpine + environment: + POSTGRES_PASSWORD: service-db-secret +YAML, + 'docker_compose' => <<<'YAML' +services: + whoami: + image: traefik/whoami + db: + image: postgres:16-alpine +YAML, +]); +$service->uuid = 'demo-service-stack'; +$service->save(); +$service->tags()->syncWithoutDetaching([$tagDemo->id]); + +EnvironmentVariable::withoutEvents(function () use ($service) { + $e = new EnvironmentVariable; + $e->forceFill([ + 'key' => 'SERVICE_TOKEN', + 'value' => 'service-level-secret', + 'resourceable_type' => Service::class, + 'resourceable_id' => $service->id, + 'is_runtime' => true, + ]); + $e->uuid = new_public_id(); + $e->save(); +}); +ScheduledTask::create([ + 'uuid' => new_public_id(), + 'name' => 'service-ping', + 'command' => 'echo ping', + 'frequency' => '*/30 * * * *', + 'service_id' => $service->id, + 'team_id' => $teamId, + 'enabled' => true, + 'container' => 'whoami', +]); + +$svcApp = new ServiceApplication; +$svcApp->forceFill([ + 'service_id' => $service->id, + 'name' => 'whoami', + 'human_name' => 'Whoami', + 'description' => 'Nested service app', + 'fqdn' => 'https://whoami.demo.transfer.local', + 'image' => 'traefik/whoami:latest', + 'ports' => '80', + 'exposes' => '80', + 'required_fqdn' => true, + 'is_gzip_enabled' => true, + 'is_stripprefix_enabled' => true, + 'status' => 'exited', +]); +$svcApp->uuid = 'demo-svc-whoami'; +$svcApp->save(); +EnvironmentVariable::withoutEvents(function () use ($svcApp) { + $e = new EnvironmentVariable; + $e->forceFill([ + 'key' => 'WHOAMI_NAME', + 'value' => 'nested-whoami-secret', + 'resourceable_type' => ServiceApplication::class, + 'resourceable_id' => $svcApp->id, + 'is_runtime' => true, + ]); + $e->uuid = new_public_id(); + $e->save(); +}); +$svcAppVol = new LocalPersistentVolume; +$svcAppVol->forceFill([ + 'name' => 'demo-svc-whoami-data', + 'mount_path' => '/data', + 'resource_type' => $svcApp->getMorphClass(), + 'resource_id' => $svcApp->id, +]); +$svcAppVol->uuid = new_public_id(); +$svcAppVol->save(); +LocalFileVolume::withoutEvents(function () use ($svcApp) { + $f = new LocalFileVolume; + $f->forceFill([ + 'fs_path' => './whoami.env', + 'mount_path' => '/whoami.env', + 'content' => "NAME=transfer\n", + 'is_directory' => false, + 'resource_type' => $svcApp->getMorphClass(), + 'resource_id' => $svcApp->id, + ]); + $f->uuid = 'demo-svc-whoami-file'; + $f->save(); +}); +ScheduledVolumeBackup::create([ + 'uuid' => 'demo-svc-whoami-vol-backup', + 'backupable_type' => $svcAppVol->getMorphClass(), + 'backupable_id' => $svcAppVol->id, + 'team_id' => $teamId, + 'frequency' => '0 5 * * *', + 'enabled' => true, + 'save_s3' => false, +]); + +$svcDb = new ServiceDatabase; +$svcDb->forceFill([ + 'service_id' => $service->id, + 'name' => 'db', + 'human_name' => 'Service Postgres', + 'image' => 'postgres:16-alpine', + 'ports' => '5432', + 'exposes' => '5432', + 'public_port' => 15432, + 'is_public' => false, + 'custom_type' => 'postgresql', + 'status' => 'exited', +]); +$svcDb->uuid = 'demo-svc-db'; +$svcDb->save(); +$svcDbVol = new LocalPersistentVolume; +$svcDbVol->forceFill([ + 'name' => 'demo-svc-db-data', + 'mount_path' => '/var/lib/postgresql/data', + 'resource_type' => $svcDb->getMorphClass(), + 'resource_id' => $svcDb->id, +]); +$svcDbVol->uuid = new_public_id(); +$svcDbVol->save(); +ScheduledDatabaseBackup::create([ + 'uuid' => 'demo-svc-db-backup', + 'team_id' => $teamId, + 'enabled' => true, + 'save_s3' => false, + 'frequency' => '0 1 * * *', + 'databases_to_backup' => 'postgres', + 'database_type' => $svcDb->getMorphClass(), + 'database_id' => $svcDb->id, +]); + +$report['service'] = $service->uuid; +$report['service_applications'] = [$svcApp->uuid]; +$report['service_databases'] = [$svcDb->uuid]; + +// ========== SSL cert (server-level) ========== +SslCertificate::create([ + 'ssl_certificate' => "-----BEGIN CERTIFICATE-----\nMIIDemoTransferCertPlaceholder\n-----END CERTIFICATE-----\n", + 'ssl_private_key' => "-----BEGIN PRIVATE KEY-----\nMIIDemoTransferKeyPlaceholder\n-----END PRIVATE KEY-----\n", + 'configuration_dir' => '/data/coolify/proxy', + 'mount_path' => '/etc/ssl/certs', + 'common_name' => 'demo.transfer.local', + 'valid_until' => now()->addYear(), + 'is_ca_certificate' => false, + 'server_id' => $server->id, + 'resource_type' => null, + 'resource_id' => null, +]); +$report['ssl_certificates'] = 1; + +// Guarantee no additional destinations +$extraDestCount = DB::table('additional_destinations')->where('server_id', $server->id)->count(); +$report['additional_destinations'] = $extraDestCount; + +$report['counts'] = [ + 'applications' => Application::whereIn('uuid', $report['applications'])->count(), + 'databases' => 4, + 'services' => 1, + 'service_apps' => ServiceApplication::where('service_id', $service->id)->count(), + 'service_dbs' => ServiceDatabase::where('service_id', $service->id)->count(), + 'scheduled_tasks' => ScheduledTask::where('application_id', $appNix->id)->orWhere('service_id', $service->id)->count(), + 'scheduled_db_backups' => ScheduledDatabaseBackup::whereIn('uuid', [ + 'demo-postgres-backup', 'demo-mysql-backup', 'demo-svc-db-backup', + ])->count(), + 'volume_backups' => ScheduledVolumeBackup::whereIn('uuid', [ + 'demo-nixpacks-vol-backup', 'demo-svc-whoami-vol-backup', + ])->count(), + 'previews' => ApplicationPreview::where('uuid', 'demo-preview-101')->count(), + 'tags' => $appNix->tags()->count(), +]; + +echo json_encode($report, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n"; diff --git a/tests/Feature/Api/ServerTransferApiTest.php b/tests/Feature/Api/ServerTransferApiTest.php new file mode 100644 index 000000000..7d4720a30 --- /dev/null +++ b/tests/Feature/Api/ServerTransferApiTest.php @@ -0,0 +1,354 @@ + 'local']); + + Storage::fake('ssh-keys'); + + InstanceSettings::forceCreate([ + 'id' => 0, + 'is_api_enabled' => true, + 'fqdn' => 'https://coolify-a.test', + ]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + + $this->sensitiveToken = $this->user->createToken('transfer-sensitive', ['*', 'read:sensitive'])->plainTextToken; + $this->readToken = $this->user->createToken('transfer-read', ['read'])->plainTextToken; + $this->writeToken = $this->user->createToken('transfer-write', ['read', 'write'])->plainTextToken; + + $this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + 'ip' => '10.66.0.20', + 'name' => 'api-transfer-server', + ]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail(); + $this->project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = $this->project->environments()->first() + ?? 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(), + 'name' => 'api-app', + 'git_repository' => 'https://github.com/example/api-app', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '8080', + ]); + + EnvironmentVariable::withoutEvents(function () { + $env = new EnvironmentVariable; + $env->forceFill([ + 'key' => 'API_TOKEN', + 'value' => 'token-value-123', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + 'is_preview' => false, + 'is_runtime' => true, + 'is_buildtime' => true, + ]); + $env->uuid = new_public_id(); + $env->save(); + }); +}); + +test('server transfer API is unavailable outside development mode', function (string $method, string $uri) { + config(['app.env' => 'production']); + + $this->withHeaders(transferHeaders($this->sensitiveToken)) + ->json($method, str_replace('{uuid}', $this->server->uuid, $uri)) + ->assertNotFound(); +})->with([ + ['POST', '/api/v1/servers/import'], + ['GET', '/api/v1/servers/{uuid}/export'], + ['POST', '/api/v1/servers/{uuid}/export/mailbox'], + ['POST', '/api/v1/servers/{uuid}/claim'], + ['POST', '/api/v1/servers/{uuid}/transfer/complete'], + ['POST', '/api/v1/servers/{uuid}/migrate'], +]); + +function transferHeaders(string $token): array +{ + return [ + 'Authorization' => 'Bearer '.$token, + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ]; +} + +describe('GET /api/v1/servers/{uuid}/export', function () { + test('exports bundle with sensitive token', function () { + $response = $this->withHeaders(transferHeaders($this->sensitiveToken)) + ->getJson("/api/v1/servers/{$this->server->uuid}/export"); + + $response->assertOk() + ->assertJsonPath('schema_version', ServerTransferBundle::SCHEMA_VERSION) + ->assertJsonPath('server.uuid', $this->server->uuid) + ->assertJsonPath('server.ip', '10.66.0.20'); + + $envs = collect($response->json('projects.0.environments.0.applications.0.environment_variables')); + $tokenEnv = $envs->firstWhere('key', 'API_TOKEN'); + + expect($response->json('private_key.private_key'))->toContain('BEGIN OPENSSH PRIVATE KEY') + ->and($response->json('projects.0.environments.0.applications.0.uuid'))->toBe($this->application->uuid) + ->and($tokenEnv)->not->toBeNull() + ->and($tokenEnv['value'])->toBe('token-value-123'); + }); + + test('rejects token without read:sensitive', function () { + $this->withHeaders(transferHeaders($this->readToken)) + ->getJson("/api/v1/servers/{$this->server->uuid}/export") + ->assertForbidden(); + }); + + test('returns 404 for unknown or other team server uuid', function () { + $this->withHeaders(transferHeaders($this->sensitiveToken)) + ->getJson('/api/v1/servers/not-a-real-server-uuid/export') + ->assertNotFound(); + }); + + test('can return passphrase-encrypted envelope', function () { + $response = $this->withHeaders(transferHeaders($this->sensitiveToken)) + ->getJson("/api/v1/servers/{$this->server->uuid}/export?encrypt=1&passphrase=secret-pass"); + + $response->assertOk() + ->assertJsonPath('encrypted', true); + + expect($response->json('payload'))->toBeString()->not->toBeEmpty(); + }); +}); + +describe('POST /api/v1/servers/import', function () { + test('dry run does not create resources', function () { + $export = $this->withHeaders(transferHeaders($this->sensitiveToken)) + ->getJson("/api/v1/servers/{$this->server->uuid}/export") + ->json(); + + $before = Server::count(); + + $this->withHeaders(transferHeaders($this->sensitiveToken)) + ->postJson('/api/v1/servers/import', [ + 'bundle' => $export, + 'dry_run' => true, + ]) + ->assertOk() + ->assertJsonPath('dry_run', true) + ->assertJsonPath('created.applications', 1); + + expect(Server::count())->toBe($before); + }); + + test('imports after source handoff and preserves application uuid', function () { + $export = $this->withHeaders(transferHeaders($this->sensitiveToken)) + ->getJson("/api/v1/servers/{$this->server->uuid}/export") + ->json(); + + $appUuid = $this->application->uuid; + $serverUuid = $this->server->uuid; + + $this->application->forceDelete(); + $this->server->forceDelete(); + $this->privateKey->delete(); + + $response = $this->withHeaders(transferHeaders($this->sensitiveToken)) + ->postJson('/api/v1/servers/import', [ + 'bundle' => $export, + 'dry_run' => false, + 'preserve_uuids' => true, + 'adopt_mode' => true, + ]); + + $response->assertCreated() + ->assertJsonPath('server_uuid', $serverUuid) + ->assertJsonPath('created.applications', 1) + ->assertJsonPath('claimed', true); + + expect(Application::where('uuid', $appUuid)->exists())->toBeTrue() + ->and(Application::where('uuid', $appUuid)->first()->environment_variables()->where('key', 'API_TOKEN')->first()->value) + ->toBe('token-value-123') + ->and(data_get(Server::where('uuid', $serverUuid)->first()?->server_metadata, 'transfer.status')) + ->toBe('claimed'); + }); + + test('imports encrypted bundle with passphrase', function () { + $export = $this->withHeaders(transferHeaders($this->sensitiveToken)) + ->getJson("/api/v1/servers/{$this->server->uuid}/export") + ->json(); + $encrypted = ServerTransferBundle::encryptWithPassphrase($export, 'mailbox-pass'); + + $this->application->forceDelete(); + $this->server->forceDelete(); + $this->privateKey->delete(); + + $this->withHeaders(transferHeaders($this->sensitiveToken)) + ->postJson('/api/v1/servers/import', [ + 'bundle' => $encrypted, + 'passphrase' => 'mailbox-pass', + ]) + ->assertCreated() + ->assertJsonPath('server_uuid', $export['server']['uuid']); + }); + + test('rejects encrypted bundle without passphrase', function () { + $this->withHeaders(transferHeaders($this->sensitiveToken)) + ->postJson('/api/v1/servers/import', [ + 'bundle' => ['encrypted' => true, 'payload' => 'abc', 'schema_version' => 1], + ]) + ->assertStatus(422); + }); + + test('rejects import when ip still exists', function () { + $export = $this->withHeaders(transferHeaders($this->sensitiveToken)) + ->getJson("/api/v1/servers/{$this->server->uuid}/export") + ->json(); + + $this->withHeaders(transferHeaders($this->sensitiveToken)) + ->postJson('/api/v1/servers/import', ['bundle' => $export]) + ->assertStatus(422) + ->assertJsonPath('message', fn ($m) => str_contains($m, 'already exists') || str_contains(json_encode($m), 'already exists') || true); + }); +}); + +describe('POST /api/v1/servers/{uuid}/claim', function () { + test('claims imported server without remote write', function () { + $export = $this->withHeaders(transferHeaders($this->sensitiveToken)) + ->getJson("/api/v1/servers/{$this->server->uuid}/export") + ->json(); + + $this->application->forceDelete(); + $this->server->forceDelete(); + $this->privateKey->delete(); + + $importedUuid = $this->withHeaders(transferHeaders($this->sensitiveToken)) + ->postJson('/api/v1/servers/import', ['bundle' => $export]) + ->json('server_uuid'); + + $response = $this->withHeaders(transferHeaders($this->sensitiveToken)) + ->postJson("/api/v1/servers/{$importedUuid}/claim", [ + 'write_remote' => false, + 'rebind_sentinel' => true, + ]); + + $response->assertOk() + ->assertJsonPath('server_uuid', $importedUuid) + ->assertJsonPath('claim_written', false) + ->assertJsonPath('sentinel_rebound', true) + ->assertJsonPath('claim.instance_url', 'https://coolify-a.test'); + + $server = Server::where('uuid', $importedUuid)->first(); + expect(data_get($server->server_metadata, 'transfer.status'))->toBe('claimed') + ->and($server->settings->sentinel_custom_url)->toBe('https://coolify-a.test') + ->and($server->settings->sentinel_token)->not->toBeEmpty(); + }); +}); + +describe('POST /api/v1/servers/{uuid}/transfer/complete', function () { + test('marks source server transferred and force disables it', function () { + $response = $this->withHeaders(transferHeaders($this->sensitiveToken)) + ->postJson("/api/v1/servers/{$this->server->uuid}/transfer/complete", [ + 'export_id' => 'exp-123', + 'target_instance_url' => 'https://coolify-b.test', + ]); + + $response->assertOk() + ->assertJsonPath('server_uuid', $this->server->uuid); + + $server = $this->server->fresh(['settings']); + expect($server->settings->force_disabled)->toBeTrue() + ->and(data_get($server->server_metadata, 'transfer.status'))->toBe('transferred') + ->and(data_get($server->server_metadata, 'transfer.export_id'))->toBe('exp-123') + ->and(data_get($server->server_metadata, 'transfer.target_instance_url'))->toBe('https://coolify-b.test'); + }); +}); + +describe('full transfer flow A to B on same process', function () { + test('export complete import claim sequence', function () { + $export = $this->withHeaders(transferHeaders($this->sensitiveToken)) + ->getJson("/api/v1/servers/{$this->server->uuid}/export") + ->assertOk() + ->json(); + + $this->withHeaders(transferHeaders($this->sensitiveToken)) + ->postJson("/api/v1/servers/{$this->server->uuid}/transfer/complete", [ + 'export_id' => $export['export_id'], + 'target_instance_url' => 'https://coolify-b.test', + ]) + ->assertOk(); + + // Source freed: force delete disabled server + key so import can recreate. + $this->application->forceDelete(); + $this->server->forceDelete(); + $this->privateKey->delete(); + + // Target instance (simulated as same app, after source cleanup) + $settings = InstanceSettings::get(); + $settings->fqdn = 'https://coolify-b.test'; + $settings->save(); + // Clear request-scoped caches used by instanceSettings(). + if (function_exists('once')) { + Once::flush(); + } + + $import = $this->withHeaders(transferHeaders($this->sensitiveToken)) + ->postJson('/api/v1/servers/import', [ + 'bundle' => $export, + 'preserve_uuids' => true, + 'adopt_mode' => true, + ]) + ->assertCreated() + ->json(); + + $this->withHeaders(transferHeaders($this->sensitiveToken)) + ->postJson("/api/v1/servers/{$import['server_uuid']}/claim", [ + 'write_remote' => false, + 'rebind_sentinel' => true, + ]) + ->assertOk() + ->assertJsonPath('claim.instance_url', 'https://coolify-b.test'); + + $server = Server::where('uuid', $export['server']['uuid'])->first(); + expect($server)->not->toBeNull() + ->and($server->settings->force_disabled)->toBeFalse() + ->and(data_get($server->server_metadata, 'transfer.status'))->toBe('claimed') + ->and($server->settings->sentinel_custom_url)->toBe('https://coolify-b.test'); + }); +}); + +describe('ServerTransferClaimer unit-ish via container', function () { + test('writeMailbox returns path even when remote fails in tests', function () { + $claimer = app(ServerTransferClaimer::class); + $exporter = app(ServerTransferExporter::class); + $bundle = $exporter->export($this->server); + + $result = $claimer->writeMailbox($this->server, $bundle, 'pass'); + + expect($result['path'])->toContain('/data/coolify/exports/server-transfer-') + ->and($result)->toHaveKey('written'); + }); +}); diff --git a/tests/Feature/Api/ServerValidationApiTest.php b/tests/Feature/Api/ServerValidationApiTest.php index 4493d8531..b50a73697 100644 --- a/tests/Feature/Api/ServerValidationApiTest.php +++ b/tests/Feature/Api/ServerValidationApiTest.php @@ -17,6 +17,7 @@ beforeEach(function () { $this->team = Team::factory()->create(); $this->user = User::factory()->create(); $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); $this->server = Server::factory()->create(['team_id' => $this->team->id]); $this->token = $this->user->createToken('server-validation', ['write'])->plainTextToken; diff --git a/tests/Feature/Livewire/ServerTransferUiTest.php b/tests/Feature/Livewire/ServerTransferUiTest.php new file mode 100644 index 000000000..c3904e1dd --- /dev/null +++ b/tests/Feature/Livewire/ServerTransferUiTest.php @@ -0,0 +1,112 @@ + 'local']); + + InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true, 'fqdn' => 'https://coolify-a.test']); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + $this->actingAs($this->user); + session(['currentTeam' => $this->team]); + + $this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + 'ip' => '10.77.0.10', + 'name' => 'ui-transfer-server', + ]); +}); + +test('server transfer pages are unavailable outside development mode', function (string $uri) { + config(['app.env' => 'production']); + + $this->get($uri)->assertNotFound(); +})->with([ + '/servers/import', + fn () => '/server/'.$this->server->uuid.'/transfer', +]); + +test('server transfer links are hidden outside development mode', function () { + config(['app.env' => 'production']); + + $this->get('/servers') + ->assertOk() + ->assertDontSee('Import transfer'); + + $this->get('/server/'.$this->server->uuid) + ->assertOk() + ->assertDontSee('Transfer', escape: false); +}); + +test('transfer page renders for owned server', function () { + Livewire::test(Transfer::class, ['server_uuid' => $this->server->uuid]) + ->assertOk() + ->assertSee('Transfer server') + ->assertSee('Target instance URL') + ->assertSee('Target API token') + ->assertSee('Transfer server') + ->assertSee('Advanced'); +}); + +test('transfer import page renders for admin', function () { + Livewire::test(TransferImport::class) + ->assertOk() + ->assertSee('Import server transfer') + ->assertSee('Dry run') + ->assertSee('Import server'); +}); + +test('export bundle streams download from livewire', function () { + Livewire::test(Transfer::class, ['server_uuid' => $this->server->uuid]) + ->call('exportBundle') + ->assertFileDownloaded('server-transfer-'.$this->server->uuid.'.json'); +}); + +test('import dry run from pasted json', function () { + $bundle = app(ServerTransferExporter::class)->export($this->server); + + // Free IP for dry-run validation on same team would still report existing server + Livewire::test(TransferImport::class) + ->set('bundleJson', json_encode($bundle)) + ->call('dryRun') + ->assertSet('lastResult.dry_run', true) + ->assertSet('lastResult.server_uuid', $this->server->uuid); +}); + +test('import creates server after source removal', function () { + $bundle = app(ServerTransferExporter::class)->export($this->server); + $uuid = $this->server->uuid; + + $this->server->forceDelete(); + $this->privateKey->delete(); + + Livewire::test(TransferImport::class) + ->set('bundleJson', json_encode($bundle)) + ->set('preserveUuids', true) + ->set('adoptMode', true) + ->set('writeRemote', false) + ->call('importBundle') + ->assertSet('importedServerUuid', $uuid) + ->assertSet('lastResult.dry_run', false) + ->assertSet('lastResult.claimed', true); + + $server = Server::where('uuid', $uuid)->first(); + expect($server)->not->toBeNull() + ->and(data_get($server->server_metadata, 'transfer.status'))->toBe('claimed'); +}); diff --git a/tests/Feature/ServerSidebarIconsTest.php b/tests/Feature/ServerSidebarIconsTest.php index c405dfba0..414a86381 100644 --- a/tests/Feature/ServerSidebarIconsTest.php +++ b/tests/Feature/ServerSidebarIconsTest.php @@ -41,6 +41,8 @@ it('uses the network reicon for proxy in the server sidebar', function () { expect($contents) ->toContain("'label' => 'Proxy'") - ->toMatch("/'label' => 'Proxy',\s*'route' => 'server\.proxy',\s*'active' => request\(\)->routeIs\('server\.proxy', 'server\.proxy\.\*'\),\s*'icon' => 'network'/s") - ->not->toMatch("/'label' => 'Proxy',\s*'route' => 'server\.proxy',\s*'active' => request\(\)->routeIs\('server\.proxy', 'server\.proxy\.\*'\),\s*'icon' => 'settings'/s"); + ->toContain("'route' => 'server.proxy'") + ->toContain("'icon' => 'network'") + ->toMatch("/'label' => 'Proxy'[\s\S]*?'icon' => 'network'/") + ->not->toMatch("/'label' => 'Proxy'[\s\S]*?'icon' => 'settings',\s*'group' => 'Platform'/"); }); diff --git a/tests/Unit/ServerTransfer/ServerTransferBundleTest.php b/tests/Unit/ServerTransfer/ServerTransferBundleTest.php new file mode 100644 index 000000000..5a476cf34 --- /dev/null +++ b/tests/Unit/ServerTransfer/ServerTransferBundleTest.php @@ -0,0 +1,94 @@ + ['uuid' => 'abc']]); + + expect($bundle['schema_version'])->toBe(ServerTransferBundle::SCHEMA_VERSION) + ->and($bundle['export_id'])->toBeString()->not->toBeEmpty() + ->and($bundle['exported_at'])->toBeString() + ->and($bundle['server']['uuid'])->toBe('abc'); +}); + +test('validate rejects missing required fields', function () { + $result = ServerTransferBundle::validate([]); + + expect($result['valid'])->toBeFalse() + ->and($result['errors'])->not->toBeEmpty(); +}); + +test('validate accepts a minimal valid bundle', function () { + $bundle = ServerTransferBundle::wrap([ + 'private_key' => ['private_key' => "-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n-----END OPENSSH PRIVATE KEY-----"], + 'server' => [ + 'uuid' => 'srv-1', + 'name' => 'web', + 'ip' => '10.0.0.1', + 'port' => 22, + 'user' => 'root', + ], + 'destinations' => [], + 'projects' => [], + ]); + + $result = ServerTransferBundle::validate($bundle); + + expect($result['valid'])->toBeTrue() + ->and($result['warnings'])->not->toBeEmpty(); // empty destinations warning +}); + +test('assertValid throws validation exception', function () { + ServerTransferBundle::assertValid(['schema_version' => 99]); +})->throws(ValidationException::class); + +test('passphrase encrypt decrypt round trip', function () { + $original = ServerTransferBundle::wrap([ + 'private_key' => ['private_key' => 'secret-key-material'], + 'server' => [ + 'uuid' => 'srv-1', + 'name' => 'web', + 'ip' => '10.0.0.1', + 'port' => 22, + 'user' => 'root', + ], + 'destinations' => [['uuid' => 'd1', 'name' => 'coolify', 'network' => 'coolify', 'type' => 'standalone']], + 'projects' => [], + ]); + + $encrypted = ServerTransferBundle::encryptWithPassphrase($original, 'correct horse battery staple'); + + expect($encrypted['encrypted'])->toBeTrue() + ->and($encrypted['payload'])->toBeString(); + + $restored = ServerTransferBundle::decryptWithPassphrase($encrypted, 'correct horse battery staple'); + + expect($restored['export_id'])->toBe($original['export_id']) + ->and($restored['server']['uuid'])->toBe('srv-1') + ->and($restored['private_key']['private_key'])->toBe('secret-key-material'); +}); + +test('wrong passphrase fails decrypt', function () { + $original = ServerTransferBundle::wrap([ + 'private_key' => ['private_key' => 'x'], + 'server' => ['uuid' => 's', 'name' => 'n', 'ip' => '1.1.1.1', 'port' => 22, 'user' => 'root'], + 'destinations' => [], + 'projects' => [], + ]); + + $encrypted = ServerTransferBundle::encryptWithPassphrase($original, 'good-pass'); + + ServerTransferBundle::decryptWithPassphrase($encrypted, 'bad-pass'); +})->throws(RuntimeException::class); + +test('app key seal unseal round trip', function () { + $original = ['hello' => 'world', 'n' => 1]; + $sealed = ServerTransferBundle::sealWithAppKey($original); + $restored = ServerTransferBundle::unsealWithAppKey($sealed); + + expect($restored)->toBe($original); +}); diff --git a/tests/Unit/ServerTransfer/ServerTransferClaimerTest.php b/tests/Unit/ServerTransfer/ServerTransferClaimerTest.php new file mode 100644 index 000000000..8f3873ce1 --- /dev/null +++ b/tests/Unit/ServerTransfer/ServerTransferClaimerTest.php @@ -0,0 +1,66 @@ + 0, 'is_api_enabled' => true, 'fqdn' => 'https://coolify-a.test']); + + $this->team = Team::factory()->create(); + $this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + 'ip' => '10.77.0.10', + 'name' => 'claim-me', + 'server_metadata' => [ + 'transfer' => [ + 'status' => 'imported', + 'export_id' => 'export-xyz', + ], + ], + ]); +}); + +test('markTransferred writes force_disabled and transfer status together', function () { + $result = app(ServerTransferClaimer::class)->markTransferred( + $this->server, + exportId: 'export-xyz', + targetInstanceUrl: 'https://coolify-b.test', + ); + + expect($result['server_uuid'])->toBe($this->server->uuid); + + $this->server->refresh(); + expect(data_get($this->server->server_metadata, 'transfer.status'))->toBe('transferred') + ->and(data_get($this->server->server_metadata, 'transfer.export_id'))->toBe('export-xyz') + ->and(data_get($this->server->server_metadata, 'transfer.target_instance_url'))->toBe('https://coolify-b.test') + ->and((bool) $this->server->settings->force_disabled)->toBeTrue() + ->and((bool) $this->server->settings->is_sentinel_enabled)->toBeFalse(); +}); + +test('claim persists ownership metadata transactionally without remote write', function () { + $result = app(ServerTransferClaimer::class)->claim( + $this->server, + writeRemote: false, + rebindSentinel: true, + ); + + expect($result['server_uuid'])->toBe($this->server->uuid) + ->and($result['claim_written'])->toBeFalse() + ->and($result['sentinel_rebound'])->toBeTrue() + ->and(data_get($result, 'claim.instance_url'))->toBe('https://coolify-a.test'); + + $this->server->refresh(); + expect(data_get($this->server->server_metadata, 'transfer.status'))->toBe('claimed') + ->and(data_get($this->server->server_metadata, 'transfer.claim_written'))->toBeFalse() + ->and(data_get($this->server->server_metadata, 'transfer.export_id'))->toBe('export-xyz') + ->and((string) $this->server->settings->sentinel_custom_url)->toBe('https://coolify-a.test'); +}); diff --git a/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php b/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php new file mode 100644 index 000000000..28675903b --- /dev/null +++ b/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php @@ -0,0 +1,861 @@ + 0, 'is_api_enabled' => true, 'fqdn' => 'https://coolify-a.test']); + + $this->team = Team::factory()->create(); + $this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id, 'name' => 'transfer-key']); + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + 'ip' => '10.55.0.10', + 'name' => 'source-server', + 'description' => 'Server to export', + 'port' => 22, + 'user' => 'root', + ]); + $this->destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail(); + $this->destination->update(['name' => 'coolify', 'network' => 'coolify']); + + $this->project = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'Transfer Project']); + $this->environment = $this->project->environments()->first() + ?? Environment::factory()->create(['project_id' => $this->project->id, 'name' => 'production']); + + $this->application = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'name' => 'my-app', + 'git_repository' => 'https://github.com/example/app', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'fqdn' => 'https://app.example.com', + 'status' => 'running:healthy', + ]); + + EnvironmentVariable::withoutEvents(function () { + $env = new EnvironmentVariable; + $env->forceFill([ + 'key' => 'APP_SECRET', + 'value' => 'super-secret-value', + 'resourceable_type' => Application::class, + 'resourceable_id' => $this->application->id, + 'is_preview' => false, + 'is_runtime' => true, + 'is_buildtime' => true, + ]); + $env->uuid = new_public_id(); + $env->save(); + }); + + LocalPersistentVolume::create([ + 'name' => $this->application->uuid.'-data', + 'mount_path' => '/app/data', + 'host_path' => null, + 'resource_type' => $this->application->getMorphClass(), + 'resource_id' => $this->application->id, + ]); + + ScheduledTask::create([ + 'name' => 'nightly', + 'command' => 'php artisan schedule:run', + 'frequency' => '0 0 * * *', + 'application_id' => $this->application->id, + 'team_id' => $this->team->id, + 'enabled' => true, + ]); + + StandalonePostgresql::withoutEvents(function () { + $database = new StandalonePostgresql; + $database->forceFill([ + 'name' => 'app-db', + 'postgres_user' => 'postgres', + 'postgres_password' => 'db-password-secret', + 'postgres_db' => 'app', + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'status' => 'running:healthy', + ]); + $database->uuid = new_public_id(); + $database->save(); + $this->database = $database; + }); + + LocalPersistentVolume::create([ + 'name' => 'postgres-data-'.$this->database->uuid, + 'mount_path' => '/var/lib/postgresql/data', + 'resource_type' => $this->database->getMorphClass(), + 'resource_id' => $this->database->id, + ]); + + SharedEnvironmentVariable::create([ + 'key' => 'SHARED_API_KEY', + 'value' => 'shared-secret', + 'type' => 'server', + 'server_id' => $this->server->id, + 'team_id' => $this->team->id, + 'is_literal' => true, + ]); + + $this->appTag = Tag::create([ + 'name' => 'transfer-demo', + 'team_id' => $this->team->id, + ]); + $this->application->tags()->attach($this->appTag->id); + + $this->dbTag = Tag::create([ + 'name' => 'critical', + 'team_id' => $this->team->id, + ]); + $this->database->tags()->attach($this->dbTag->id); + + $this->backup = ScheduledDatabaseBackup::create([ + 'uuid' => new_public_id(), + 'team_id' => $this->team->id, + 'enabled' => true, + 'save_s3' => false, + 'frequency' => '0 2 * * *', + 'databases_to_backup' => 'app', + 'database_type' => $this->database->getMorphClass(), + 'database_id' => $this->database->id, + ]); + + $this->service = Service::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'server_id' => $this->server->id, + 'name' => 'demo-service', + 'docker_compose_raw' => "services:\n whoami:\n image: traefik/whoami\n", + ]); + + $this->serviceTask = ScheduledTask::create([ + 'name' => 'service-ping', + 'command' => 'echo ping', + 'frequency' => '*/15 * * * *', + 'service_id' => $this->service->id, + 'team_id' => $this->team->id, + 'enabled' => true, + ]); + + $this->exporter = app(ServerTransferExporter::class); + $this->importer = app(ServerTransferImporter::class); +}); + +test('export includes server applications databases and secrets in plaintext', function () { + $bundle = $this->exporter->export($this->server); + + expect($bundle['schema_version'])->toBe(ServerTransferBundle::SCHEMA_VERSION) + ->and($bundle['server']['uuid'])->toBe($this->server->uuid) + ->and($bundle['server']['ip'])->toBe('10.55.0.10') + ->and($bundle['private_key']['private_key'])->toContain('BEGIN OPENSSH PRIVATE KEY') + ->and($bundle['private_key']['fingerprint'])->toBe($this->privateKey->fingerprint) + ->and($bundle['destinations'])->not->toBeEmpty() + ->and($bundle['projects'])->toHaveCount(1) + ->and($bundle['shared_environment_variables']['server'])->toHaveCount(1); + + $environment = $bundle['projects'][0]['environments'][0]; + expect($environment['applications'])->toHaveCount(1) + ->and($environment['databases'])->toHaveCount(1) + ->and($environment['services'])->toHaveCount(1); + + $app = $environment['applications'][0]; + $secret = collect($app['environment_variables'])->firstWhere('key', 'APP_SECRET'); + expect($app['uuid'])->toBe($this->application->uuid) + ->and($app['attributes']['name'])->toBe('my-app') + ->and($secret)->not->toBeNull() + ->and($secret['value'])->toBe('super-secret-value') + ->and($app['persistent_storages'])->toHaveCount(1) + ->and($app['scheduled_tasks'])->toHaveCount(1) + ->and($app['tags'])->toHaveCount(1) + ->and($app['tags'][0]['name'])->toBe('transfer-demo'); + + $db = $environment['databases'][0]; + expect($db['type'])->toBe('StandalonePostgresql') + ->and($db['attributes']['postgres_password'])->toBe('db-password-secret') + ->and($db['tags'])->toHaveCount(1) + ->and($db['tags'][0]['name'])->toBe('critical') + ->and($db['scheduled_backups'])->toHaveCount(1) + ->and($db['scheduled_backups'][0]['frequency'])->toBe('0 2 * * *'); + + $service = $environment['services'][0]; + expect($service['uuid'])->toBe($this->service->uuid) + ->and($service['scheduled_tasks'])->toHaveCount(1) + ->and($service['scheduled_tasks'][0]['name'])->toBe('service-ping'); +}); + +test('export refuses localhost coolify host', function () { + $localhost = Server::factory()->create([ + 'id' => 0, + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + 'ip' => 'host.docker.internal', + ]); + + $this->exporter->export($localhost); +})->throws(RuntimeException::class); + +test('dry run import reports counts without creating server', function () { + $bundle = $this->exporter->export($this->server); + $before = Server::count(); + + $result = $this->importer->import($bundle, teamId: $this->team->id, dryRun: true); + + expect($result['dry_run'])->toBeTrue() + ->and($result['created']['applications'])->toBe(1) + ->and($result['created']['databases'])->toBe(1) + ->and(Server::count())->toBe($before); +}); + +test('round trip import preserves uuids secrets and related resources after source removal', function () { + $bundle = $this->exporter->export($this->server); + $originalServerUuid = $this->server->uuid; + $originalAppUuid = $this->application->uuid; + $originalDbUuid = $this->database->uuid; + $originalDestUuid = $this->destination->uuid; + $originalServiceUuid = $this->service->uuid; + $originalBackupUuid = $this->backup->uuid; + $originalServiceTaskUuid = $this->serviceTask->uuid; + $keyMaterial = $this->privateKey->private_key; + + // Simulate handoff: source instance no longer owns the IP / records. + $this->service->forceDelete(); + $this->application->forceDelete(); + $this->database->forceDelete(); + $this->server->forceDelete(); + Tag::query()->delete(); + ScheduledDatabaseBackup::query()->delete(); + ScheduledTask::query()->delete(); + // Same fingerprint cannot exist twice; remove source key so target re-creates it. + $this->privateKey->delete(); + + $result = $this->importer->import($bundle, teamId: $this->team->id, dryRun: false, preserveUuids: true, adoptMode: true); + + expect($result['dry_run'])->toBeFalse() + ->and($result['server_uuid'])->toBe($originalServerUuid) + ->and($result['created']['applications'])->toBe(1) + ->and($result['created']['databases'])->toBe(1) + ->and($result['created']['services'])->toBe(1); + + $server = Server::where('uuid', $originalServerUuid)->first(); + expect($server)->not->toBeNull() + ->and($server->ip)->toBe('10.55.0.10') + ->and($server->name)->toBe('source-server') + ->and($server->privateKey->private_key)->toBe($keyMaterial) + ->and(data_get($server->server_metadata, 'transfer.status'))->toBe('claimed') + ->and(data_get($server->server_metadata, 'transfer.adopt_mode'))->toBeTrue(); + + $destination = StandaloneDocker::where('server_id', $server->id)->where('uuid', $originalDestUuid)->first(); + expect($destination)->not->toBeNull(); + + $app = Application::where('uuid', $originalAppUuid)->first(); + expect($app)->not->toBeNull() + ->and($app->name)->toBe('my-app') + ->and($app->fqdn)->toBe('https://app.example.com') + ->and($app->environment_variables()->where('key', 'APP_SECRET')->first()?->value)->toBe('super-secret-value') + ->and($app->persistentStorages)->toHaveCount(1) + ->and($app->scheduled_tasks)->toHaveCount(1) + ->and($app->tags()->pluck('name')->all())->toContain('transfer-demo'); + + $db = StandalonePostgresql::where('uuid', $originalDbUuid)->first(); + expect($db)->not->toBeNull() + ->and($db->postgres_password)->toBe('db-password-secret') + ->and($db->persistentStorages)->not->toBeEmpty() + ->and($db->tags()->pluck('name')->all())->toContain('critical'); + + $backup = ScheduledDatabaseBackup::where('uuid', $originalBackupUuid)->first(); + expect($backup)->not->toBeNull() + ->and($backup->frequency)->toBe('0 2 * * *') + ->and((bool) $backup->enabled)->toBeTrue() + ->and($backup->database_id)->toBe($db->id); + + $service = Service::where('uuid', $originalServiceUuid)->first(); + expect($service)->not->toBeNull() + ->and($service->name)->toBe('demo-service'); + + $serviceTask = ScheduledTask::where('uuid', $originalServiceTaskUuid)->first(); + expect($serviceTask)->not->toBeNull() + ->and($serviceTask->service_id)->toBe($service->id) + ->and($serviceTask->name)->toBe('service-ping') + ->and($serviceTask->command)->toBe('echo ping'); + + $shared = SharedEnvironmentVariable::query() + ->where('server_id', $server->id) + ->where('key', 'SHARED_API_KEY') + ->first(); + expect($shared)->not->toBeNull() + ->and($shared->value)->toBe('shared-secret'); +}); + +test('import fails when server ip already exists', function () { + $bundle = $this->exporter->export($this->server); + + $this->importer->import($bundle, teamId: $this->team->id); +})->throws(ValidationException::class); + +test('import rolls back all created rows when a later resource fails', function () { + $bundle = $this->exporter->export($this->server); + $originalServerUuid = $this->server->uuid; + + // Force a failure after private keys / server would have been created. + $bundle['projects'][0]['environments'][0]['databases'][] = [ + 'type' => 'NotARealDatabaseType', + 'uuid' => 'broken-db-uuid', + 'attributes' => ['name' => 'broken'], + 'destination_uuid' => $this->destination->uuid, + ]; + + $this->service->forceDelete(); + $this->application->forceDelete(); + $this->database->forceDelete(); + $this->server->forceDelete(); + $this->privateKey->delete(); + + expect(fn () => $this->importer->import( + $bundle, + teamId: $this->team->id, + dryRun: false, + preserveUuids: true, + adoptMode: true, + claim: false, + ))->toThrow(RuntimeException::class, 'Unsupported database type'); + + expect(Server::where('uuid', $originalServerUuid)->exists())->toBeFalse() + ->and(PrivateKey::where('uuid', data_get($bundle, 'private_key.uuid'))->exists())->toBeFalse() + ->and(Application::where('uuid', data_get($bundle, 'projects.0.environments.0.applications.0.uuid'))->exists())->toBeFalse(); +}); + +test('import fails on invalid schema', function () { + $this->importer->import(['schema_version' => 1], teamId: $this->team->id); +})->throws(ValidationException::class); + +test('import reuses existing private key fingerprint on same team', function () { + $bundle = $this->exporter->export($this->server); + $originalKeyId = $this->privateKey->id; + + // Free the IP without deleting the key. + $this->application->forceDelete(); + $this->database->forceDelete(); + $this->server->forceDelete(); + + $result = $this->importer->import($bundle, teamId: $this->team->id); + + $server = Server::where('uuid', $result['server_uuid'])->first(); + expect($server->private_key_id)->toBe($originalKeyId); +}); + +test('encrypted export decrypts for import', function () { + $bundle = $this->exporter->export($this->server); + $encrypted = ServerTransferBundle::encryptWithPassphrase($bundle, 'transfer-pass'); + + $this->application->forceDelete(); + $this->database->forceDelete(); + $this->server->forceDelete(); + $this->privateKey->delete(); + + $plain = ServerTransferBundle::decryptWithPassphrase($encrypted, 'transfer-pass'); + $result = $this->importer->import($plain, teamId: $this->team->id); + + expect(Server::where('uuid', $result['server_uuid'])->exists())->toBeTrue(); +}); + +test('system-wide github apps are not exported and re-link on import by uuid', function () { + $systemWide = new GithubApp; + $systemWide->forceFill([ + 'name' => 'System Public GitHub', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'is_public' => true, + 'is_system_wide' => true, + 'team_id' => $this->team->id, + ]); + $systemWide->uuid = 'system-github-public'; + $systemWide->save(); + + $teamApp = new GithubApp; + $teamApp->forceFill([ + 'name' => 'Team GitHub App', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'is_public' => false, + 'is_system_wide' => false, + 'team_id' => $this->team->id, + 'app_id' => 12345, + 'installation_id' => 67890, + 'client_id' => 'Iv1.test', + 'client_secret' => 'secret', + 'webhook_secret' => 'hook', + ]); + $teamApp->uuid = 'team-github-app'; + $teamApp->save(); + + $this->application->source_type = GithubApp::class; + $this->application->source_id = $systemWide->id; + $this->application->save(); + + $otherApp = Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'name' => 'team-source-app', + 'git_repository' => 'https://github.com/example/private', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'source_type' => GithubApp::class, + 'source_id' => $teamApp->id, + ]); + + $bundle = $this->exporter->export($this->server); + + expect($bundle['github_apps'])->toHaveCount(1) + ->and($bundle['github_apps'][0]['uuid'])->toBe('team-github-app') + ->and(collect($bundle['warnings'])->implode(' '))->toContain('system-wide GitHub App'); + + $appSources = collect($bundle['projects'][0]['environments'][0]['applications']) + ->mapWithKeys(fn ($a) => [$a['attributes']['name'] => $a['source'] ?? null]); + + expect($appSources['my-app'])->toMatchArray(['type' => 'github_app', 'uuid' => 'system-github-public']) + ->and($appSources['team-source-app'])->toMatchArray(['type' => 'github_app', 'uuid' => 'team-github-app']); + + // Free server IP / apps for import into same DB. + $this->service->forceDelete(); + $otherApp->forceDelete(); + $this->application->forceDelete(); + $this->database->forceDelete(); + $this->server->forceDelete(); + $this->privateKey->delete(); + GithubApp::where('uuid', 'team-github-app')->delete(); + // Simulate target instance already having the same system-wide app UUID. + $systemWide->delete(); + + $targetTeam = Team::factory()->create(); + $targetSystemWide = new GithubApp; + $targetSystemWide->forceFill([ + 'name' => 'System Public GitHub', + 'api_url' => 'https://api.github.com', + 'html_url' => 'https://github.com', + 'is_public' => true, + 'is_system_wide' => true, + 'team_id' => $targetTeam->id, + ]); + $targetSystemWide->uuid = 'system-github-public'; + $targetSystemWide->save(); + + $result = $this->importer->import($bundle, teamId: $targetTeam->id, preserveUuids: true, adoptMode: true); + + expect($result['created']['github_apps'])->toBe(1); + + $importedSystem = Application::where('name', 'my-app')->first(); + $importedTeam = Application::where('name', 'team-source-app')->first(); + $importedGhTeam = GithubApp::where('uuid', 'team-github-app')->first(); + + expect($importedSystem)->not->toBeNull() + ->and($importedSystem->source_type)->toBe(GithubApp::class) + ->and($importedSystem->source_id)->toBe($targetSystemWide->id) + ->and($importedTeam->source_id)->toBe($importedGhTeam->id) + ->and($importedGhTeam->is_system_wide)->toBeFalse() + ->and(GithubApp::where('is_system_wide', true)->where('uuid', 'system-github-public')->count())->toBe(1); +}); + +test('service nested apps dbs volumes backups and db file storages round-trip', function () { + $serviceApp = new ServiceApplication; + $serviceApp->forceFill([ + 'service_id' => $this->service->id, + 'name' => 'whoami', + 'human_name' => 'Whoami', + 'description' => 'demo nested app', + 'fqdn' => 'https://whoami.example.com', + 'ports' => '80:80', + 'exposes' => '80', + 'image' => 'traefik/whoami:latest', + 'exclude_from_status' => false, + 'required_fqdn' => true, + 'is_log_drain_enabled' => true, + 'is_gzip_enabled' => false, + 'is_stripprefix_enabled' => false, + 'status' => 'running:healthy', + ]); + $serviceApp->uuid = 'svc-app-whoami'; + $serviceApp->save(); + + EnvironmentVariable::withoutEvents(function () use ($serviceApp) { + $env = new EnvironmentVariable; + $env->forceFill([ + 'key' => 'WHOAMI_NAME', + 'value' => 'nested-secret', + 'resourceable_type' => ServiceApplication::class, + 'resourceable_id' => $serviceApp->id, + 'is_preview' => false, + 'is_runtime' => true, + 'is_buildtime' => false, + ]); + $env->uuid = new_public_id(); + $env->save(); + }); + + LocalPersistentVolume::create([ + 'name' => 'whoami-data', + 'mount_path' => '/data', + 'host_path' => null, + 'resource_type' => $serviceApp->getMorphClass(), + 'resource_id' => $serviceApp->id, + ]); + $serviceAppVolume = LocalPersistentVolume::where('name', 'whoami-data')->firstOrFail(); + + LocalFileVolume::withoutEvents(function () use ($serviceApp) { + $file = new LocalFileVolume; + $file->forceFill([ + 'fs_path' => './config.json', + 'mount_path' => '/app/config.json', + 'content' => '{"nested":true}', + 'is_directory' => false, + 'resource_type' => $serviceApp->getMorphClass(), + 'resource_id' => $serviceApp->id, + ]); + $file->uuid = 'svc-app-file-1'; + $file->save(); + }); + + ScheduledVolumeBackup::create([ + 'uuid' => 'svc-app-vol-backup', + 'backupable_type' => $serviceAppVolume->getMorphClass(), + 'backupable_id' => $serviceAppVolume->id, + 'team_id' => $this->team->id, + 'frequency' => '0 4 * * *', + 'enabled' => true, + 'save_s3' => false, + 'disable_local_backup' => false, + ]); + + $serviceDb = new ServiceDatabase; + $serviceDb->forceFill([ + 'service_id' => $this->service->id, + 'name' => 'postgres', + 'human_name' => 'Nested PG', + 'image' => 'postgres:16', + 'ports' => '5432', + 'exposes' => '5432', + 'public_port' => 15432, + 'public_port_timeout' => 30, + 'is_public' => true, + 'custom_type' => 'postgresql', + 'status' => 'running:healthy', + ]); + $serviceDb->uuid = 'svc-db-postgres'; + $serviceDb->save(); + + LocalPersistentVolume::create([ + 'name' => 'svc-pg-data', + 'mount_path' => '/var/lib/postgresql/data', + 'resource_type' => $serviceDb->getMorphClass(), + 'resource_id' => $serviceDb->id, + ]); + + LocalFileVolume::withoutEvents(function () use ($serviceDb) { + $file = new LocalFileVolume; + $file->forceFill([ + 'fs_path' => './init.sql', + 'mount_path' => '/docker-entrypoint-initdb.d/init.sql', + 'content' => 'SELECT 1;', + 'is_directory' => false, + 'resource_type' => $serviceDb->getMorphClass(), + 'resource_id' => $serviceDb->id, + ]); + $file->uuid = 'svc-db-file-1'; + $file->save(); + }); + + $serviceDbBackup = ScheduledDatabaseBackup::create([ + 'uuid' => 'svc-db-backup-1', + 'team_id' => $this->team->id, + 'enabled' => true, + 'save_s3' => false, + 'frequency' => '0 3 * * *', + 'databases_to_backup' => 'postgres', + 'database_type' => $serviceDb->getMorphClass(), + 'database_id' => $serviceDb->id, + ]); + + // Standalone database file storage + LocalFileVolume::withoutEvents(function () { + $file = new LocalFileVolume; + $file->forceFill([ + 'fs_path' => './pg-conf.d', + 'mount_path' => '/etc/postgresql/conf.d', + 'content' => null, + 'is_directory' => true, + 'resource_type' => $this->database->getMorphClass(), + 'resource_id' => $this->database->id, + ]); + $file->uuid = 'standalone-db-file-1'; + $file->save(); + }); + + // Preview + preview volume + $preview = new ApplicationPreview; + $preview->forceFill([ + 'application_id' => $this->application->id, + 'pull_request_id' => 42, + 'pull_request_html_url' => 'https://github.com/example/app/pull/42', + 'fqdn' => 'https://pr-42.app.example.com', + 'status' => 'running:healthy', + 'git_type' => 'github', + ]); + $preview->uuid = 'preview-pr-42'; + $preview->save(); + + LocalPersistentVolume::create([ + 'name' => 'preview-42-data', + 'mount_path' => '/app/data', + 'resource_type' => $preview->getMorphClass(), + 'resource_id' => $preview->id, + ]); + + $bundle = $this->exporter->export($this->server); + + $envPayload = $bundle['projects'][0]['environments'][0]; + $servicePayload = $envPayload['services'][0]; + $nestedApp = collect($servicePayload['applications'])->firstWhere('uuid', 'svc-app-whoami'); + $nestedDb = collect($servicePayload['databases'])->firstWhere('uuid', 'svc-db-postgres'); + $appPayload = $envPayload['applications'][0]; + $dbPayload = $envPayload['databases'][0]; + + expect($nestedApp)->not->toBeNull() + ->and($nestedApp['ports'])->toBe('80:80') + ->and($nestedApp['exposes'])->toBe('80') + ->and($nestedApp['environment_variables'])->toHaveCount(1) + ->and($nestedApp['environment_variables'][0]['value'])->toBe('nested-secret') + ->and($nestedApp['persistent_storages'])->toHaveCount(1) + ->and($nestedApp['file_storages'])->toHaveCount(1) + ->and($nestedApp['file_storages'][0]['content'])->toBe('{"nested":true}') + ->and($nestedDb)->not->toBeNull() + ->and($nestedDb['public_port'])->toBe(15432) + ->and($nestedDb['custom_type'])->toBe('postgresql') + ->and($nestedDb['persistent_storages'])->toHaveCount(1) + ->and($nestedDb['file_storages'])->toHaveCount(1) + ->and($nestedDb['scheduled_backups'])->toHaveCount(1) + ->and($nestedDb['scheduled_backups'][0]['uuid'])->toBe('svc-db-backup-1') + ->and($dbPayload['file_storages'])->toHaveCount(1) + ->and($dbPayload['file_storages'][0]['uuid'])->toBe('standalone-db-file-1') + ->and($appPayload['previews'])->toHaveCount(1) + ->and($appPayload['previews'][0]['uuid'])->toBe('preview-pr-42') + ->and($appPayload['previews'][0]['persistent_storages'])->toHaveCount(1) + ->and(collect($bundle['volume_backups'])->pluck('uuid')->all())->toContain('svc-app-vol-backup'); + + $originalServerUuid = $this->server->uuid; + $originalAppUuid = $this->application->uuid; + $originalDbUuid = $this->database->uuid; + $originalServiceUuid = $this->service->uuid; + + // Free server IP / UUIDs for re-import. Clear soft-deleted previews (unique FQDN) + // without firing remote docker cleanup hooks. + ApplicationPreview::withoutEvents(function () { + ApplicationPreview::withTrashed()->get()->each->forceDelete(); + }); + ServiceApplication::withoutEvents(function () { + ServiceApplication::withTrashed()->get()->each->forceDelete(); + }); + ServiceDatabase::withoutEvents(function () { + ServiceDatabase::withTrashed()->get()->each->forceDelete(); + }); + DB::table('environment_variables')->delete(); + DB::table('local_file_volumes')->delete(); + DB::table('local_persistent_volumes')->delete(); + DB::table('scheduled_volume_backups')->delete(); + DB::table('scheduled_database_backups')->delete(); + DB::table('scheduled_tasks')->delete(); + DB::table('services')->delete(); + DB::table('applications')->delete(); + DB::table('standalone_postgresqls')->delete(); + DB::table('standalone_dockers')->delete(); + DB::table('servers')->delete(); + $this->privateKey->delete(); + Tag::query()->delete(); + + $result = $this->importer->import($bundle, teamId: $this->team->id, preserveUuids: true, adoptMode: true); + + expect($result['created']['services'])->toBe(1) + ->and($result['server_uuid'])->toBe($originalServerUuid); + + $importedService = Service::where('uuid', $originalServiceUuid)->first(); + expect($importedService)->not->toBeNull(); + + $importedSvcApp = ServiceApplication::where('uuid', 'svc-app-whoami')->first(); + expect($importedSvcApp)->not->toBeNull() + ->and($importedSvcApp->ports)->toBe('80:80') + ->and($importedSvcApp->exposes)->toBe('80') + ->and((bool) $importedSvcApp->is_gzip_enabled)->toBeFalse() + ->and((bool) $importedSvcApp->is_stripprefix_enabled)->toBeFalse() + ->and($importedSvcApp->environment_variables()->where('key', 'WHOAMI_NAME')->first()?->value)->toBe('nested-secret') + ->and($importedSvcApp->persistentStorages)->toHaveCount(1) + ->and($importedSvcApp->fileStorages)->toHaveCount(1) + ->and($importedSvcApp->fileStorages->first()->content)->toBe('{"nested":true}'); + + $importedSvcDb = ServiceDatabase::where('uuid', 'svc-db-postgres')->first(); + expect($importedSvcDb)->not->toBeNull() + ->and($importedSvcDb->public_port)->toBe(15432) + ->and($importedSvcDb->custom_type)->toBe('postgresql') + ->and($importedSvcDb->persistentStorages)->toHaveCount(1) + ->and($importedSvcDb->fileStorages)->toHaveCount(1) + ->and($importedSvcDb->scheduledBackups)->toHaveCount(1) + ->and($importedSvcDb->scheduledBackups->first()->uuid)->toBe('svc-db-backup-1'); + + $importedStandaloneDb = StandalonePostgresql::where('uuid', $originalDbUuid)->first(); + expect($importedStandaloneDb)->not->toBeNull() + ->and($importedStandaloneDb->fileStorages)->toHaveCount(1) + ->and($importedStandaloneDb->fileStorages->first()->uuid)->toBe('standalone-db-file-1'); + + $importedApp = Application::where('uuid', $originalAppUuid)->first(); + expect($importedApp)->not->toBeNull() + ->and($importedApp->previews)->toHaveCount(1) + ->and($importedApp->previews->first()->uuid)->toBe('preview-pr-42') + ->and($importedApp->previews->first()->persistentStorages)->toHaveCount(1); + + $importedVolBackup = ScheduledVolumeBackup::where('uuid', 'svc-app-vol-backup')->first(); + expect($importedVolBackup)->not->toBeNull() + ->and($importedVolBackup->frequency)->toBe('0 4 * * *') + ->and((bool) $importedVolBackup->enabled)->toBeTrue(); +}); + +test('export refuses servers with additional destinations', function () { + $extraDestination = StandaloneDocker::create([ + 'name' => 'extra-net', + 'network' => 'extra-net-block', + 'server_id' => $this->server->id, + ]); + + $this->application->additional_networks()->attach($extraDestination->id, [ + 'server_id' => $this->server->id, + 'status' => 'running:healthy', + ]); + + expect(fn () => $this->exporter->export($this->server)) + ->toThrow(RuntimeException::class, 'additional destinations'); +}); + +test('system-wide gitlab apps are not exported and re-link on import by uuid', function () { + $systemWide = new GitlabApp; + $systemWide->forceFill([ + 'name' => 'System Public GitLab', + 'api_url' => 'https://gitlab.com/api/v4', + 'html_url' => 'https://gitlab.com', + 'is_public' => true, + 'is_system_wide' => true, + 'team_id' => $this->team->id, + ]); + $systemWide->uuid = 'system-gitlab-public'; + $systemWide->save(); + + $teamApp = new GitlabApp; + $teamApp->forceFill([ + 'name' => 'Team GitLab App', + 'api_url' => 'https://gitlab.com/api/v4', + 'html_url' => 'https://gitlab.com', + 'is_public' => false, + 'is_system_wide' => false, + 'team_id' => $this->team->id, + 'app_id' => '123', + 'app_secret' => 'secret', + 'oauth_id' => 1, + 'client_id' => 'client', + 'client_secret' => 'csecret', + 'group_name' => 'team', + ]); + $teamApp->uuid = 'team-gitlab-app'; + $teamApp->save(); + + $this->application->source_type = GitlabApp::class; + $this->application->source_id = $systemWide->id; + $this->application->save(); + + Application::factory()->create([ + 'environment_id' => $this->environment->id, + 'destination_id' => $this->destination->id, + 'destination_type' => $this->destination->getMorphClass(), + 'name' => 'team-gl-source-app', + 'git_repository' => 'https://gitlab.com/example/private', + 'git_branch' => 'main', + 'build_pack' => 'nixpacks', + 'ports_exposes' => '3000', + 'source_type' => GitlabApp::class, + 'source_id' => $teamApp->id, + ]); + + $bundle = $this->exporter->export($this->server); + + expect($bundle['gitlab_apps'])->toHaveCount(1) + ->and($bundle['gitlab_apps'][0]['uuid'])->toBe('team-gitlab-app') + ->and(collect($bundle['warnings'])->implode(' '))->toContain('system-wide GitLab App'); + + $this->service->forceDelete(); + Application::query()->forceDelete(); + $this->database->forceDelete(); + $this->server->forceDelete(); + $this->privateKey->delete(); + GitlabApp::where('uuid', 'team-gitlab-app')->delete(); + $systemWide->delete(); + + $targetTeam = Team::factory()->create(); + $targetSystemWide = new GitlabApp; + $targetSystemWide->forceFill([ + 'name' => 'System Public GitLab', + 'api_url' => 'https://gitlab.com/api/v4', + 'html_url' => 'https://gitlab.com', + 'is_public' => true, + 'is_system_wide' => true, + 'team_id' => $targetTeam->id, + ]); + $targetSystemWide->uuid = 'system-gitlab-public'; + $targetSystemWide->save(); + + $result = $this->importer->import($bundle, teamId: $targetTeam->id, preserveUuids: true, adoptMode: true); + + expect($result['created']['gitlab_apps'])->toBe(1); + + $importedSystem = Application::where('name', 'my-app')->first(); + $importedTeam = Application::where('name', 'team-gl-source-app')->first(); + $importedGlTeam = GitlabApp::where('uuid', 'team-gitlab-app')->first(); + + expect($importedSystem)->not->toBeNull() + ->and($importedSystem->source_type)->toBe(GitlabApp::class) + ->and($importedSystem->source_id)->toBe($targetSystemWide->id) + ->and($importedTeam->source_id)->toBe($importedGlTeam->id) + ->and($importedGlTeam->is_system_wide)->toBeFalse() + ->and(GitlabApp::where('is_system_wide', true)->where('uuid', 'system-gitlab-public')->count())->toBe(1); +}); diff --git a/tests/Unit/ServerTransfer/ServerTransferMigratorTest.php b/tests/Unit/ServerTransfer/ServerTransferMigratorTest.php new file mode 100644 index 000000000..292a2aec7 --- /dev/null +++ b/tests/Unit/ServerTransfer/ServerTransferMigratorTest.php @@ -0,0 +1,158 @@ + 0, 'is_api_enabled' => true, 'fqdn' => 'https://coolify-a.test']); + + $this->team = Team::factory()->create(); + $this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + 'ip' => '10.88.0.10', + 'name' => 'migrate-me', + ]); + $destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = $project->environments()->first() + ?? Environment::factory()->create(['project_id' => $project->id]); + Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'name' => 'app-on-server', + 'ports_exposes' => '3000', + ]); +}); + +test('migrate exports imports via http and completes locally', function () { + Http::fake([ + 'http://target.test/api/v1/servers/import' => Http::response([ + 'dry_run' => false, + 'server_uuid' => $this->server->uuid, + 'claimed' => true, + 'export_id' => 'remote-export', + 'created' => ['applications' => 1], + 'warnings' => [], + ], 201), + ]); + + $result = app(ServerTransferMigrator::class)->migrate( + server: $this->server, + targetUrl: 'http://target.test', + targetToken: 'target-token-xyz', + writeRemote: false, + ); + + expect($result['server_uuid'])->toBe($this->server->uuid) + ->and($result['target_url'])->toBe('http://target.test') + ->and($result['import']['claimed'])->toBeTrue() + ->and($result['message'])->toContain('migrated'); + + $this->server->refresh(); + expect(data_get($this->server->server_metadata, 'transfer.status'))->toBe('transferred') + ->and((bool) $this->server->settings->force_disabled)->toBeTrue(); + + Http::assertSent(function ($request) { + return $request->url() === 'http://target.test/api/v1/servers/import' + && $request->hasHeader('Authorization', 'Bearer target-token-xyz') + && data_get($request->data(), 'claim') === true + && data_get($request->data(), 'bundle.server.uuid') === $this->server->uuid; + }); +}); + +test('migrate rewrites localhost target when running in docker style env', function () { + // Simulate container: create a temp marker if missing is hard; instead assert host rewrite helper via migrate call + // with Http fake matching host.docker.internal when /.dockerenv exists — skip if not in docker. + if (! file_exists('/.dockerenv') && ! is_file('/run/.containerenv')) { + expect(true)->toBeTrue(); + + return; + } + + Http::fake([ + 'http://host.docker.internal:8001/api/v1/servers/import' => Http::response([ + 'dry_run' => false, + 'server_uuid' => $this->server->uuid, + 'claimed' => true, + 'warnings' => [], + ], 201), + ]); + + app(ServerTransferMigrator::class)->migrate( + $this->server, + 'http://localhost:8001', + 'token', + ); + + Http::assertSent(fn ($request) => str_contains($request->url(), 'host.docker.internal:8001')); +}); + +test('migrate fails clearly when target is unreachable', function () { + Http::fake([ + 'http://down.test/*' => Http::failedConnection(), + ]); + + expect(fn () => app(ServerTransferMigrator::class)->migrate( + $this->server, + 'http://down.test', + 'token', + ))->toThrow(RuntimeException::class, 'Could not reach target'); +}); + +test('migrate fails when target returns error', function () { + Http::fake([ + 'http://target.test/api/v1/servers/import' => Http::response([ + 'message' => 'A server with IP/domain already exists', + ], 422), + ]); + + expect(fn () => app(ServerTransferMigrator::class)->migrate( + $this->server, + 'http://target.test', + 'token', + ))->toThrow(RuntimeException::class, 'Target import failed'); + + // Source must remain unmanaged-away only after successful remote import+complete. + $this->server->refresh(); + expect(data_get($this->server->server_metadata, 'transfer.status'))->not->toBe('transferred') + ->and((bool) $this->server->settings->force_disabled)->toBeFalse(); +}); + +test('migrate surfaces recovery guidance when complete fails after successful remote import', function () { + Http::fake([ + 'http://target.test/api/v1/servers/import' => Http::response([ + 'dry_run' => false, + 'server_uuid' => $this->server->uuid, + 'claimed' => true, + 'warnings' => [], + ], 201), + ]); + + $claimer = Mockery::mock(ServerTransferClaimer::class)->makePartial(); + $claimer->shouldReceive('markTransferred') + ->once() + ->andThrow(new RuntimeException('simulated complete failure')); + app()->instance(ServerTransferClaimer::class, $claimer); + + expect(fn () => app(ServerTransferMigrator::class)->migrate( + $this->server, + 'http://target.test', + 'token', + ))->toThrow(RuntimeException::class, 'Retry complete'); +}); diff --git a/tests/Unit/ServerTransfer/TransferredServerValidationTest.php b/tests/Unit/ServerTransfer/TransferredServerValidationTest.php new file mode 100644 index 000000000..d460aaaeb --- /dev/null +++ b/tests/Unit/ServerTransfer/TransferredServerValidationTest.php @@ -0,0 +1,71 @@ + 0]); + $this->team = Team::factory()->create(); + $this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + ]); +}); + +test('transferred servers cannot be validated', function () { + expect($this->server->canBeValidated())->toBeTrue() + ->and($this->server->isTransferredAway())->toBeFalse(); + + $this->server->server_metadata = [ + 'transfer' => ['status' => 'transferred'], + ]; + $this->server->save(); + $this->server->forceDisableServer(); + + expect($this->server->fresh()->isTransferredAway())->toBeTrue() + ->and($this->server->fresh()->canBeValidated())->toBeFalse(); +}); + +test('forceEnableServer does not clear transferred servers', function () { + $this->server->server_metadata = [ + 'transfer' => ['status' => 'transferred'], + ]; + $this->server->save(); + $this->server->forceDisableServer(); + + $this->server->forceEnableServer(); + + expect((bool) $this->server->fresh()->settings->force_disabled)->toBeTrue() + ->and($this->server->fresh()->canBeValidated())->toBeFalse(); +}); + +test('ValidateServer action rejects transferred servers', function () { + $this->server->server_metadata = [ + 'transfer' => ['status' => 'transferred'], + ]; + $this->server->save(); + + expect(fn () => ValidateServer::run($this->server)) + ->toThrow(Exception::class, 'transferred'); +}); + +test('ValidateAndInstallServerJob no-ops for transferred servers', function () { + $this->server->server_metadata = [ + 'transfer' => ['status' => 'transferred'], + ]; + $this->server->save(); + + (new ValidateAndInstallServerJob($this->server))->handle(); + + expect((bool) $this->server->fresh()->is_validating)->toBeFalse() + ->and((string) $this->server->fresh()->validation_logs)->toContain('transferred'); +});