From 7c14c0098e8768907d9b1bea17e18dfbe38bb198 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Sun, 18 Oct 2026 22:03:45 +0200
Subject: [PATCH] feat(server-transfer): run instance migration as a background
job (#12180)
Co-authored-by: Claude Opus 5.5
---
.ai/lessons.md | 1 +
AGENTS.md | 16 +
app/Console/Commands/Generate/OpenApi.php | 3 -
app/Events/TransferFinishChanged.php | 26 +
.../Api/ServerTransferController.php | 259 +++---
app/Http/Controllers/Webhook/Bitbucket.php | 2 +
app/Http/Controllers/Webhook/Gitea.php | 2 +
app/Http/Controllers/Webhook/Github.php | 3 +
app/Http/Controllers/Webhook/Gitlab.php | 3 +
app/Jobs/CheckTransferFinishStepsJob.php | 39 +
app/Jobs/CleanupGithubRunnerJob.php | 8 +
app/Jobs/DeleteResourceJob.php | 2 +-
app/Jobs/MigrateServerToInstanceJob.php | 69 ++
app/Jobs/ProvisionGithubRunnerJob.php | 9 +-
app/Jobs/ServerConnectionCheckJob.php | 31 +-
app/Livewire/Server/Delete.php | 28 +-
app/Livewire/Server/Show.php | 33 +-
app/Livewire/Server/Transfer.php | 123 +--
app/Livewire/Server/TransferFinish.php | 122 +++
app/Livewire/Server/TransferImport.php | 150 ----
app/Livewire/Source/Github/Change.php | 9 +-
app/Models/GithubApp.php | 15 +
app/Models/Server.php | 25 +-
app/Models/User.php | 4 +-
app/Services/Dns/CloudflareDnsProvider.php | 79 ++
app/Services/Dns/ManagedDnsRecordCleanup.php | 65 ++
app/Services/ProjectIconStorageService.php | 10 +-
.../ServerTransfer/ServerTransferBundle.php | 99 +--
.../ServerTransfer/ServerTransferClaimer.php | 233 +++--
.../ServerTransfer/ServerTransferCoverage.php | 347 ++++++++
.../ServerTransfer/ServerTransferExporter.php | 591 +++++++++++--
.../ServerTransfer/ServerTransferImporter.php | 824 ++++++++++++++++--
.../ServerTransfer/ServerTransferMigrator.php | 673 +++++++++++++-
.../TransferFinishChecklist.php | 581 ++++++++++++
docker-compose.dev-side-by-side.yml | 9 +
docs/server-transfer-full-coverage.md | 139 +++
openapi.json | 298 +++++++
openapi.yaml | 198 +++++
resources/css/app.css | 2 +-
resources/css/utilities.css | 5 -
resources/js/app.js | 2 -
resources/js/settings-sidebar-accordion.js | 42 -
.../configuration-sidebar.blade.php | 20 +-
.../views/components/backup-sidebar.blade.php | 1 -
.../database/configuration-sidebar.blade.php | 20 +-
resources/views/components/reicon.blade.php | 1 +
.../views/components/server/sidebar.blade.php | 26 +-
.../service-database/sidebar.blade.php | 1 -
.../service/configuration-sidebar.blade.php | 72 +-
.../livewire/project/service/index.blade.php | 1 -
.../views/livewire/server/canvas.blade.php | 10 -
.../views/livewire/server/delete.blade.php | 19 +-
.../views/livewire/server/index.blade.php | 13 +-
.../views/livewire/server/navbar.blade.php | 93 +-
.../views/livewire/server/show.blade.php | 91 +-
.../livewire/server/transfer-finish.blade.php | 171 ++++
.../livewire/server/transfer-import.blade.php | 128 ---
.../views/livewire/server/transfer.blade.php | 174 +++-
routes/api.php | 4 +-
routes/web.php | 2 -
scripts/dev | 26 +-
scripts/transfer-test/README.md | 70 ++
scripts/transfer-test/e2e.sh | 68 ++
scripts/transfer-test/extras-e2e.sh | 25 +
scripts/transfer-test/lib.sh | 153 ++++
scripts/transfer-test/manual-setup.sh | 35 +
scripts/transfer-test/reset.sh | 77 ++
scripts/transfer-test/reverse-e2e.sh | 65 ++
scripts/transfer-test/seed-extras.php | 92 ++
.../seed-inventory.php} | 7 +-
scripts/transfer-test/seed.sh | 59 ++
scripts/transfer-test/setup.sh | 47 +
.../transfer-test/verify-extras-source.php | 28 +
.../transfer-test/verify-extras-target.php | 69 ++
tests/Feature/Api/ServerTransferApiTest.php | 325 ++++---
.../ResourceHeadingAuthorizationTest.php | 2 +-
tests/Feature/GithubRunnerFlowTest.php | 49 ++
.../Feature/Livewire/ServerTransferUiTest.php | 339 +++++--
.../ServerConnectionCheckIsolationTest.php | 55 ++
.../ServerDangerMenuVisibilityTest.php | 3 +-
.../Feature/ServerManagementOwnershipTest.php | 119 +--
tests/Feature/ServerMetadataTest.php | 29 +
.../ServerNavbarStatusVisibilityTest.php | 37 +-
tests/Feature/ServerSettingsAuditTest.php | 6 -
.../ServerTransferPreviewOwnershipTest.php | 12 +-
.../ServerTransferBundleTest.php | 55 +-
.../ServerTransferClaimerTest.php | 229 +++++
.../ServerTransferCoverageTest.php | 208 +++++
.../ServerTransfer/ServerTransferDnsTest.php | 250 ++++++
.../ServerTransferExporterImporterTest.php | 793 ++++++++++++++++-
.../ServerTransferMigratorTest.php | 534 +++++++++++-
.../ServerTransferS3DestinationsTest.php | 9 +
.../TransferFinishChecklistTest.php | 278 ++++++
.../Browser/TransferFinishChecklistTest.php | 50 ++
94 files changed, 8705 insertions(+), 1524 deletions(-)
create mode 100644 app/Events/TransferFinishChanged.php
create mode 100644 app/Jobs/CheckTransferFinishStepsJob.php
create mode 100644 app/Jobs/MigrateServerToInstanceJob.php
create mode 100644 app/Livewire/Server/TransferFinish.php
delete mode 100644 app/Livewire/Server/TransferImport.php
create mode 100644 app/Services/ServerTransfer/ServerTransferCoverage.php
create mode 100644 app/Services/ServerTransfer/TransferFinishChecklist.php
create mode 100644 docker-compose.dev-side-by-side.yml
create mode 100644 docs/server-transfer-full-coverage.md
delete mode 100644 resources/js/settings-sidebar-accordion.js
create mode 100644 resources/views/livewire/server/transfer-finish.blade.php
delete mode 100644 resources/views/livewire/server/transfer-import.blade.php
create mode 100644 scripts/transfer-test/README.md
create mode 100755 scripts/transfer-test/e2e.sh
create mode 100755 scripts/transfer-test/extras-e2e.sh
create mode 100644 scripts/transfer-test/lib.sh
create mode 100755 scripts/transfer-test/manual-setup.sh
create mode 100755 scripts/transfer-test/reset.sh
create mode 100755 scripts/transfer-test/reverse-e2e.sh
create mode 100644 scripts/transfer-test/seed-extras.php
rename scripts/{seed-transfer-demo.php => transfer-test/seed-inventory.php} (98%)
create mode 100755 scripts/transfer-test/seed.sh
create mode 100755 scripts/transfer-test/setup.sh
create mode 100644 scripts/transfer-test/verify-extras-source.php
create mode 100644 scripts/transfer-test/verify-extras-target.php
create mode 100644 tests/Unit/ServerTransfer/ServerTransferCoverageTest.php
create mode 100644 tests/Unit/ServerTransfer/ServerTransferDnsTest.php
create mode 100644 tests/Unit/ServerTransfer/TransferFinishChecklistTest.php
create mode 100644 tests/v4/Browser/TransferFinishChecklistTest.php
diff --git a/.ai/lessons.md b/.ai/lessons.md
index cd64c0a27e..ea671e124f 100644
--- a/.ai/lessons.md
+++ b/.ai/lessons.md
@@ -10,6 +10,7 @@
- The checkout is shared with other sessions. When the full suite fails, rerun each failing file alone and compare with a clean `git archive HEAD` copy before you connect a failure to your change.
- Run seeders and other artisan commands that write SSH keys in a dev container as `www-data` (`docker exec -u www-data`). Root-owned files in `storage/app/ssh` make every SSH call from the web process fail.
- `sshd` keeps the login shell of an open multiplexed SSH connection. Call `SshMultiplexingHelper::removeMuxFile()` before a live test of SSH or login-shell behavior.
+- Remote command batches run in `bash -se` (`sh -se` only when the host has no bash). A command that can fail without being an error (such as `cat` of an optional file) stops the batch unless it ends with `|| true`. `Process::fake()` cannot show this, so run such a command once in a real `bash -se` on the target host.
- SSH retries replay the full command batch. Only exit code 255 is an SSH transport failure; other exit codes come from the remote command, so do not retry them. Keep batches safe to run twice (for example `docker rm -f || true` before `docker run --name `).
- Redirect browser test output to a file (`> /tmp/x.log 2>&1`); piping it (`| tail`) hangs because the Playwright server keeps the pipe open.
- Browser `click()`/`assertSee()` wait forever when nothing matches, so a bad selector hangs the run instead of failing it. `click('Text')` can match a hidden element first (use `button:visible:has-text("Text")`); `:text-is()` on an element with child elements and `assertSee()` text with double quotes never match. Prefer plain CSS selectors.
diff --git a/AGENTS.md b/AGENTS.md
index a57f2c2c4c..d87aeea253 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -175,6 +175,22 @@ Because the "server" and the test share one PHP process, they share the phpunit
- **Project/Environment** — Organizational hierarchy: Team → Project → Environment → Resources.
- **Proxy** — Traefik reverse proxy managed per server.
+### Server transfer (keep it complete)
+
+A server transfer moves a server and every record that belongs to it to another Coolify instance: `ServerTransferExporter` writes the bundle, `ServerTransferImporter` reads it (`app/Services/ServerTransfer/`). `ServerTransferCoverage` records the transfer decision for every model and every column.
+
+- When you add a model, a column, or a relation that belongs to a server or to a resource on a server (application, preview, database, service, volume, environment variable, backup, scheduled task, tag, or setting), decide whether a transfer moves it:
+ - **Moved:** export and import it, and add a round-trip assertion to `tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php`. For a new model, also add it to `ServerTransferCoverage::TRANSFERRED`.
+ - **Not moved:** add the model to `ServerTransferCoverage::NOT_TRANSFERRED`, or the column to `NOT_EXPORTED_COLUMNS`, with the reason. If the data is lost for the user, also add an export warning.
+- `tests/Unit/ServerTransfer/ServerTransferCoverageTest.php` exports a server with one record of every transferred model. It fails when a model or a column has no decision. When you add a transferred model, add a record of it to the fixture of that test.
+- `docs/server-transfer-full-coverage.md` lists what to set up on a source server for a manual transfer test that covers every transferred model and setting. Add new transferred models and settings to it.
+- `scripts/transfer-test/` has live end-to-end transfer tests between two dev instances (see its README). Run them after a change to the transfer flow. When you add transferred data that the unit tests cannot prove (remote commands, provider calls, queue behavior), add a check to `extras-e2e.sh`.
+- Application, Service, and the standalone databases export their `$fillable` columns, plus hand-written lists for hidden columns. A column that is not fillable is lost unless you add it to those lists.
+- Do not copy a foreign key as a raw ID. IDs are different on the target. Link the record by UUID, or leave the column out with a reason.
+- Team credentials that a moved record needs (integration tokens, S3 storages, git sources, private keys, cloud provider tokens) move with it.
+- When the user must do a step after the transfer (for example, change a webhook URL at GitHub), add an item type to `TransferFinishChecklist`: the "Finish the transfer" list on the server page of the instance that manages the server now. Give it a check where Coolify can verify the step, and a fix where it can do the step. Also add it to the `post_transfer_checklist` of the bundle (`{target_url}` stands for the URL of the target), which the source shows as text.
+- On the source, a transferred server (`Server::isTransferredAway()`) is only an archive, because another instance manages the host. Do not run remote commands, cloud provider calls, or DNS provider calls for it, also not when the archive is deleted.
+
### Instance sentinels (`id = 0`)
Coolify seeds **instance-owned** rows at primary key `0`. That value is a sentinel meaning “this is the Coolify instance itself”, not a normal autoincrement id. Do not migrate, resequence, or “fix” these to a positive id.
diff --git a/app/Console/Commands/Generate/OpenApi.php b/app/Console/Commands/Generate/OpenApi.php
index 64d83e1bf9..0a91e6e9cf 100644
--- a/app/Console/Commands/Generate/OpenApi.php
+++ b/app/Console/Commands/Generate/OpenApi.php
@@ -21,9 +21,6 @@ class OpenApi extends Command
$process = Process::env(['XDEBUG_MODE' => 'off'])->run([
'./vendor/bin/openapi',
'app',
- // Server transfer endpoints are development-only (isDev()), so they stay out of the public docs.
- '--exclude',
- 'Http/Controllers/Api/ServerTransferController.php',
'-o',
'openapi.yaml',
'--version',
diff --git a/app/Events/TransferFinishChanged.php b/app/Events/TransferFinishChanged.php
new file mode 100644
index 0000000000..15fd906b90
--- /dev/null
+++ b/app/Events/TransferFinishChanged.php
@@ -0,0 +1,26 @@
+teamId = $server->team_id;
+ $this->serverUuid = $server->uuid;
+ }
+}
diff --git a/app/Http/Controllers/Api/ServerTransferController.php b/app/Http/Controllers/Api/ServerTransferController.php
index f43189abf8..ffc86b8641 100644
--- a/app/Http/Controllers/Api/ServerTransferController.php
+++ b/app/Http/Controllers/Api/ServerTransferController.php
@@ -3,10 +3,12 @@
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
+use App\Models\GithubApp;
+use App\Models\GitlabApp;
use App\Models\Server;
+use App\Rules\ValidServerIp;
use App\Services\ServerTransfer\ServerTransferBundle;
use App\Services\ServerTransfer\ServerTransferClaimer;
-use App\Services\ServerTransfer\ServerTransferExporter;
use App\Services\ServerTransfer\ServerTransferImporter;
use App\Services\ServerTransfer\ServerTransferMigrator;
use Illuminate\Http\JsonResponse;
@@ -18,17 +20,14 @@ use Throwable;
class ServerTransferController extends Controller
{
public function __construct(
- private ServerTransferExporter $exporter,
private ServerTransferImporter $importer,
private ServerTransferClaimer $claimer,
private ServerTransferMigrator $migrator,
- ) {
- abort_unless(isDev(), 404);
- }
+ ) {}
#[OA\Post(
summary: 'Migrate server to another Coolify instance',
- description: 'One-shot handoff: export this server, import+claim on the target instance (using the provided token), then disable automations here. Requires read:sensitive and write.',
+ description: 'Start a transfer in the background: export this server, import and claim it on the target instance (with the given token), then disable automations here. Follow the progress with GET /servers/{uuid}/transfer. The target URL must use https. Requires read:sensitive and write.',
path: '/servers/{uuid}/migrate',
operationId: 'migrate-server-between-instances',
security: [['bearerAuth' => []]],
@@ -43,13 +42,11 @@ class ServerTransferController extends Controller
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: '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: 202, description: 'Transfer started'),
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'),
@@ -83,10 +80,8 @@ class ServerTransferController extends Controller
$validator = customApiValidator($request->all(), [
'target_url' => 'required|string|url',
'target_token' => 'required|string',
- 'preserve_uuids' => 'boolean|nullable',
- 'adopt_mode' => 'boolean|nullable',
]);
- $allowedFields = ['target_url', 'target_token', 'preserve_uuids', 'adopt_mode'];
+ $allowedFields = ['target_url', 'target_token'];
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || $extraFields !== []) {
$errors = $validator->errors();
@@ -101,59 +96,52 @@ class ServerTransferController extends Controller
}
try {
- $result = $this->migrator->migrate(
+ $this->migrator->start(
server: $server,
targetUrl: $request->string('target_url')->toString(),
targetToken: $request->string('target_token')->toString(),
- preserveUuids: $request->boolean('preserve_uuids', true),
- adoptMode: $request->boolean('adopt_mode', true),
);
} catch (Throwable $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
+ $server->refresh();
auditLog('api.server.migrate', [
'team_id' => $teamId,
'server_uuid' => $server->uuid,
- 'export_id' => $result['export_id'],
- 'target_url' => $result['target_url'],
+ 'target_url' => data_get($server->server_metadata, 'migration.target_url'),
]);
- return response()->json($result);
+ return response()->json([
+ 'server_uuid' => $server->uuid,
+ 'migration' => data_get($server->server_metadata, 'migration'),
+ 'message' => 'Transfer started. Follow it with GET /api/v1/servers/'.$server->uuid.'/transfer.',
+ ], 202);
}
#[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',
+ summary: 'Get server transfer status',
+ description: 'Show the transfer state of a server on this instance: the import/claim state (transfer) and the state of a running or finished transfer to another instance (migration).',
+ path: '/servers/{uuid}/transfer',
+ operationId: 'get-server-transfer-status',
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: 200, description: 'Transfer status'),
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
+ public function status(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);
@@ -161,25 +149,106 @@ class ServerTransferController extends Controller
$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,
+ return response()->json([
'server_uuid' => $server->uuid,
- 'export_id' => data_get($bundle, 'export_id'),
+ 'transfer' => collect((array) data_get($server->server_metadata, 'transfer', []))
+ ->only(['status', 'export_id', 'imported_at', 'claimed_at', 'transferred_at', 'target_instance_url'])
+ ->all(),
+ 'migration' => data_get($server->server_metadata, 'migration'),
]);
+ }
- if ($request->boolean('encrypt') && $request->filled('passphrase')) {
- return response()->json(
- ServerTransferBundle::encryptWithPassphrase($bundle, $request->string('passphrase')->toString())
- );
+ #[OA\Get(
+ summary: 'Check server import',
+ description: 'Check that this instance can import a server, before the source instance starts the transfer. It checks the token, the bundle version, the server limit, servers with the same UUID or IP, and that this instance can reach the SSH port of the server.',
+ path: '/servers/import/check',
+ operationId: 'check-server-import',
+ security: [['bearerAuth' => []]],
+ tags: ['Servers'],
+ parameters: [
+ new OA\Parameter(name: 'server_uuid', in: 'query', required: true, schema: new OA\Schema(type: 'string')),
+ new OA\Parameter(name: 'ip', in: 'query', required: true, schema: new OA\Schema(type: 'string')),
+ new OA\Parameter(name: 'port', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 22)),
+ new OA\Parameter(name: 'schema_version', in: 'query', required: false, schema: new OA\Schema(type: 'integer')),
+ new OA\Parameter(name: 'system_github_apps[]', in: 'query', required: false, description: 'UUIDs of system-wide GitHub Apps that the server uses. The response lists the ones this instance does not have.', schema: new OA\Schema(type: 'array', items: new OA\Items(type: 'string'))),
+ new OA\Parameter(name: 'system_gitlab_apps[]', in: 'query', required: false, description: 'UUIDs of system-wide GitLab Apps that the server uses. The response lists the ones this instance does not have.', schema: new OA\Schema(type: 'array', items: new OA\Items(type: 'string'))),
+ ],
+ responses: [
+ new OA\Response(response: 200, description: 'The server can be imported. Lists every check with its result.'),
+ new OA\Response(response: 401, ref: '#/components/responses/401'),
+ new OA\Response(response: 422, description: 'One or more checks failed. Lists every check with its result.'),
+ ]
+ )]
+ public function check(Request $request): JsonResponse
+ {
+ $teamId = getTeamIdFromToken();
+ if (is_null($teamId)) {
+ return invalidTokenResponse();
}
- return response()->json($bundle);
+ $this->authorize('create', Server::class);
+
+ $validator = customApiValidator($request->query(), [
+ 'server_uuid' => 'required|string',
+ 'ip' => ['required', 'string', new ValidServerIp],
+ 'port' => 'nullable|integer|between:1,65535',
+ 'schema_version' => 'nullable|integer',
+ 'system_github_apps' => 'nullable|array|max:100',
+ 'system_github_apps.*' => 'string|max:64',
+ 'system_gitlab_apps' => 'nullable|array|max:100',
+ 'system_gitlab_apps.*' => 'string|max:64',
+ ]);
+ if ($validator->fails()) {
+ return response()->json([
+ 'message' => 'Validation failed.',
+ 'errors' => $validator->errors(),
+ ], 422);
+ }
+
+ // System-wide git sources are not transferred. Applications re-link only to one with the same uuid here.
+ $missingSystemSources = [
+ 'github' => array_values(array_diff((array) $request->query('system_github_apps', []), GithubApp::query()->where('is_system_wide', true)->whereIn('uuid', (array) $request->query('system_github_apps', []))->pluck('uuid')->all())),
+ 'gitlab' => array_values(array_diff((array) $request->query('system_gitlab_apps', []), GitlabApp::query()->where('is_system_wide', true)->whereIn('uuid', (array) $request->query('system_gitlab_apps', []))->pluck('uuid')->all())),
+ ];
+
+ $schemaVersion = (int) $request->query('schema_version', ServerTransferBundle::SCHEMA_VERSION);
+ $ip = trim((string) $request->query('ip'));
+ $port = (int) $request->query('port', 22);
+
+ // The target connects to the server over SSH when it takes over management, so an unreachable
+ // server would leave an imported server here that this instance cannot manage.
+ $connection = @fsockopen($ip, $port, timeout: 5);
+ if ($connection !== false) {
+ fclose($connection);
+ }
+
+ $checks = [
+ [
+ 'label' => 'the transfer bundle version is supported',
+ 'ok' => $schemaVersion === ServerTransferBundle::SCHEMA_VERSION,
+ 'message' => $schemaVersion === ServerTransferBundle::SCHEMA_VERSION ? null
+ : 'This instance reads transfer bundle version '.ServerTransferBundle::SCHEMA_VERSION.", but the source sends version {$schemaVersion}. Upgrade both instances to the same Coolify version.",
+ ],
+ ...collect($this->importer->importChecks((string) $request->query('server_uuid'), $ip, $teamId))
+ ->map(fn (array $check) => collect($check)->only(['label', 'ok', 'message'])->all())
+ ->all(),
+ [
+ 'label' => "the target can reach the SSH port of the server ({$ip}:{$port})",
+ 'ok' => $connection !== false,
+ 'message' => $connection !== false ? null
+ : "This instance cannot reach the server at {$ip}:{$port}. Allow SSH from this instance to the server, then transfer again.",
+ ],
+ ];
+
+ $failed = collect($checks)->where('ok', false);
+
+ return response()->json([
+ 'message' => $failed->isEmpty()
+ ? 'This instance can import the server.'
+ : $failed->pluck('message')->implode(' '),
+ 'checks' => $checks,
+ 'missing_system_sources' => $missingSystemSources,
+ ], $failed->isEmpty() ? 200 : 422);
}
#[OA\Post(
@@ -193,8 +262,7 @@ class ServerTransferController extends Controller
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: 'bundle', type: 'object', description: 'Transfer bundle'),
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'),
@@ -224,12 +292,11 @@ class ServerTransferController extends Controller
$validator = customApiValidator($request->all(), [
'bundle' => 'required|array',
- 'passphrase' => 'string|nullable',
'dry_run' => 'boolean|nullable',
'preserve_uuids' => 'boolean|nullable',
'adopt_mode' => 'boolean|nullable',
]);
- $allowedFields = ['bundle', 'passphrase', 'dry_run', 'preserve_uuids', 'adopt_mode'];
+ $allowedFields = ['bundle', 'dry_run', 'preserve_uuids', 'adopt_mode'];
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || $extraFields !== []) {
$errors = $validator->errors();
@@ -243,34 +310,21 @@ class ServerTransferController extends Controller
], 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,
+ bundle: $request->input('bundle', []),
teamId: $teamId,
dryRun: $request->boolean('dry_run', false),
preserveUuids: $request->boolean('preserve_uuids', true),
adoptMode: $request->boolean('adopt_mode', 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);
+ return response()->json($payload, 422);
}
auditLog('api.server.import', [
@@ -285,7 +339,7 @@ class ServerTransferController extends Controller
#[OA\Post(
summary: 'Mark server transferred',
- description: 'Source-instance step: disable automations after a successful export/import handoff.',
+ description: 'Source-instance recovery step: disable automations here when the target already manages the server but the transfer could not finish on this instance.',
path: '/servers/{uuid}/transfer/complete',
operationId: 'complete-server-transfer',
security: [['bearerAuth' => []]],
@@ -350,77 +404,6 @@ class ServerTransferController extends Controller
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/Webhook/Bitbucket.php b/app/Http/Controllers/Webhook/Bitbucket.php
index e06a14e0bf..46e4b50362 100644
--- a/app/Http/Controllers/Webhook/Bitbucket.php
+++ b/app/Http/Controllers/Webhook/Bitbucket.php
@@ -11,6 +11,7 @@ use App\Http\Controllers\Webhook\Concerns\ReadsWebhookPushPayload;
use App\Http\Controllers\Webhook\Concerns\ValidatesPreviewDeploymentRepository;
use App\Models\Application;
use App\Models\ApplicationPreview;
+use App\Services\ServerTransfer\TransferFinishChecklist;
use Exception;
use Illuminate\Http\Request;
@@ -128,6 +129,7 @@ class Bitbucket extends Controller
continue;
}
+ TransferFinishChecklist::recordWebhook('application', $application->uuid);
$isFunctional = $application->destination->server->isFunctional();
if (! $isFunctional) {
$return_payloads->push([
diff --git a/app/Http/Controllers/Webhook/Gitea.php b/app/Http/Controllers/Webhook/Gitea.php
index b3c0b22a48..6a9c8dd624 100644
--- a/app/Http/Controllers/Webhook/Gitea.php
+++ b/app/Http/Controllers/Webhook/Gitea.php
@@ -11,6 +11,7 @@ use App\Http\Controllers\Webhook\Concerns\ReadsWebhookPushPayload;
use App\Http\Controllers\Webhook\Concerns\ValidatesPreviewDeploymentRepository;
use App\Models\Application;
use App\Models\ApplicationPreview;
+use App\Services\ServerTransfer\TransferFinishChecklist;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
@@ -114,6 +115,7 @@ class Gitea extends Controller
continue;
}
+ TransferFinishChecklist::recordWebhook('application', $application->uuid);
$isFunctional = $application->destination->server->isFunctional();
if (! $isFunctional) {
$return_payloads->push([
diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php
index ba270cf688..64a357ea49 100644
--- a/app/Http/Controllers/Webhook/Github.php
+++ b/app/Http/Controllers/Webhook/Github.php
@@ -19,6 +19,7 @@ use App\Models\GithubRunnerExecution;
use App\Models\PrivateKey;
use App\Services\GithubRunner\GithubRunnerApi;
use App\Services\GithubRunner\GithubRunnerContainer;
+use App\Services\ServerTransfer\TransferFinishChecklist;
use Exception;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Http\RedirectResponse;
@@ -136,6 +137,7 @@ class Github extends Controller
continue;
}
+ TransferFinishChecklist::recordWebhook('application', $application->uuid);
$isFunctional = $application->destination->server->isFunctional();
if (! $isFunctional) {
$return_payloads->push([
@@ -428,6 +430,7 @@ class Github extends Controller
return response('Invalid signature.');
}
}
+ TransferFinishChecklist::recordWebhook('github_app', $github_app->uuid);
if ($x_github_event === 'installation' || $x_github_event === 'installation_repositories') {
// Installation handled by setup redirect url. Repositories queried on-demand.
$action = data_get($payload, 'action');
diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php
index 577ea820c6..893e8a7bb3 100644
--- a/app/Http/Controllers/Webhook/Gitlab.php
+++ b/app/Http/Controllers/Webhook/Gitlab.php
@@ -13,6 +13,7 @@ use App\Livewire\Source\Gitlab\Change as GitlabSource;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\GitlabApp;
+use App\Services\ServerTransfer\TransferFinishChecklist;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
@@ -122,6 +123,7 @@ class Gitlab extends Controller
], 401);
}
+ TransferFinishChecklist::recordWebhook('gitlab_app', $gitlab_app->uuid);
$project_id = $this->webhookPayloadDatabaseId($payload, 'project.id', required: true);
$applications = Application::where('source_id', $gitlab_app->id)
->where('source_type', GitlabApp::class)
@@ -473,6 +475,7 @@ class Gitlab extends Controller
continue;
}
+ TransferFinishChecklist::recordWebhook('application', $application->uuid);
$isFunctional = $application->destination->server->isFunctional();
if (! $isFunctional) {
$return_payloads->push([
diff --git a/app/Jobs/CheckTransferFinishStepsJob.php b/app/Jobs/CheckTransferFinishStepsJob.php
new file mode 100644
index 0000000000..ebd47f5871
--- /dev/null
+++ b/app/Jobs/CheckTransferFinishStepsJob.php
@@ -0,0 +1,39 @@
+server->id;
+ }
+
+ public function handle(TransferFinishChecklist $checklist): void
+ {
+ $checklist->checkAll($this->server);
+ }
+}
diff --git a/app/Jobs/CleanupGithubRunnerJob.php b/app/Jobs/CleanupGithubRunnerJob.php
index baca8504f0..2caf75f505 100644
--- a/app/Jobs/CleanupGithubRunnerJob.php
+++ b/app/Jobs/CleanupGithubRunnerJob.php
@@ -36,6 +36,14 @@ class CleanupGithubRunnerJob implements ShouldQueue
return;
}
+ // Another Coolify instance manages a transferred server and its runners: never touch them from here.
+ if ($execution->server?->isTransferredAway()) {
+ Log::info('Skipped GitHub runner cleanup: the server was transferred to another instance', ['execution' => $execution->uuid]);
+ ProvisionGithubRunnerJob::dispatchQueued($execution->github_app_id);
+
+ return;
+ }
+
if ($execution->server) {
instant_remote_process(GithubRunnerContainer::cleanupCommands($execution->uuid), $execution->server, false);
}
diff --git a/app/Jobs/DeleteResourceJob.php b/app/Jobs/DeleteResourceJob.php
index d5e8aaf4ff..4749891550 100644
--- a/app/Jobs/DeleteResourceJob.php
+++ b/app/Jobs/DeleteResourceJob.php
@@ -56,7 +56,7 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
return;
}
- if ($this->deleteFromCoolifyOnly && $this->resource instanceof Service) {
+ if ($this->deleteFromCoolifyOnly) {
// No remote calls on this path: remove only the schedule records, so the scheduler does
// not keep running backups of volumes that no longer exist in Coolify.
$this->volumeBackupSchedules()->each->delete();
diff --git a/app/Jobs/MigrateServerToInstanceJob.php b/app/Jobs/MigrateServerToInstanceJob.php
new file mode 100644
index 0000000000..cba109fb04
--- /dev/null
+++ b/app/Jobs/MigrateServerToInstanceJob.php
@@ -0,0 +1,69 @@
+onQueue('high');
+ }
+
+ public function uniqueId(): string
+ {
+ return (string) $this->server->id;
+ }
+
+ public function handle(ServerTransferMigrator $migrator): void
+ {
+ try {
+ $result = $migrator->migrate($this->server, $this->targetUrl, $this->targetToken);
+ $migrator->recordMigration($this->server, 'succeeded', [
+ 'export_id' => $result['export_id'],
+ 'warnings' => $result['warnings'],
+ 'checklist' => $result['checklist'],
+ 'target_server_url' => $result['target_server_url'] ?? null,
+ ]);
+ } catch (Throwable $e) {
+ $migrator->recordMigration($this->server, 'failed', ['error' => $e->getMessage()]);
+ }
+ }
+
+ /**
+ * The worker stopped the job, for example at its timeout. The target can manage the server already,
+ * so the migrator asks the target before it records the result.
+ */
+ public function failed(?Throwable $exception): void
+ {
+ app(ServerTransferMigrator::class)->recordStoppedMigration(
+ $this->server,
+ $this->targetUrl,
+ $this->targetToken,
+ $exception?->getMessage() ?: 'The transfer job stopped before it finished.',
+ );
+ }
+}
diff --git a/app/Jobs/ProvisionGithubRunnerJob.php b/app/Jobs/ProvisionGithubRunnerJob.php
index e4415a81c1..ae9e5e15e7 100644
--- a/app/Jobs/ProvisionGithubRunnerJob.php
+++ b/app/Jobs/ProvisionGithubRunnerJob.php
@@ -85,7 +85,8 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue
if ($this->stopIfFinished($execution)) {
return;
}
- instant_remote_process($container->startCommands($runner['encoded_jit_config']), $server);
+ // stopIfFinished() reloaded the server, so a transfer that just finished stops the start command.
+ instant_remote_process($container->startCommands($runner['encoded_jit_config']), $execution->server);
if ($this->stopIfFinished($execution)) {
return;
@@ -159,11 +160,15 @@ class ProvisionGithubRunnerJob implements ShouldBeEncrypted, ShouldQueue
return true;
}
+ // Reloads the server too: a transfer to another instance can finish while this job runs.
$execution->refresh();
- if ($execution->isActive()) {
+ if ($execution->isActive() && ! $execution->server?->isTransferredAway()) {
return false;
}
+ if ($execution->isActive()) {
+ $execution->finish(GithubRunnerStatus::Cancelled, 'The server was transferred to another Coolify instance.');
+ }
CleanupGithubRunnerJob::dispatch($execution->id);
return true;
diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php
index ab8d56a98a..841e915b78 100644
--- a/app/Jobs/ServerConnectionCheckJob.php
+++ b/app/Jobs/ServerConnectionCheckJob.php
@@ -5,6 +5,7 @@ namespace App\Jobs;
use App\Events\ServerReachabilityChanged;
use App\Helpers\SshMultiplexingHelper;
use App\Models\Server;
+use App\Services\ServerTransfer\ServerTransferClaimer;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -44,6 +45,9 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
private ?string $connectionError = null;
+ /** Owner marker that the SSH probe read from the host; empty when the host has none. */
+ private ?string $ownerMarker = null;
+
public function __construct(
public Server $server,
public bool $disableMux = true
@@ -108,6 +112,10 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
return;
}
+ if ($this->releasedToAnotherInstance()) {
+ return;
+ }
+
// Server is reachable, check if Docker is available
$isUsable = $this->checkDockerAvailability();
@@ -218,9 +226,11 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
{
try {
$process = Process::timeout(self::SSH_TIMEOUT_SECONDS)
- ->run($this->sshCommand('ls -la /', disableMultiplexing: true));
+ ->run($this->sshCommand(self::probeCommand(), disableMultiplexing: true));
if ($process->exitCode() !== 0) {
$this->connectionError = 'ssh exit '.$process->exitCode().': '.Str::limit(trim($process->errorOutput()), 300);
+ } else {
+ $this->ownerMarker = trim($process->output());
}
return $process->exitCode() === 0;
@@ -235,6 +245,25 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
}
}
+ /**
+ * The SSH probe also reads the owner marker, so the marker check needs no extra SSH connection.
+ * The remote shell runs with `set -e` and most servers have no marker, so a missing file must not fail the probe.
+ */
+ public static function probeCommand(): string
+ {
+ return 'cat '.ServerTransferClaimer::OWNER_MARKER_PATH.' 2>/dev/null || true; ls -la / > /dev/null';
+ }
+
+ /**
+ * After a transfer, another instance can claim this server. Its owner marker on the host is the only
+ * record that both instances share, so this instance stops managing the server when the marker names
+ * another claim.
+ */
+ private function releasedToAnotherInstance(): bool
+ {
+ return app(ServerTransferClaimer::class)->releaseIfClaimedElsewhere($this->server, $this->ownerMarker);
+ }
+
private function checkDockerAvailability(): bool
{
try {
diff --git a/app/Livewire/Server/Delete.php b/app/Livewire/Server/Delete.php
index c3539aa282..2b5051c65e 100644
--- a/app/Livewire/Server/Delete.php
+++ b/app/Livewire/Server/Delete.php
@@ -5,6 +5,7 @@ namespace App\Livewire\Server;
use App\Actions\Server\DeleteServer;
use App\Jobs\DeleteResourceJob;
use App\Models\Server;
+use App\Services\ServerTransfer\ServerTransferClaimer;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@@ -46,6 +47,16 @@ class Delete extends Component
$this->delete_from_hostinger = in_array('delete_from_hostinger', $selectedActions);
$this->force_delete_resources = in_array('force_delete_resources', $selectedActions);
}
+ // Another Coolify instance manages a transferred server. This instance deletes only its own records:
+ // never the VM, and never the containers that the other instance runs.
+ $isArchive = $this->server->isTransferredAway();
+ if ($isArchive) {
+ $this->delete_from_hetzner = false;
+ $this->delete_from_vultr = false;
+ $this->delete_from_digitalocean = false;
+ $this->delete_from_hostinger = false;
+ $this->force_delete_resources = true;
+ }
try {
$this->authorize('delete', $this->server);
if ($this->server->is_coolify_host) {
@@ -59,9 +70,12 @@ class Delete extends Component
return;
}
+ if ($isArchive) {
+ app(ServerTransferClaimer::class)->forgetDnsRecords($this->server);
+ }
if ($this->force_delete_resources) {
foreach ($this->server->definedResources() as $resource) {
- DeleteResourceJob::dispatch($resource);
+ DeleteResourceJob::dispatch($resource, deleteFromCoolifyOnly: $isArchive);
}
}
@@ -90,7 +104,7 @@ class Delete extends Component
{
$checkboxes = [];
- if ($this->server->hasDefinedResources()) {
+ if ($this->server->hasDefinedResources() && ! $this->server->isTransferredAway()) {
$resourceCount = $this->server->definedResources()->count();
$checkboxes[] = [
'id' => 'force_delete_resources',
@@ -99,7 +113,9 @@ class Delete extends Component
];
}
- if ($this->server->hetzner_server_id) {
+ $canDeleteFromProvider = ! $this->server->isTransferredAway();
+
+ if ($canDeleteFromProvider && $this->server->hetzner_server_id) {
$checkboxes[] = [
'id' => 'delete_from_hetzner',
'label' => 'Also delete server from Hetzner Cloud',
@@ -107,7 +123,7 @@ class Delete extends Component
];
}
- if ($this->server->vultr_instance_id) {
+ if ($canDeleteFromProvider && $this->server->vultr_instance_id) {
$checkboxes[] = [
'id' => 'delete_from_vultr',
'label' => 'Also delete server from Vultr',
@@ -115,7 +131,7 @@ class Delete extends Component
];
}
- if ($this->server->digitalocean_droplet_id) {
+ if ($canDeleteFromProvider && $this->server->digitalocean_droplet_id) {
$checkboxes[] = [
'id' => 'delete_from_digitalocean',
'label' => 'Also delete droplet from DigitalOcean',
@@ -123,7 +139,7 @@ class Delete extends Component
];
}
- if ($this->server->hostinger_virtual_machine_id) {
+ if ($canDeleteFromProvider && $this->server->hostinger_virtual_machine_id) {
$checkboxes[] = [
'id' => 'delete_from_hostinger',
'label' => 'Also delete VPS from Hostinger (at the end of the paid period)',
diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php
index 4cb59e1996..ff5751ed2a 100644
--- a/app/Livewire/Server/Show.php
+++ b/app/Livewire/Server/Show.php
@@ -395,43 +395,46 @@ class Show extends Component
}
}
- public function toggleManagement(ServerTransferClaimer $claimer): void
+ /**
+ * Take back a server that was transferred to another instance.
+ */
+ public function enableManagement(ServerTransferClaimer $claimer): void
{
- abort_unless(isDev(), 404);
$this->authorize('update', $this->server);
- if ($this->server->isLocalhost()) {
- $this->dispatch('error', 'The Coolify host cannot be transferred.');
-
+ if ($this->server->isLocalhost() || ! $this->server->isTransferredAway()) {
return;
}
- if ($this->server->isTransferredAway() && $this->server->team->serverOverflow()) {
+ if ($this->server->team->serverOverflow()) {
$this->dispatch('error', 'Your team is over its server limit. Upgrade your subscription or remove a server first.');
return;
}
- if ($this->server->isTransferredAway()) {
+ try {
$claimer->claim($this->server);
- $event = 'ui.server.management_enabled';
- $message = 'This Coolify instance now manages the server.';
- } else {
- $claimer->markTransferred($this->server, managementDisabled: true);
- $event = 'ui.server.management_disabled';
- $message = 'Server automations are disabled on this Coolify instance.';
+ } catch (\Throwable $e) {
+ handleError($e, $this);
+
+ return;
}
+ $claimer->restoreAfterTakeBack($this->server->refresh());
$this->server->refresh();
+ // Sentinel must report here. While it reports to the other instance, that instance does not run
+ // the connection check that reads the owner marker and makes it stop managing the server.
+ $this->server->restartSentinel();
$this->syncData();
+ $this->dispatch('transferFinishStarted')->to(TransferFinish::class);
- auditLog($event, [
+ auditLog('ui.server.management_enabled', [
'team_id' => $this->server->team_id,
'server_uuid' => $this->server->uuid,
'server_name' => $this->server->name,
]);
- $this->dispatch('success', $message);
+ $this->dispatch('success', 'This Coolify instance now manages the server.');
}
public function checkLocalhostConnection()
diff --git a/app/Livewire/Server/Transfer.php b/app/Livewire/Server/Transfer.php
index 8ae349ec3f..22bb6d0685 100644
--- a/app/Livewire/Server/Transfer.php
+++ b/app/Livewire/Server/Transfer.php
@@ -3,8 +3,6 @@
namespace App\Livewire\Server;
use App\Models\Server;
-use App\Services\ServerTransfer\ServerTransferBundle;
-use App\Services\ServerTransfer\ServerTransferExporter;
use App\Services\ServerTransfer\ServerTransferMigrator;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
@@ -21,25 +19,27 @@ class Transfer extends Component
public string $targetToken = '';
- /** Manual transfer */
- /** Encrypts the downloaded file when filled; empty or whitespace means no encryption. */
- public ?string $passphrase = null;
-
public ?string $exportId = null;
+ /** Transfer back fields, shown for an archive */
+ public string $managerUrl = '';
+
+ public string $managerToken = '';
+
+ public string $thisInstanceUrl = '';
+
/** @var list */
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);
+ // The transfer exports every credential of the server, so only admins and owners may open it.
+ $this->authorize('update', $this->server);
$this->exportId = data_get($this->server->server_metadata, 'transfer.export_id');
+ $this->managerUrl = (string) data_get($this->server->server_metadata, 'transfer.target_instance_url', '');
+ $this->thisInstanceUrl = rtrim((string) (instanceSettings()->fqdn ?: config('app.url')), '/');
} catch (Throwable $e) {
handleError($e, $this);
$this->redirect(route('server.index'), navigate: true);
@@ -51,72 +51,86 @@ class Transfer extends Component
return (int) $this->server->id === 0;
}
+ /**
+ * State of the queued transfer to another instance, read by the page poll.
+ *
+ * @return array|null
+ */
+ public function getMigrationProperty(): ?array
+ {
+ $migration = data_get($this->server->server_metadata, 'migration');
+
+ return is_array($migration) ? $migration : null;
+ }
+
+ /**
+ * Manual steps of a finished transfer that the target instance cannot do.
+ *
+ * @return list
+ */
+ public function getPostTransferChecklistProperty(): array
+ {
+ if (data_get($this->migration, 'status') !== 'succeeded') {
+ return [];
+ }
+
+ return array_values(array_filter((array) data_get($this->migration, 'checklist', []), 'is_string'));
+ }
+
+ public function refreshMigration(): void
+ {
+ $this->server->refresh();
+ $this->lastWarnings = array_values((array) data_get($this->migration, 'warnings', $this->lastWarnings));
+ }
+
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(
+ $migrator->start(
server: $this->server,
targetUrl: $this->targetUrl,
targetToken: $this->targetToken,
);
+ auditLog('ui.server.migrate', [
+ 'team_id' => $this->server->team_id,
+ 'server_uuid' => $this->server->uuid,
+ 'target_url' => $this->targetUrl,
+ ]);
+
$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->lastWarnings = [];
$this->targetToken = '';
- $this->dispatch('success', $result['message'] ?? 'Server transferred.');
+ $this->dispatch('success', 'All checks passed. The transfer started.');
} catch (Throwable $e) {
+ // A failed check is saved in the transfer log, so show it.
+ $this->server->refresh();
handleError($e, $this);
}
}
- public function exportBundle(ServerTransferExporter $exporter)
+ public function transferBack(ServerTransferMigrator $migrator): void
{
- $this->ensureDevelopmentAvailability();
-
try {
- $this->authorize('view', $this->server);
- if ($this->isLocalhost) {
- throw new \RuntimeException('The Coolify host (localhost) cannot be transferred.');
- }
+ $this->authorize('update', $this->server);
+ $migrator->requestTransferBack($this->server, auth()->user(), $this->managerUrl, $this->managerToken, $this->thisInstanceUrl);
- $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 (filled($this->passphrase)) {
- $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',
+ auditLog('ui.server.transfer_back', [
+ 'team_id' => $this->server->team_id,
+ 'server_uuid' => $this->server->uuid,
+ 'from' => $this->managerUrl,
]);
+
+ $this->server->refresh();
+ $this->managerToken = '';
+ $this->dispatch('success', 'The other instance started the transfer back.');
} catch (Throwable $e) {
- return handleError($e, $this);
+ handleError($e, $this);
}
}
@@ -124,9 +138,4 @@ class Transfer extends Component
{
return view('livewire.server.transfer');
}
-
- private function ensureDevelopmentAvailability(): void
- {
- abort_unless(isDev(), 404);
- }
}
diff --git a/app/Livewire/Server/TransferFinish.php b/app/Livewire/Server/TransferFinish.php
new file mode 100644
index 0000000000..e60664ed0c
--- /dev/null
+++ b/app/Livewire/Server/TransferFinish.php
@@ -0,0 +1,122 @@
+server->team_id},TransferFinishChanged" => 'handleTransferFinishChanged',
+ ];
+ }
+
+ /**
+ * The server page sends this after a take back, which starts a new list.
+ */
+ #[On('transferFinishStarted')]
+ public function reloadServer(): void
+ {
+ $this->server->refresh();
+ }
+
+ /**
+ * A background check, or a user in another tab, changed a step of this list.
+ */
+ public function handleTransferFinishChanged(array $event): void
+ {
+ if (($event['serverUuid'] ?? null) === $this->server->uuid) {
+ $this->server->refresh();
+ }
+ }
+
+ /**
+ * Start the background checks when they are stale. Called when the panel loads and by its poll.
+ */
+ public function refreshSteps(TransferFinishChecklist $checklist): void
+ {
+ $this->server->refresh();
+ if (auth()->user()?->can('update', $this->server)) {
+ $checklist->checkWhenStale($this->server);
+ }
+ }
+
+ public function checkAllNow(): void
+ {
+ $this->authorize('update', $this->server);
+ CheckTransferFinishStepsJob::dispatch($this->server);
+ }
+
+ public function checkStep(TransferFinishChecklist $checklist, string $key): void
+ {
+ $this->authorize('update', $this->server);
+ $checklist->check($this->server, $key);
+ }
+
+ public function fixStep(TransferFinishChecklist $checklist, string $key): void
+ {
+ $this->authorize('update', $this->server);
+ $item = $checklist->fix($this->server, $key);
+ $toast = match ($item['status']) {
+ 'ok' => 'success',
+ 'pending' => 'info',
+ default => 'error',
+ };
+ $this->dispatch($toast, $item['message'] ?? 'Done.');
+ }
+
+ public function markStepDone(TransferFinishChecklist $checklist, string $key): void
+ {
+ $this->authorize('update', $this->server);
+ $checklist->markDone($this->server, $key);
+ }
+
+ public function finishTransfer(TransferFinishChecklist $checklist): void
+ {
+ $this->authorize('update', $this->server);
+ $skipped = count($checklist->openItems($this->server));
+ $checklist->finish($this->server, auth()->user());
+
+ auditLog('ui.server.transfer_finished', [
+ 'team_id' => $this->server->team_id,
+ 'server_uuid' => $this->server->uuid,
+ 'skipped_steps' => $skipped,
+ ]);
+
+ $this->dispatch('success', 'The transfer is finished.');
+ }
+
+ public function render()
+ {
+ $checklist = app(TransferFinishChecklist::class);
+ $finish = $checklist->isOpen($this->server) ? $checklist->of($this->server) : null;
+ $items = collect($finish['items'] ?? []);
+ $openItems = $items->filter(fn (array $item) => in_array($item['status'], TransferFinishChecklist::OPEN_STATUSES, true));
+
+ return view('livewire.server.transfer-finish', [
+ 'checklist' => $checklist,
+ 'finish' => $finish,
+ 'openGroups' => collect(TransferFinishChecklist::GROUPS)
+ ->map(fn (string $label, string $group) => ['label' => $label, 'items' => $openItems->filter(fn (array $item) => $checklist->groupOf($item) === $group)->values()])
+ ->filter(fn (array $group) => $group['items']->isNotEmpty()),
+ 'doneItems' => $items->reject(fn (array $item) => in_array($item['status'], TransferFinishChecklist::OPEN_STATUSES, true))->values(),
+ 'openCount' => $openItems->count(),
+ 'totalCount' => $items->count(),
+ ]);
+ }
+}
diff --git a/app/Livewire/Server/TransferImport.php b/app/Livewire/Server/TransferImport.php
deleted file mode 100644
index 97bb6c5c7d..0000000000
--- a/app/Livewire/Server/TransferImport.php
+++ /dev/null
@@ -1,150 +0,0 @@
-|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,
- );
-
- $this->lastResult = $result;
- $this->lastWarnings = array_values((array) data_get($result, 'warnings', []));
- $this->importedServerUuid = $dryRun ? null : data_get($result, 'server_uuid');
-
- if (! $dryRun) {
- auditLog('ui.server.imported', [
- 'team_id' => $teamId,
- 'server_uuid' => $this->importedServerUuid,
- 'claimed' => (bool) data_get($result, 'claimed'),
- 'adopt_mode' => $this->adoptMode,
- ]);
- }
-
- if ($dryRun) {
- $this->dispatch('success', 'Dry run completed — nothing was written.');
- } elseif (data_get($result, 'claimed')) {
- $this->dispatch('success', 'Server imported. This instance now manages it.');
- } else {
- $this->dispatch('success', 'Server imported, but management could not be enabled. Check the warnings, then click Enable management on the server 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/Source/Github/Change.php b/app/Livewire/Source/Github/Change.php
index 7e0753ad69..ca59577ccb 100644
--- a/app/Livewire/Source/Github/Change.php
+++ b/app/Livewire/Source/Github/Change.php
@@ -372,14 +372,7 @@ class Change extends Component
public function getGithubAppNameUpdatePath()
{
- $name = encodeGithubPathSegment($this->github_app->name);
- $organization = normalizeGithubOrganization($this->github_app->organization);
-
- if (filled($organization)) {
- return rtrim($this->github_app->html_url, '/').'/organizations/'.encodeGithubPathSegment($organization)."/settings/apps/{$name}";
- }
-
- return rtrim($this->github_app->html_url, '/')."/settings/apps/{$name}";
+ return $this->github_app->settingsUrl();
}
public function updateGithubAppName()
diff --git a/app/Models/GithubApp.php b/app/Models/GithubApp.php
index 0052232fb1..d0964f53a8 100644
--- a/app/Models/GithubApp.php
+++ b/app/Models/GithubApp.php
@@ -165,4 +165,19 @@ class GithubApp extends BaseModel
return filled($this->app_id) && filled($this->installation_id);
}
+
+ /**
+ * The settings page of this App at GitHub.
+ */
+ public function settingsUrl(): string
+ {
+ $name = encodeGithubPathSegment($this->name);
+ $organization = normalizeGithubOrganization($this->organization);
+
+ if (filled($organization)) {
+ return rtrim($this->html_url, '/').'/organizations/'.encodeGithubPathSegment($organization)."/settings/apps/{$name}";
+ }
+
+ return rtrim($this->html_url, '/')."/settings/apps/{$name}";
+ }
}
diff --git a/app/Models/Server.php b/app/Models/Server.php
index e6b85e522f..7fa3c12f0a 100644
--- a/app/Models/Server.php
+++ b/app/Models/Server.php
@@ -252,7 +252,13 @@ class Server extends BaseModel
$destination->delete();
});
// Leftover active runner rows would block deleting the GitHub App.
- GithubRunnerExecution::deleteAndDeregister(GithubRunnerExecution::query()->where('server_id', $server->id));
+ // The runners of a transferred server now belong to the other instance, so keep them registered at GitHub.
+ $executions = GithubRunnerExecution::query()->where('server_id', $server->id);
+ if ($server->isTransferredAway()) {
+ $executions->delete();
+ } else {
+ GithubRunnerExecution::deleteAndDeregister($executions);
+ }
$server->githubRunnerConfig()->delete();
$server->settings()->delete();
$server->sslCertificates()->delete();
@@ -1376,15 +1382,6 @@ $siteAddress {
return data_get($this->server_metadata, 'transfer.status') === 'transferred';
}
- /**
- * Management was disabled manually on this instance; the server is ready to be transferred.
- */
- public function isManagementDisabled(): bool
- {
- return $this->isTransferredAway()
- && (bool) data_get($this->server_metadata, 'transfer.management_disabled', false);
- }
-
/**
* Whether this server may be validated / installed against from this instance.
*/
@@ -2120,7 +2117,13 @@ $siteAddress {
'collected_at' => now()->toIso8601String(),
];
- $this->update(['server_metadata' => $metadata]);
+ // Merge into the saved row: server_metadata also holds the transfer and migration state.
+ DB::transaction(function () use ($metadata) {
+ $locked = self::query()->lockForUpdate()->findOrFail($this->id);
+ $locked->update(['server_metadata' => array_merge($locked->server_metadata ?? [], $metadata)]);
+ $this->server_metadata = $locked->server_metadata;
+ $this->syncOriginalAttribute('server_metadata');
+ });
try {
$detectedDockerVersion = parseDockerEngineVersion($sections['DOCKER'] ?? null);
diff --git a/app/Models/User.php b/app/Models/User.php
index 743871fcfc..1257f3c857 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -263,7 +263,7 @@ class User extends Authenticatable implements PasskeyUser, SendsEmail
return $new_team;
}
- public function createToken(string $name, array $abilities = ['read'], ?DateTimeInterface $expiresAt = null)
+ public function createToken(string $name, array $abilities = ['read'], ?DateTimeInterface $expiresAt = null, ?int $teamId = null)
{
$plainTextToken = sprintf(
'%s%s%s',
@@ -277,7 +277,7 @@ class User extends Authenticatable implements PasskeyUser, SendsEmail
'token' => hash('sha256', $plainTextToken),
'abilities' => $abilities,
'expires_at' => $expiresAt,
- 'team_id' => session('currentTeam')->id,
+ 'team_id' => $teamId ?? session('currentTeam')->id,
]);
return new NewAccessToken($token, $token->getKey().'|'.$plainTextToken);
diff --git a/app/Services/Dns/CloudflareDnsProvider.php b/app/Services/Dns/CloudflareDnsProvider.php
index d3c57b78b5..9cd21a4fbf 100644
--- a/app/Services/Dns/CloudflareDnsProvider.php
+++ b/app/Services/Dns/CloudflareDnsProvider.php
@@ -330,6 +330,85 @@ class CloudflareDnsProvider
return $this->forgetDeletedRecord($record, ManagedDnsDeletionResult::Deleted, $resource);
}
+ /**
+ * Takes ownership of a record that another Coolify instance created, after a server transfer. The provider
+ * comment changes from $previousComment to the ownership comment of this instance only when the record still
+ * has the expected type, name, value and comment. Never throws; returns false when the record was not adopted.
+ */
+ public function adoptRecord(ManagedDnsRecord $record, string $previousComment): bool
+ {
+ try {
+ $record->loadMissing(['zone', 'integrationToken']);
+ if ($record->zone === null || $record->integrationToken?->provider !== 'cloudflare') {
+ return false;
+ }
+
+ return $this->hostnameLock->run(
+ (int) $record->integrationToken->team_id,
+ $record->name,
+ fn (): bool => $this->adoptRecordLocked($record, $previousComment),
+ );
+ } catch (Throwable) {
+ return false;
+ }
+ }
+
+ /**
+ * Takes a record back after a take back, when another Coolify instance may have adopted it meanwhile. The
+ * provider comment must be an ownership comment for this record uuid, from this or any other instance.
+ * Never throws; returns false when the record was not taken back.
+ */
+ public function reclaimRecord(ManagedDnsRecord $record): bool
+ {
+ try {
+ $record->loadMissing(['zone', 'integrationToken']);
+ if ($record->zone === null || $record->integrationToken?->provider !== 'cloudflare') {
+ return false;
+ }
+
+ return $this->hostnameLock->run(
+ (int) $record->integrationToken->team_id,
+ $record->name,
+ function () use ($record): bool {
+ $url = "https://api.cloudflare.com/client/v4/zones/{$record->zone->provider_zone_id}/dns_records/{$record->provider_record_id}";
+ $comment = $this->client($record->integrationToken)->get($url)->json('result.comment');
+ $pattern = '/^'.preg_quote(ManagedDnsRecord::OWNERSHIP_COMMENT_PREFIX, '/').' \\S+\\/'.preg_quote($record->uuid, '/').'$/';
+ if (! is_string($comment) || preg_match($pattern, $comment) !== 1) {
+ return false;
+ }
+
+ return $comment === $record->ownershipComment() || $this->adoptRecordLocked($record, $comment);
+ },
+ );
+ } catch (Throwable) {
+ return false;
+ }
+ }
+
+ private function adoptRecordLocked(ManagedDnsRecord $record, string $previousComment): bool
+ {
+ $url = "https://api.cloudflare.com/client/v4/zones/{$record->zone->provider_zone_id}/dns_records/{$record->provider_record_id}";
+ $response = $this->client($record->integrationToken)->get($url);
+ $remote = $response->json('result');
+ if (! $response->successful() || ! is_array($remote)) {
+ return false;
+ }
+ if (($remote['type'] ?? null) !== $record->type
+ || strtolower((string) ($remote['name'] ?? '')) !== $record->name
+ || ! DnsRecordHints::sameAddress(is_string($remote['content'] ?? null) ? $remote['content'] : null, $record->content)
+ || ($remote['comment'] ?? null) !== $previousComment) {
+ return false;
+ }
+
+ $patchResponse = $this->client($record->integrationToken)->patch($url, ['comment' => $record->ownershipComment()]);
+ if (! $patchResponse->successful()) {
+ return false;
+ }
+ $this->auditDnsRecord('ownership_adopted', $record->zone, $record->name, null);
+
+ return true;
+ }
+
private function forgetDeletedRecord(ManagedDnsRecord $record, ManagedDnsDeletionResult $result, ?Model $resource): ManagedDnsDeletionResult
{
$record->delete();
diff --git a/app/Services/Dns/ManagedDnsRecordCleanup.php b/app/Services/Dns/ManagedDnsRecordCleanup.php
index d8d02dfd73..59b75a55a6 100644
--- a/app/Services/Dns/ManagedDnsRecordCleanup.php
+++ b/app/Services/Dns/ManagedDnsRecordCleanup.php
@@ -149,6 +149,71 @@ class ManagedDnsRecordCleanup
return $successful;
}
+ /**
+ * Stops tracking the records of these resources without touching the provider. Used when another Coolify
+ * instance takes over the resources: their records still serve live traffic there. A record that another
+ * resource of this instance still uses keeps that resource's references.
+ *
+ * @param iterable $resources
+ */
+ public function forgetResources(iterable $resources): void
+ {
+ $keys = collect($resources)
+ ->flatMap(fn (Model $resource) => $this->resourceKeys($resource->getMorphClass(), $resource->getKey()))
+ ->all();
+ if ($keys === []) {
+ return;
+ }
+
+ $records = ManagedDnsRecord::query()
+ ->whereHas('references', fn (Builder $query) => $this->whereResourceKeys($query, $keys))
+ ->get();
+
+ foreach ($records as $record) {
+ $this->deleteReferences($record, $keys);
+ if (! $record->references()->exists()) {
+ $this->forget($record, 'The resource was transferred to another Coolify instance, which uses the record now.', 'info');
+ }
+ }
+ }
+
+ /**
+ * Keeps the records of resources that another Coolify instance manages now, for a later take back, but marks
+ * them not owned, so this instance never changes or deletes them at the provider. A record that another
+ * resource of this instance also uses only loses the references of these resources.
+ *
+ * @param iterable $resources
+ * @return list ids of the records that were owned before
+ */
+ public function parkResources(iterable $resources): array
+ {
+ $keys = collect($resources)
+ ->flatMap(fn (Model $resource) => $this->resourceKeys($resource->getMorphClass(), $resource->getKey()))
+ ->all();
+ if ($keys === []) {
+ return [];
+ }
+
+ $parked = [];
+ $records = ManagedDnsRecord::query()
+ ->whereHas('references', fn (Builder $query) => $this->whereResourceKeys($query, $keys))
+ ->get();
+ foreach ($records as $record) {
+ $sharedWithOthers = $record->references()->whereNot(fn (Builder $query) => $this->whereResourceKeys($query, $keys))->exists();
+ if ($sharedWithOthers) {
+ $this->deleteReferences($record, $keys);
+
+ continue;
+ }
+ if ($record->owned) {
+ $record->update(['owned' => false]);
+ $parked[] = $record->id;
+ }
+ }
+
+ return $parked;
+ }
+
/**
* Releases orphaned records: records whose references all point to deleted (or soft-deleted) resources, and
* records without references that are older than the grace period. Records with a live reference are not touched.
diff --git a/app/Services/ProjectIconStorageService.php b/app/Services/ProjectIconStorageService.php
index b7a381e109..0c6d314816 100644
--- a/app/Services/ProjectIconStorageService.php
+++ b/app/Services/ProjectIconStorageService.php
@@ -9,6 +9,14 @@ use RuntimeException;
class ProjectIconStorageService extends AvatarStorageService
{
public function storeProject(Project $project, UploadedFile $upload): void
+ {
+ $this->storeProjectContents($project, $this->compress($upload));
+ }
+
+ /**
+ * Stores an icon that is already a compressed JPEG, for example one from a server transfer bundle.
+ */
+ public function storeProjectContents(Project $project, string $contents): void
{
$settings = instanceSettings();
$storageType = $settings->avatar_storage_type === 's3' && $settings->avatar_s3_storage_id ? 's3' : 'local';
@@ -16,7 +24,7 @@ class ProjectIconStorageService extends AvatarStorageService
$disk = $this->disk($storageType, $s3StorageId);
$path = "project-icons/{$project->uuid}/icon.jpg";
- if (! $disk->put($path, $this->compress($upload))) {
+ if (! $disk->put($path, $contents)) {
throw new RuntimeException('Unable to store the project icon.');
}
diff --git a/app/Services/ServerTransfer/ServerTransferBundle.php b/app/Services/ServerTransfer/ServerTransferBundle.php
index 692842d085..aa5936d14e 100644
--- a/app/Services/ServerTransfer/ServerTransferBundle.php
+++ b/app/Services/ServerTransfer/ServerTransferBundle.php
@@ -2,16 +2,12 @@
namespace App\Services\ServerTransfer;
-use Illuminate\Support\Facades\Crypt;
use Illuminate\Validation\ValidationException;
-use RuntimeException;
class ServerTransferBundle
{
public const SCHEMA_VERSION = 1;
- public const MAILBOX_DIR = '/data/coolify/exports';
-
/**
* @param array $payload
*/
@@ -19,6 +15,7 @@ class ServerTransferBundle
{
return array_merge([
'schema_version' => self::SCHEMA_VERSION,
+ 'coolify_version' => config('constants.coolify.version'),
'exported_at' => now()->toIso8601String(),
'export_id' => new_public_id(),
], $payload);
@@ -39,6 +36,14 @@ class ServerTransferBundle
$errors[] = 'Unsupported schema_version '.$bundle['schema_version'].'. Expected '.self::SCHEMA_VERSION.'.';
}
+ $bundleVersion = data_get($bundle, 'coolify_version');
+ $instanceVersion = (string) config('constants.coolify.version');
+ if (! is_string($bundleVersion) || $bundleVersion === '') {
+ $errors[] = 'Missing coolify_version. Export the server again with Coolify '.$instanceVersion.' or newer.';
+ } elseif (version_compare($bundleVersion, $instanceVersion, '>')) {
+ $errors[] = "This bundle was exported from Coolify {$bundleVersion}. Upgrade this instance (Coolify {$instanceVersion}) to {$bundleVersion} or newer before you import it.";
+ }
+
if (! isset($bundle['export_id']) || ! is_string($bundle['export_id']) || $bundle['export_id'] === '') {
$errors[] = 'Missing export_id.';
}
@@ -90,90 +95,4 @@ class ServerTransferBundle
]);
}
}
-
- /**
- * 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
index 593bdd91ed..f479b1fb49 100644
--- a/app/Services/ServerTransfer/ServerTransferClaimer.php
+++ b/app/Services/ServerTransfer/ServerTransferClaimer.php
@@ -2,7 +2,16 @@
namespace App\Services\ServerTransfer;
+use App\Enums\GithubRunnerStatus;
+use App\Jobs\DeleteResourceJob;
+use App\Models\GithubRunnerExecution;
+use App\Models\ManagedDnsRecord;
use App\Models\Server;
+use App\Notifications\Internal\GeneralNotification;
+use App\Services\Dns\CloudflareDnsProvider;
+use App\Services\Dns\ManagedDnsRecordCleanup;
+use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use RuntimeException;
@@ -10,11 +19,20 @@ use Throwable;
class ServerTransferClaimer
{
+ /**
+ * The managed host records which claim owns it. Both instances can reach the host, so it is the one
+ * place where an instance can learn that another instance took the server over.
+ */
+ public const OWNER_MARKER_DIR = '/data/coolify/transfer';
+
+ public const OWNER_MARKER_PATH = self::OWNER_MARKER_DIR.'/owner.json';
+
/**
* Take management of a server on this Coolify instance and bind Sentinel to it.
*
* Database ownership (metadata + Sentinel settings) is committed in one transaction so a
- * mid-flight failure rolls back.
+ * mid-flight failure rolls back. The owner marker on the host is written last in that
+ * transaction: if the host cannot be reached, the claim fails and nothing changes here.
*
* @return array{
* server_uuid: string,
@@ -36,6 +54,7 @@ class ServerTransferClaimer
$exportId = data_get($server->server_metadata, 'transfer.export_id');
$claim = [
+ 'claim_id' => new_public_id(),
'instance_url' => $instanceUrl,
'server_uuid' => $server->uuid,
'team_id' => $server->team_id,
@@ -47,13 +66,16 @@ class ServerTransferClaimer
return DB::transaction(function () use ($server, $claim, $instanceUrl) {
$server = Server::query()->with('settings')->lockForUpdate()->findOrFail($server->id);
+ // A disabled server refuses SSH, so the owner marker could not be written.
+ if ($server->team->serverOverflow()) {
+ throw new RuntimeException('Your team is over its server limit. Upgrade your subscription or remove a server first.');
+ }
+
if ($server->settings) {
$server->settings->sentinel_custom_url = $instanceUrl;
$server->settings->ensureValidSentinelToken();
$server->settings->is_sentinel_enabled = true;
- if (! $server->team->serverOverflow()) {
- $server->settings->force_disabled = false;
- }
+ $server->settings->force_disabled = false;
$server->settings->save();
}
@@ -66,6 +88,8 @@ class ServerTransferClaimer
$server->server_metadata = $metadata;
$server->save();
+ $this->writeOwnerMarker($server, $claim);
+
return [
'server_uuid' => $server->uuid,
'claim' => $claim,
@@ -81,18 +105,15 @@ class ServerTransferClaimer
* 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).
*
- * $managementDisabled marks a manual "Disable management" action: the server is not moved yet,
- * only ready to be transferred to another instance.
- *
* @return array{server_uuid: string, message: string}
*/
- public function markTransferred(Server $server, ?string $exportId = null, ?string $targetInstanceUrl = null, bool $managementDisabled = false): array
+ 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, $managementDisabled) {
+ $result = DB::transaction(function () use ($server, $exportId, $targetInstanceUrl) {
$server = Server::query()->with('settings')->lockForUpdate()->findOrFail($server->id);
if ($server->settings) {
@@ -107,8 +128,15 @@ class ServerTransferClaimer
'export_id' => $exportId ?? data_get($metadata, 'transfer.export_id'),
'target_instance_url' => $targetInstanceUrl,
'transferred_at' => now()->toIso8601String(),
- 'management_disabled' => $managementDisabled,
]);
+ unset($metadata['transfer']['finish'], $metadata['transfer']['taken_back_at']);
+ $server->server_metadata = $metadata;
+ $server->save();
+
+ $this->releaseGithubRunners($server);
+
+ // Kept for a take back. Not owned, so this instance never changes or deletes them at the provider.
+ $metadata['transfer']['parked_dns_record_ids'] = app(ManagedDnsRecordCleanup::class)->parkResources($this->dnsResources($server));
$server->server_metadata = $metadata;
$server->save();
@@ -123,6 +151,144 @@ class ServerTransferClaimer
return $result;
}
+ /**
+ * The runners on the server belong to the target now. Queued runners are cancelled. Started runners are marked
+ * finished without removing them at GitHub: their jobs keep running, and deleting the archive here later must
+ * not deregister them. A disabled config also stops new runners for this server from GitHub webhooks.
+ */
+ /**
+ * The other instance serves the hostnames of a transferred server. Without this, deleting the archive here,
+ * or the orphaned-record cleanup after it, would delete live DNS records at the provider.
+ */
+ public function forgetDnsRecords(Server $server): void
+ {
+ app(ManagedDnsRecordCleanup::class)->forgetResources($this->dnsResources($server));
+ }
+
+ /**
+ * Delete the archive of a transferred server with its resources, records only: another instance manages the
+ * host, so nothing runs on the server and nothing changes at a provider.
+ */
+ public function deleteArchive(Server $server): void
+ {
+ if (! $server->isTransferredAway()) {
+ throw new RuntimeException('Only the archive of a transferred server can be deleted this way.');
+ }
+
+ $this->forgetDnsRecords($server);
+ foreach ($server->definedResources() as $resource) {
+ (new DeleteResourceJob($resource, deleteFromCoolifyOnly: true))->handle();
+ }
+ $server->forceDelete();
+ }
+
+ /**
+ * @return Collection
+ */
+ private function dnsResources(Server $server): Collection
+ {
+ return $server->applications()
+ ->concat($server->services()->with('applications')->get()->flatMap->applications);
+ }
+
+ /**
+ * After a take back: own the parked DNS records again where Cloudflare confirms them, then start the steps that
+ * finish the take back (webhooks, runners, records that this instance could not take back). Provider calls run
+ * here, after the claim committed.
+ *
+ * @return list> the steps
+ */
+ public function restoreAfterTakeBack(Server $server): array
+ {
+ $dns = app(CloudflareDnsProvider::class);
+ $parkedIds = (array) data_get($server->server_metadata, 'transfer.parked_dns_record_ids', []);
+ foreach (ManagedDnsRecord::query()->whereIn('id', $parkedIds)->get() as $record) {
+ if ($dns->reclaimRecord($record)) {
+ $record->update(['owned' => true]);
+ }
+ }
+
+ DB::transaction(function () use ($server) {
+ $locked = Server::query()->lockForUpdate()->findOrFail($server->id);
+ $metadata = $locked->server_metadata ?? [];
+ unset($metadata['transfer']['parked_dns_record_ids']);
+ $metadata['transfer']['taken_back_at'] = now()->toIso8601String();
+ $locked->server_metadata = $metadata;
+ $locked->save();
+ $server->server_metadata = $metadata;
+ });
+
+ return app(TransferFinishChecklist::class)->start($server);
+ }
+
+ private function releaseGithubRunners(Server $server): void
+ {
+ $server->githubRunnerConfig()->update(['is_enabled' => false]);
+
+ $message = 'The server was transferred to another Coolify instance. This instance stopped managing its runners.';
+ GithubRunnerExecution::query()
+ ->where('server_id', $server->id)
+ ->whereIn('status', GithubRunnerStatus::active())
+ ->get()
+ ->each(fn (GithubRunnerExecution $execution) => $execution->finish(GithubRunnerStatus::Cancelled, $message));
+ }
+
+ /**
+ * Stop managing a claimed server when its owner marker names a claim of another instance.
+ * A server that was added here without a claim ignores the marker: a marker can be left over on a
+ * host that an old instance deleted, and the next claim overwrites it.
+ *
+ * @return bool true when this instance released the server
+ */
+ public function releaseIfClaimedElsewhere(Server $server, ?string $markerJson): bool
+ {
+ if ($server->id === 0 || data_get($server->server_metadata, 'transfer.status') !== 'claimed') {
+ return false;
+ }
+
+ $marker = json_decode((string) $markerJson, true);
+ $markerClaimId = data_get($marker, 'claim_id');
+ if (! is_string($markerClaimId) || $markerClaimId === '') {
+ return false;
+ }
+
+ if ($markerClaimId === data_get($server->server_metadata, 'transfer.claim.claim_id')) {
+ return false;
+ }
+
+ $owner = data_get($marker, 'instance_url');
+ $owner = is_string($owner) && $owner !== '' ? $owner : null;
+
+ $this->markTransferred($server, targetInstanceUrl: $owner);
+
+ Log::warning('Server is claimed by another Coolify instance; management stopped here', [
+ 'server_uuid' => $server->uuid,
+ 'owner_instance_url' => $owner,
+ ]);
+ $server->team?->notify(new GeneralNotification(
+ "Server '{$server->name}' is now managed by ".($owner ?? 'another Coolify instance').'. This instance stopped managing it.',
+ success: false,
+ ));
+
+ return true;
+ }
+
+ /**
+ * @param array $claim
+ */
+ private function writeOwnerMarker(Server $server, array $claim): void
+ {
+ $marker = json_encode([
+ 'claim_id' => $claim['claim_id'],
+ 'instance_url' => $claim['instance_url'],
+ 'server_uuid' => $claim['server_uuid'],
+ 'claimed_at' => $claim['claimed_at'],
+ ], JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
+
+ instant_remote_process(['mkdir -p '.self::OWNER_MARKER_DIR], $server);
+ instant_remote_write_file($server, self::OWNER_MARKER_PATH, $marker);
+ }
+
/**
* 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.
@@ -139,51 +305,4 @@ class ServerTransferClaimer
]);
}
}
-
- /**
- * 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),
- // tee, not a redirect: the SSH user's shell opens a redirect, and a non-root user cannot enter /data/coolify.
- 'echo '.escapeshellarg($b64).' | base64 -d | tee '.escapeshellarg($path).' > /dev/null',
- '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,
- ];
- }
}
diff --git a/app/Services/ServerTransfer/ServerTransferCoverage.php b/app/Services/ServerTransfer/ServerTransferCoverage.php
new file mode 100644
index 0000000000..168ffae7c0
--- /dev/null
+++ b/app/Services/ServerTransfer/ServerTransferCoverage.php
@@ -0,0 +1,347 @@
+>
+ */
+ public const TRANSFERRED = [
+ Server::class => ['server'],
+ ServerSetting::class => ['server.settings'],
+ PrivateKey::class => ['private_key', 'private_keys.*'],
+ StandaloneDocker::class => ['destinations.*'],
+ SwarmDocker::class => ['destinations.*'],
+ CloudProviderToken::class => ['cloud_provider_tokens.*'],
+ IntegrationToken::class => ['integration_tokens.*'],
+ DnsProviderZone::class => ['dns_zones.*'],
+ ManagedDnsRecord::class => ['managed_dns_records.*'],
+ ManagedDnsRecordReference::class => ['managed_dns_records.*.references.*'],
+ SecretManagerLink::class => [
+ 'projects.*.environments.*.applications.*.secret_manager_link',
+ 'projects.*.environments.*.databases.*.secret_manager_link',
+ 'projects.*.environments.*.services.*.secret_manager_link',
+ ],
+ SslCertificate::class => ['ssl_certificates.*'],
+ GithubApp::class => ['github_apps.*'],
+ GithubRunnerConfig::class => ['github_runner_config'],
+ GitlabApp::class => ['gitlab_apps.*'],
+ S3Storage::class => ['s3_storages.*'],
+ Project::class => ['projects.*'],
+ Environment::class => ['projects.*.environments.*'],
+ ResourceLink::class => ['projects.*.environments.*.resource_links.*'],
+ SharedEnvironmentVariable::class => [
+ 'shared_environment_variables.server.*',
+ 'shared_environment_variables.team.*',
+ 'projects.*.shared_environment_variables.*',
+ 'projects.*.environments.*.shared_environment_variables.*',
+ ],
+ Application::class => ['projects.*.environments.*.applications.*'],
+ ApplicationSetting::class => ['projects.*.environments.*.applications.*.settings'],
+ ApplicationPreview::class => ['projects.*.environments.*.applications.*.previews.*'],
+ EnvironmentVariable::class => [
+ 'projects.*.environments.*.applications.*.environment_variables.*',
+ 'projects.*.environments.*.applications.*.environment_variables_preview.*',
+ 'projects.*.environments.*.databases.*.environment_variables.*',
+ 'projects.*.environments.*.services.*.environment_variables.*',
+ ],
+ LocalPersistentVolume::class => [
+ 'projects.*.environments.*.applications.*.persistent_storages.*',
+ 'projects.*.environments.*.databases.*.persistent_storages.*',
+ ],
+ LocalFileVolume::class => [
+ 'projects.*.environments.*.applications.*.file_storages.*',
+ 'projects.*.environments.*.databases.*.file_storages.*',
+ ],
+ ScheduledTask::class => [
+ 'projects.*.environments.*.applications.*.scheduled_tasks.*',
+ 'projects.*.environments.*.services.*.scheduled_tasks.*',
+ ],
+ Tag::class => [
+ 'projects.*.environments.*.applications.*.tags.*',
+ 'projects.*.environments.*.databases.*.tags.*',
+ 'projects.*.environments.*.services.*.tags.*',
+ ],
+ ScheduledDatabaseBackup::class => ['projects.*.environments.*.databases.*.scheduled_backups.*'],
+ ScheduledVolumeBackup::class => ['volume_backups.*'],
+ Service::class => ['projects.*.environments.*.services.*'],
+ ServiceApplication::class => ['projects.*.environments.*.services.*.applications.*'],
+ ServiceDatabase::class => ['projects.*.environments.*.services.*.databases.*'],
+ StandalonePostgresql::class => ['projects.*.environments.*.databases.*'],
+ StandaloneMysql::class => ['projects.*.environments.*.databases.*'],
+ StandaloneMariadb::class => ['projects.*.environments.*.databases.*'],
+ StandaloneMongodb::class => ['projects.*.environments.*.databases.*'],
+ StandaloneRedis::class => ['projects.*.environments.*.databases.*'],
+ StandaloneKeydb::class => ['projects.*.environments.*.databases.*'],
+ StandaloneDragonfly::class => ['projects.*.environments.*.databases.*'],
+ StandaloneClickhouse::class => ['projects.*.environments.*.databases.*'],
+ StandaloneSqlite::class => ['projects.*.environments.*.databases.*'],
+ ];
+
+ private const LINKED = 'The bundle links the record by UUID or by its place in the bundle; the target sets its own ID.';
+
+ private const TEAM = 'The target imports into one of its own teams.';
+
+ private const RUNTIME = 'Runtime state: the target sets it again when it manages the server.';
+
+ /**
+ * Columns of transferred models that are not in the bundle under their own name, with the reason.
+ * Columns in ALWAYS_NOT_EXPORTED are left out of every model.
+ *
+ * @var array>
+ */
+ public const NOT_EXPORTED_COLUMNS = [
+ Server::class => [
+ 'team_id' => self::TEAM, 'private_key_id' => self::LINKED, 'cloud_provider_token_id' => self::LINKED,
+ 'unreachable_count' => self::RUNTIME, 'high_disk_usage_notification_sent' => self::RUNTIME,
+ 'log_drain_notification_sent' => self::RUNTIME, 'validation_logs' => self::RUNTIME,
+ 'sentinel_updated_at' => self::RUNTIME, 'sentinel_waiting_since' => self::RUNTIME, 'ip_previous' => self::RUNTIME,
+ 'is_validating' => self::RUNTIME, 'detected_traefik_version' => self::RUNTIME, 'traefik_outdated_info' => self::RUNTIME,
+ 'hetzner_server_status' => self::RUNTIME, 'vultr_instance_status' => self::RUNTIME,
+ 'digitalocean_droplet_status' => self::RUNTIME, 'hostinger_virtual_machine_status' => self::RUNTIME,
+ 'server_metadata' => 'Facts about the host and the transfer state of the source; the target collects its own.',
+ ],
+ ServerSetting::class => [
+ 'server_id' => self::LINKED, 'is_reachable' => self::RUNTIME, 'is_usable' => self::RUNTIME, 'force_disabled' => self::RUNTIME,
+ 'is_sentinel_enabled' => 'The target enables Sentinel when it claims the server.',
+ 'is_sentinel_debug_enabled' => 'The target enables Sentinel when it claims the server.',
+ 'sentinel_token' => 'The target makes a new Sentinel token when it claims the server.',
+ 'sentinel_custom_url' => 'The target sets its own URL when it claims the server.',
+ 'cloudflare_tunnel_integration_token_id' => 'Exported as cloudflare_tunnel_integration_token_uuid. '.self::LINKED,
+ ],
+ PrivateKey::class => ['team_id' => self::TEAM],
+ StandaloneDocker::class => ['server_id' => self::LINKED],
+ SwarmDocker::class => ['server_id' => self::LINKED],
+ CloudProviderToken::class => ['team_id' => self::TEAM],
+ IntegrationToken::class => ['team_id' => self::TEAM],
+ DnsProviderZone::class => ['integration_token_id' => self::LINKED],
+ ManagedDnsRecord::class => ['team_id' => self::TEAM, 'integration_token_id' => self::LINKED, 'dns_provider_zone_id' => self::LINKED],
+ ManagedDnsRecordReference::class => [
+ 'managed_dns_record_id' => 'The reference is part of its record in the bundle.',
+ 'resource_type' => self::LINKED, 'resource_id' => self::LINKED,
+ ],
+ SecretManagerLink::class => ['resourceable_type' => self::LINKED, 'resourceable_id' => self::LINKED, 'integration_token_id' => self::LINKED],
+ SslCertificate::class => ['server_id' => self::LINKED, 'resource_type' => self::LINKED, 'resource_id' => self::LINKED],
+ GithubApp::class => ['team_id' => self::TEAM, 'private_key_id' => self::LINKED],
+ GithubRunnerConfig::class => [
+ 'server_id' => self::LINKED,
+ 'github_app_id' => 'Exported as github_app_uuid. '.self::LINKED,
+ ],
+ GitlabApp::class => ['team_id' => self::TEAM, 'private_key_id' => self::LINKED],
+ S3Storage::class => ['team_id' => self::TEAM, 'unusable_email_sent' => self::RUNTIME],
+ Project::class => [
+ 'team_id' => self::TEAM,
+ 'icon_path' => 'The icon file is exported as `icon` (base64 content). The target stores it in its own icon storage and sets the path.',
+ 'icon_storage_type' => 'The target uses its own default icon storage. See icon_path.',
+ 'icon_s3_storage_id' => 'The target uses its own default icon storage. See icon_path.',
+ ],
+ Environment::class => ['project_id' => self::LINKED],
+ ResourceLink::class => [
+ 'environment_id' => 'The link is part of its environment in the bundle.',
+ 'source_type' => 'Exported as source.kind and source.uuid. '.self::LINKED,
+ 'source_id' => 'Exported as source.kind and source.uuid. '.self::LINKED,
+ 'target_type' => 'Exported as target.kind and target.uuid. '.self::LINKED,
+ 'target_id' => 'Exported as target.kind and target.uuid. '.self::LINKED,
+ 'created_by' => 'A user of the source instance; the target leaves it empty.',
+ ],
+ SharedEnvironmentVariable::class => [
+ 'type' => 'The scope follows from the place of the variable in the bundle.',
+ 'team_id' => self::TEAM, 'project_id' => self::LINKED, 'environment_id' => self::LINKED, 'server_id' => self::LINKED,
+ 'version' => 'The Coolify version that created the variable; the target sets its own.',
+ ],
+ Application::class => [
+ 'environment_id' => self::LINKED, 'destination_type' => self::LINKED, 'destination_id' => self::LINKED,
+ 'source_type' => self::LINKED, 'source_id' => self::LINKED, 'private_key_id' => self::LINKED,
+ ],
+ ApplicationSetting::class => ['application_id' => self::LINKED],
+ ApplicationPreview::class => [
+ 'application_id' => self::LINKED, 'last_online_at' => self::RUNTIME, 'domain_dns_statuses' => self::RUNTIME,
+ 'restart_count' => self::RUNTIME, 'restart_limit_reached' => self::RUNTIME, 'last_restart_at' => self::RUNTIME,
+ 'last_restart_type' => self::RUNTIME,
+ ],
+ EnvironmentVariable::class => ['resourceable_type' => self::LINKED, 'resourceable_id' => self::LINKED],
+ LocalPersistentVolume::class => [
+ 'resource_type' => self::LINKED, 'resource_id' => self::LINKED, 'standalone_sqlite_id' => self::LINKED,
+ 'container_id' => 'Not used: only listed in $fillable.',
+ ],
+ LocalFileVolume::class => ['resource_type' => self::LINKED, 'resource_id' => self::LINKED],
+ ScheduledTask::class => ['team_id' => self::TEAM, 'application_id' => self::LINKED, 'service_id' => self::LINKED],
+ Tag::class => ['team_id' => self::TEAM],
+ ScheduledDatabaseBackup::class => [
+ 'team_id' => self::TEAM, 'database_type' => self::LINKED, 'database_id' => self::LINKED, 's3_storage_id' => self::LINKED,
+ 'last_execution_at' => 'Execution history of the source.',
+ ],
+ ScheduledVolumeBackup::class => [
+ 'team_id' => self::TEAM, 'backupable_type' => self::LINKED, 'backupable_id' => self::LINKED, 's3_storage_id' => self::LINKED,
+ ],
+ Service::class => [
+ 'environment_id' => self::LINKED, 'server_id' => self::LINKED, 'destination_type' => self::LINKED, 'destination_id' => self::LINKED,
+ ],
+ ServiceApplication::class => ['service_id' => self::LINKED],
+ ServiceDatabase::class => ['service_id' => self::LINKED],
+ StandalonePostgresql::class => [
+ 'destination_type' => self::LINKED, 'destination_id' => self::LINKED, 'environment_id' => self::LINKED,
+ 'config_hash' => 'Calculated again when the target starts the database.',
+ ],
+ StandaloneMysql::class => [
+ 'destination_type' => self::LINKED, 'destination_id' => self::LINKED, 'environment_id' => self::LINKED,
+ 'config_hash' => 'Calculated again when the target starts the database.',
+ ],
+ StandaloneMariadb::class => [
+ 'destination_type' => self::LINKED, 'destination_id' => self::LINKED, 'environment_id' => self::LINKED,
+ 'config_hash' => 'Calculated again when the target starts the database.',
+ ],
+ StandaloneMongodb::class => [
+ 'destination_type' => self::LINKED, 'destination_id' => self::LINKED, 'environment_id' => self::LINKED,
+ 'config_hash' => 'Calculated again when the target starts the database.',
+ ],
+ StandaloneRedis::class => [
+ 'destination_type' => self::LINKED, 'destination_id' => self::LINKED, 'environment_id' => self::LINKED,
+ 'config_hash' => 'Calculated again when the target starts the database.',
+ ],
+ StandaloneKeydb::class => [
+ 'destination_type' => self::LINKED, 'destination_id' => self::LINKED, 'environment_id' => self::LINKED,
+ 'config_hash' => 'Calculated again when the target starts the database.',
+ ],
+ StandaloneDragonfly::class => [
+ 'destination_type' => self::LINKED, 'destination_id' => self::LINKED, 'environment_id' => self::LINKED,
+ 'config_hash' => 'Calculated again when the target starts the database.',
+ ],
+ StandaloneClickhouse::class => [
+ 'destination_type' => self::LINKED, 'destination_id' => self::LINKED, 'environment_id' => self::LINKED,
+ 'config_hash' => 'Calculated again when the target starts the database.',
+ ],
+ StandaloneSqlite::class => [
+ 'destination_type' => self::LINKED, 'destination_id' => self::LINKED, 'environment_id' => self::LINKED,
+ 'config_hash' => 'Calculated again when the target starts the database.',
+ 'is_public' => 'Not used: SQLite has no public port.',
+ ],
+ ];
+
+ /**
+ * Created on the target instance for every record.
+ *
+ * @var list
+ */
+ public const ALWAYS_NOT_EXPORTED = ['id', 'created_at', 'updated_at', 'deleted_at'];
+
+ /**
+ * Models that a transfer does not move, with the reason.
+ *
+ * @var array
+ */
+ public const NOT_TRANSFERRED = [
+ Team::class => 'Instance-wide: the target imports into one of its own teams.',
+ User::class => 'Instance-wide: users are not moved with a server.',
+ TeamInvitation::class => 'Team data, not server data.',
+ Subscription::class => 'Billing of the source instance.',
+ InstanceSettings::class => 'Settings of the source instance.',
+ OauthSetting::class => 'Settings of the source instance.',
+ OauthIdentity::class => 'User login data of the source instance.',
+ PersonalAccessToken::class => 'API tokens of the source instance.',
+ UserChangelogRead::class => 'User data of the source instance.',
+ AuditEvent::class => 'Audit log of the source instance.',
+ NotificationThrottle::class => 'Runtime state of the source instance.',
+ ScheduledJobState::class => 'Scheduler state of the source instance.',
+ ScheduledJobDelivery::class => 'Scheduler state of the source instance.',
+ EmailNotificationSettings::class => 'Team notification settings, not server data.',
+ DiscordNotificationSettings::class => 'Team notification settings, not server data.',
+ SlackNotificationSettings::class => 'Team notification settings, not server data.',
+ TelegramNotificationSettings::class => 'Team notification settings, not server data.',
+ PushoverNotificationSettings::class => 'Team notification settings, not server data.',
+ WebhookNotificationSettings::class => 'Team notification settings, not server data.',
+ CloudInitScript::class => 'Team server-creation templates, not server data.',
+ ProjectSetting::class => 'Created with the project on the target; it has no settings of its own yet.',
+ ApplicationDeploymentQueue::class => 'Deployment history.',
+ ScheduledTaskExecution::class => 'Execution history.',
+ ScheduledDatabaseBackupExecution::class => 'Execution history.',
+ ScheduledVolumeBackupExecution::class => 'Execution history.',
+ DatabaseBackupS3Replica::class => 'Backup history.',
+ VolumeBackupS3Replica::class => 'Backup history.',
+ DockerCleanupExecution::class => 'Execution history.',
+ GithubRunnerExecution::class => 'Execution history. The source stops tracking the runners of the server when it is transferred.',
+ ];
+}
diff --git a/app/Services/ServerTransfer/ServerTransferExporter.php b/app/Services/ServerTransfer/ServerTransferExporter.php
index 176aa8d30d..b93c9f9ccc 100644
--- a/app/Services/ServerTransfer/ServerTransferExporter.php
+++ b/app/Services/ServerTransfer/ServerTransferExporter.php
@@ -5,12 +5,17 @@ namespace App\Services\ServerTransfer;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\CloudProviderToken;
+use App\Models\DnsProviderZone;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\GithubApp;
+use App\Models\GithubRunnerConfig;
use App\Models\GitlabApp;
+use App\Models\IntegrationToken;
use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
+use App\Models\ManagedDnsRecord;
+use App\Models\ManagedDnsRecordReference;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\S3Storage;
@@ -23,14 +28,22 @@ use App\Models\SharedEnvironmentVariable;
use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
use App\Models\Tag;
+use App\Services\ProjectIconStorageService;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
+use Illuminate\Support\Str;
use RuntimeException;
+use Throwable;
class ServerTransferExporter
{
+ /**
+ * Larger project icons are not put in the bundle. Icons that Coolify stores are small JPEG files.
+ */
+ public const MAX_PROJECT_ICON_BYTES = 2 * 1024 * 1024;
+
/**
* Settings that are instance-bound or runtime health state and must not transfer.
*
@@ -48,6 +61,8 @@ class ServerTransferExporter
'sentinel_custom_url',
'is_sentinel_enabled',
'is_sentinel_debug_enabled',
+ // Exported as cloudflare_tunnel_integration_token_uuid.
+ 'cloudflare_tunnel_integration_token_id',
];
/**
@@ -86,6 +101,9 @@ class ServerTransferExporter
if (! $privateKey instanceof PrivateKey) {
throw new RuntimeException('Server has no private key to export.');
}
+ if ((int) $privateKey->team_id !== (int) $server->team_id) {
+ throw new RuntimeException('The server private key does not belong to the server team. Select a private key of this team first.');
+ }
if (! $includeSensitive) {
throw new RuntimeException('Server transfer export requires sensitive data access (read:sensitive).');
@@ -100,15 +118,36 @@ class ServerTransferExporter
$this->assertNoAdditionalDestinations($server, $applications);
$projectIds = $this->collectProjectIds($applications, $databases, $services);
- $projects = Project::query()->whereIn('id', $projectIds)->orderBy('name')->get();
+ $projects = Project::query()->where('team_id', $server->team_id)->whereIn('id', $projectIds)->orderBy('name')->get();
+ if ($projects->count() !== collect($projectIds)->unique()->count()) {
+ throw new RuntimeException('Some resources on this server belong to a project of another team. Move them to a project of this team first.');
+ }
$destinationUuidByKey = $this->destinationUuidMap($server);
- $dependencies = $this->collectDependencies($server, $applications, $databases, $services);
+ $runnerConfig = $server->githubRunnerConfig()->with('githubApp')->first();
+ $dependencies = $this->collectDependencies($server, $applications, $databases, $services, $runnerConfig);
$sourceInstanceUrl = rtrim((string) (instanceSettings()->fqdn ?: config('app.url')), '/');
- $warnings = $this->buildWarnings($applications, $dependencies, $sourceInstanceUrl);
- if ($secretManagerWarning = $this->secretManagerWarning($applications->concat($databases)->concat($services))) {
- $warnings[] = $secretManagerWarning;
+ $managedDns = $this->collectManagedDnsRecords($server, $applications, $services);
+ $integrationTokens = $this->collectIntegrationTokens($server, $applications->concat($databases)->concat($services), $managedDns['records']);
+
+ $serverSharedEnvVars = $this->exportSharedEnvVars(
+ SharedEnvironmentVariable::query()
+ ->where('type', 'server')
+ ->where('server_id', $server->id)
+ ->get()
+ );
+ $projectWarnings = [];
+ $leftOutLinks = 0;
+ $projectPayloads = $projects->map(function (Project $project) use ($destinationUuidByKey, $applications, $databases, $services, &$projectWarnings, &$leftOutLinks) {
+ return $this->exportProject($project, $destinationUuidByKey, $applications, $databases, $services, $projectWarnings, $leftOutLinks);
+ })->values()->all();
+
+ $warnings = $this->buildWarnings($applications, $dependencies);
+ $warnings = array_merge($warnings, $managedDns['warnings'], $projectWarnings);
+ if ($leftOutLinks > 0) {
+ $warnings[] = $leftOutLinks.' '.Str::plural('link', $leftOutLinks).' between resources on the environment canvas '
+ .($leftOutLinks === 1 ? 'is' : 'are').' not transferred, because one of the linked resources stays on this instance.';
}
$payload = ServerTransferBundle::wrap([
@@ -117,6 +156,8 @@ class ServerTransferExporter
'name' => config('app.name'),
],
'warnings' => $warnings,
+ // Manual steps after the transfer. The source replaces {target_url} with the URL of the target.
+ 'post_transfer_checklist' => $this->postTransferChecklist($dependencies, $runnerConfig, $applications),
// Back-compat: single server key
'private_key' => $this->exportPrivateKey($privateKey),
// All keys needed by server, apps, and git sources
@@ -125,21 +166,19 @@ class ServerTransferExporter
'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(),
+ 'integration_tokens' => $integrationTokens->map(fn (IntegrationToken $token) => $this->exportIntegrationToken($token))->values()->all(),
+ 'dns_zones' => $managedDns['records']->pluck('zone')->unique('id')->map(fn (DnsProviderZone $zone) => $this->exportDnsZone($zone))->values()->all(),
+ 'managed_dns_records' => $managedDns['records']->map(fn (ManagedDnsRecord $record) => $this->exportManagedDnsRecord($record, $managedDns['resource_keys']))->values()->all(),
'ssl_certificates' => $this->exportSslCertificates($server),
'volume_backups' => $this->exportVolumeBackups($applications, $databases, $services),
'server' => $this->exportServer($server),
'destinations' => $this->exportDestinations($server),
+ 'github_runner_config' => $runnerConfig ? $this->exportGithubRunnerConfig($runnerConfig) : null,
'shared_environment_variables' => [
- 'server' => $this->exportSharedEnvVars(
- SharedEnvironmentVariable::query()
- ->where('type', 'server')
- ->where('server_id', $server->id)
- ->get()
- ),
+ 'server' => $serverSharedEnvVars,
+ 'team' => $this->exportReferencedTeamSharedEnvVars($server->team_id, $serverSharedEnvVars, $projectPayloads),
],
- 'projects' => $projects->map(function (Project $project) use ($server, $destinationUuidByKey, $applications, $databases, $services) {
- return $this->exportProject($project, $server, $destinationUuidByKey, $applications, $databases, $services);
- })->values()->all(),
+ 'projects' => $projectPayloads,
]);
return $payload;
@@ -157,47 +196,46 @@ class ServerTransferExporter
* cloud_provider_tokens: Collection
* }
*/
- private function collectDependencies(Server $server, Collection $applications, Collection $databases, Collection $services): array
+ private function collectDependencies(Server $server, Collection $applications, Collection $databases, Collection $services, ?GithubRunnerConfig $runnerConfig): array
{
+ $teamId = $server->team_id;
$privateKeys = collect([$server->privateKey])->filter();
$githubApps = collect();
$gitlabApps = collect();
$s3Ids = collect();
+ // System-wide GitHub Apps are instance-owned; do not copy them. The application source and the runner
+ // config still record the App uuid, so the import can re-link to a system-wide App of the target.
+ $collectGithubApp = function (int|string $id) use ($teamId, $githubApps, $privateKeys): void {
+ $gh = $this->findGitSource(GithubApp::class, $id, $teamId);
+ if ($gh && ! $gh->is_system_wide) {
+ $githubApps->push($gh);
+ if ($gh->private_key_id) {
+ $privateKeys->push($this->findTeamRecord(PrivateKey::class, $gh->private_key_id, $teamId, 'private key'));
+ }
+ }
+ };
+
+ if ($runnerConfig) {
+ $collectGithubApp($runnerConfig->github_app_id);
+ }
+
foreach ($applications as $application) {
if ($application->private_key_id) {
- $key = PrivateKey::find($application->private_key_id);
- if ($key) {
- $privateKeys->push($key);
- }
+ $privateKeys->push($this->findTeamRecord(PrivateKey::class, $application->private_key_id, $teamId, 'private 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);
- }
- }
- }
+ $collectGithubApp($application->source_id);
}
if ($application->source_type === GitlabApp::class && $application->source_id !== null) {
- $gl = GitlabApp::find($application->source_id);
+ $gl = $this->findGitSource(GitlabApp::class, $application->source_id, $teamId);
// 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);
- }
+ $privateKeys->push($this->findTeamRecord(PrivateKey::class, $gl->private_key_id, $teamId, 'private key'));
}
}
}
@@ -264,37 +302,75 @@ class ServerTransferExporter
$cloudTokens = collect();
if ($server->cloud_provider_token_id) {
- $token = CloudProviderToken::find($server->cloud_provider_token_id);
- if ($token) {
- $cloudTokens->push($token);
- }
+ $cloudTokens->push($this->findTeamRecord(CloudProviderToken::class, $server->cloud_provider_token_id, $teamId, 'cloud provider 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(),
+ 's3_storages' => $this->findTeamS3Storages($s3Ids->unique()->filter()->values()->all(), $teamId),
'cloud_provider_tokens' => $cloudTokens->unique('id')->values(),
];
}
+ /**
+ * Load a credential record that must belong to the server team. A record of another team is
+ * never exported, so a broken reference stops the export instead of leaking its secrets.
+ *
+ * @template T of Model
+ *
+ * @param class-string $modelClass
+ * @return T
+ */
+ private function findTeamRecord(string $modelClass, int|string $id, int $teamId, string $label): Model
+ {
+ $record = $modelClass::query()->where('team_id', $teamId)->find($id);
+ if (! $record) {
+ throw new RuntimeException("A {$label} used on this server does not belong to the server team. Select a {$label} of this team first.");
+ }
+
+ return $record;
+ }
+
+ /**
+ * System-wide git sources are returned as they are (they are only linked by UUID, never exported).
+ *
+ * @param class-string $modelClass
+ */
+ private function findGitSource(string $modelClass, int|string $id, int $teamId): GithubApp|GitlabApp|null
+ {
+ $source = $modelClass::find($id);
+ if ($source && ! $source->is_system_wide && (int) $source->team_id !== $teamId) {
+ throw new RuntimeException('A git source used on this server does not belong to the server team. Select a git source of this team first.');
+ }
+
+ return $source;
+ }
+
+ /**
+ * @param list $ids
+ * @return Collection
+ */
+ private function findTeamS3Storages(array $ids, int $teamId): Collection
+ {
+ $storages = S3Storage::query()->where('team_id', $teamId)->whereIn('id', $ids)->get();
+ if ($storages->count() !== count($ids)) {
+ throw new RuntimeException('An S3 storage used by a backup on this server does not belong to the server team. Select an S3 storage of this team first.');
+ }
+
+ return $storages;
+ }
+
/**
* @param Collection $applications
* @param array $dependencies
* @return list
*/
- private function buildWarnings(Collection $applications, array $dependencies, string $sourceInstanceUrl): array
+ private function buildWarnings(Collection $applications, array $dependencies): 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;
@@ -319,11 +395,6 @@ class ServerTransferExporter
$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.';
}
@@ -332,24 +403,180 @@ class ServerTransferExporter
}
/**
- * Secret manager links point to integration tokens of this instance, which are not exported.
+ * Managed DNS records that only exported resources use. A record that a resource staying on this instance
+ * also uses is not exported: this instance keeps managing it.
*
- * @param Collection $resources
+ * @param Collection $applications
+ * @param Collection $services
+ * @return array{
+ * records: Collection,
+ * resource_keys: array,
+ * warnings: list
+ * }
*/
- private function secretManagerWarning(Collection $resources): ?string
+ private function collectManagedDnsRecords(Server $server, Collection $applications, Collection $services): array
{
- $linkedNames = $resources
- ->filter(fn (Model $resource) => method_exists($resource, 'secretManagerLink') && $resource->secretManagerLink()->exists())
- ->pluck('name')
- ->unique()
- ->values();
-
- if ($linkedNames->isEmpty()) {
- return null;
+ // Keyed by "morph type:id". Soft-deleted previews are covered, but their references are not exported.
+ $resourceKeys = [];
+ foreach ($applications as $application) {
+ $resourceKeys[$application->getMorphClass().':'.$application->id] = ['kind' => 'application', 'uuid' => $application->uuid];
+ foreach (ApplicationPreview::withTrashed()->where('application_id', $application->id)->get() as $preview) {
+ $resourceKeys[$preview->getMorphClass().':'.$preview->id] = $preview->trashed() ? null : ['kind' => 'preview', 'uuid' => $preview->uuid];
+ }
+ }
+ foreach ($services as $service) {
+ foreach ($service->applications as $serviceApplication) {
+ $resourceKeys[$serviceApplication->getMorphClass().':'.$serviceApplication->id] = ['kind' => 'service_application', 'uuid' => $serviceApplication->uuid];
+ }
+ }
+ if ($resourceKeys === []) {
+ return ['records' => collect(), 'resource_keys' => [], 'warnings' => []];
}
- return 'Some resources load environment variables from a secret manager: '.$linkedNames->implode(', ').'. '
- .'Secret manager tokens and links are not exported. After import, add the integration token on the target instance and link the secret manager source again, or deployments of these resources fail.';
+ $records = ManagedDnsRecord::query()
+ ->whereHas('references', function ($query) use ($resourceKeys) {
+ $query->where(function ($query) use ($resourceKeys) {
+ foreach (array_keys($resourceKeys) as $key) {
+ [$type, $id] = explode(':', $key, 2);
+ $query->orWhere(fn ($query) => $query->where('resource_type', $type)->where('resource_id', $id));
+ }
+ });
+ })
+ ->with(['references', 'zone.integrationToken', 'integrationToken'])
+ ->orderBy('name')
+ ->get();
+
+ $exported = collect();
+ $warnings = [];
+ foreach ($records as $record) {
+ $teamId = (int) $server->team_id;
+ if ((int) $record->team_id !== $teamId
+ || (int) $record->integrationToken?->team_id !== $teamId
+ || (int) $record->zone?->integrationToken?->team_id !== $teamId) {
+ $warnings[] = "DNS record {$record->name} is not transferred, because its DNS provider token belongs to another team. The target instance does not manage it.";
+
+ continue;
+ }
+ $sharedWithOtherResources = $record->references
+ ->contains(fn (ManagedDnsRecordReference $reference) => ! array_key_exists($reference->resource_type.':'.$reference->resource_id, $resourceKeys));
+ if ($sharedWithOtherResources) {
+ $warnings[] = "DNS record {$record->name} stays managed by this instance, because a resource that is not transferred also uses it. The target instance does not update or delete it.";
+
+ continue;
+ }
+ $hasLiveReference = $record->references
+ ->contains(fn (ManagedDnsRecordReference $reference) => $resourceKeys[$reference->resource_type.':'.$reference->resource_id] !== null);
+ if ($hasLiveReference) {
+ $exported->push($record);
+ }
+ }
+
+ return ['records' => $exported, 'resource_keys' => $resourceKeys, 'warnings' => $warnings];
+ }
+
+ /**
+ * Integration tokens that the exported resources need: secret manager links, managed DNS records, and the
+ * Cloudflare tunnel of the server.
+ *
+ * @param Collection $resources
+ * @param Collection $dnsRecords
+ * @return Collection
+ */
+ private function collectIntegrationTokens(Server $server, Collection $resources, Collection $dnsRecords): Collection
+ {
+ $tokenIds = collect([$server->settings?->cloudflare_tunnel_integration_token_id]);
+ foreach ($resources as $resource) {
+ if (method_exists($resource, 'secretManagerLink')) {
+ $tokenIds->push($resource->secretManagerLink()->value('integration_token_id'));
+ }
+ }
+ foreach ($dnsRecords as $record) {
+ $tokenIds->push($record->integration_token_id, $record->zone->integration_token_id);
+ }
+
+ return $tokenIds->filter()->unique()->values()
+ ->map(fn (int|string $id) => $this->findTeamRecord(IntegrationToken::class, $id, (int) $server->team_id, 'integration token'));
+ }
+
+ /**
+ * Manual steps after the transfer. {target_url} stands for the URL of the target instance.
+ *
+ * @param array{github_apps: Collection, gitlab_apps: Collection} $dependencies
+ * @param Collection $applications
+ * @return list
+ */
+ private function postTransferChecklist(array $dependencies, ?GithubRunnerConfig $runnerConfig, Collection $applications): array
+ {
+ $steps = [];
+ // A manual webhook is set in the repository itself, so its URL still points to this instance. Every
+ // application has the secrets, so list the git applications that do not deploy through a private App.
+ $manualWebhookApps = $applications
+ ->filter(fn (Application $application) => $application->build_pack !== 'dockerimage' && filled($application->git_repository)
+ && (! $application->source || data_get($application->source, 'is_public') === true))
+ ->pluck('name');
+ if ($manualWebhookApps->isNotEmpty()) {
+ $steps[] = 'If a repository of these applications has a manual webhook to Coolify, change its URL to the target: '
+ .$manualWebhookApps->implode(', ').'. Use {target_url}/webhooks/source/github/events/manual, '
+ .'{target_url}/webhooks/source/gitlab/events/manual, {target_url}/webhooks/source/bitbucket/events/manual, '
+ .'or {target_url}/webhooks/source/gitea/events/manual.';
+ }
+ foreach ($dependencies['github_apps'] as $githubApp) {
+ $steps[] = "On GitHub, open the settings of GitHub App {$githubApp->name}. Set the Webhook URL to {target_url}/webhooks/source/github/events, "
+ .'the Setup URL to {target_url}/webhooks/source/github/install, and the Callback URL to {target_url}/login/github/app.';
+ }
+ foreach ($dependencies['gitlab_apps'] as $gitlabApp) {
+ $steps[] = "In GitLab, open Settings > Webhooks of every project that uses GitLab App {$gitlabApp->name}, and set the webhook URL to {target_url}/webhooks/source/gitlab/events.";
+ }
+ if ($runnerConfig) {
+ $steps[] = 'After the GitHub App sends webhooks to {target_url}, open this server there, check the runner requirements, and enable GitHub runners. '
+ .'Enabling them stops runners that another Coolify instance started on this server.';
+ }
+
+ return $steps;
+ }
+
+ /**
+ * Team variables are exported only when an exported variable references them as {{team.KEY}}.
+ *
+ * @param list> $serverVariables
+ * @param list> $projects
+ * @return list>
+ */
+ private function exportReferencedTeamSharedEnvVars(int $teamId, array $serverVariables, array $projects): array
+ {
+ $values = collect($serverVariables)->pluck('value');
+ foreach ([
+ '*.shared_environment_variables.*.value',
+ '*.environments.*.shared_environment_variables.*.value',
+ '*.environments.*.applications.*.environment_variables.*.value',
+ '*.environments.*.applications.*.environment_variables_preview.*.value',
+ '*.environments.*.databases.*.environment_variables.*.value',
+ '*.environments.*.services.*.environment_variables.*.value',
+ '*.environments.*.services.*.applications.*.environment_variables.*.value',
+ ] as $path) {
+ $values = $values->merge(data_get($projects, $path, []));
+ }
+
+ $keys = $values
+ ->filter(fn ($value) => is_string($value))
+ ->flatMap(function (string $value) {
+ preg_match_all('/{{\s*team\.(.+?)\s*}}/', $value, $matches);
+
+ return $matches[1];
+ })
+ ->unique()
+ ->values();
+ if ($keys->isEmpty()) {
+ return [];
+ }
+
+ return $this->exportSharedEnvVars(
+ SharedEnvironmentVariable::query()
+ ->where('type', 'team')
+ ->where('team_id', $teamId)
+ ->whereIn('key', $keys)
+ ->get()
+ );
}
/**
@@ -359,7 +586,7 @@ class ServerTransferExporter
{
try {
$material = $key->private_key;
- } catch (\Throwable $e) {
+ } 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 '
@@ -395,6 +622,9 @@ class ServerTransferExporter
// Read through cast so encrypted fields are plaintext for re-encryption on import.
$settingsPayload[$key] = $settings->{$key};
}
+ $settingsPayload['cloudflare_tunnel_integration_token_uuid'] = $settings->cloudflare_tunnel_integration_token_id
+ ? IntegrationToken::query()->whereKey($settings->cloudflare_tunnel_integration_token_id)->value('uuid')
+ : null;
}
return [
@@ -402,12 +632,18 @@ class ServerTransferExporter
'name' => $server->name,
'description' => $server->description,
'ip' => (string) $server->ip,
- 'port' => (int) $server->port,
+ 'port' => $server->port ?: 22,
'user' => (string) $server->user,
'proxy' => $server->proxy?->toArray() ?? [],
'server_role' => $server->settings->effectiveServerRole()->value,
'is_build_server' => $server->isBuildServer(),
'cloud_provider_token_uuid' => $server->cloudProviderToken?->uuid,
+ 'hetzner_server_id' => $server->hetzner_server_id,
+ 'vultr_instance_id' => $server->vultr_instance_id,
+ 'digitalocean_droplet_id' => $server->digitalocean_droplet_id,
+ 'hostinger_virtual_machine_id' => $server->hostinger_virtual_machine_id,
+ 'proxy_error_page' => $server->proxy_error_page,
+ 'swarm_cluster' => $server->swarm_cluster,
'settings' => $settingsPayload,
];
}
@@ -443,29 +679,37 @@ class ServerTransferExporter
* @param Collection $databases
* @param Collection $services
* @param array $destinationUuidByKey
+ * @param list $warnings
* @return array
*/
private function exportProject(
Project $project,
- Server $server,
array $destinationUuidByKey,
Collection $applications,
Collection $databases,
Collection $services,
+ array &$warnings,
+ int &$leftOutLinks,
): array {
- $environments = $project->environments()->orderBy('name')->get();
+ // Only environments with a resource of this server. The other environments stay on this instance.
+ $environmentIds = $applications->pluck('environment_id')
+ ->merge($databases->pluck('environment_id'))
+ ->merge($services->pluck('environment_id'))
+ ->unique();
+ $environments = $project->environments()->whereIn('id', $environmentIds)->orderBy('name')->get();
return [
'uuid' => $project->uuid,
'name' => $project->name,
'description' => $project->description,
+ 'icon' => $this->exportProjectIcon($project, $warnings),
'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) {
+ 'environments' => $environments->map(function (Environment $environment) use ($destinationUuidByKey, $applications, $databases, $services, &$leftOutLinks) {
$envApps = $applications->where('environment_id', $environment->id)->values();
$envDbs = $databases->where('environment_id', $environment->id)->values();
$envServices = $services->where('environment_id', $environment->id)->values();
@@ -474,6 +718,10 @@ class ServerTransferExporter
'uuid' => $environment->uuid,
'name' => $environment->name,
'description' => $environment->description,
+ // Keyed by resource uuid; only the positions of exported resources.
+ 'canvas_positions' => collect($environment->canvas_positions ?? [])
+ ->only($envApps->pluck('uuid')->merge($envDbs->pluck('uuid'))->merge($envServices->pluck('uuid')))
+ ->all(),
'shared_environment_variables' => $this->exportSharedEnvVars(
SharedEnvironmentVariable::query()
->where('type', 'environment')
@@ -483,11 +731,105 @@ class ServerTransferExporter
'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(),
+ 'resource_links' => $this->exportResourceLinks($environment, $envApps, $envDbs, $envServices, $leftOutLinks),
];
})->values()->all(),
];
}
+ /**
+ * The icon file travels in the bundle; the target stores it in its own icon storage.
+ *
+ * @param list $warnings
+ * @return array{content_base64: string, extension: string, mime: string}|null
+ */
+ private function exportProjectIcon(Project $project, array &$warnings): ?array
+ {
+ if (blank($project->icon_path)) {
+ return null;
+ }
+
+ try {
+ $contents = app(ProjectIconStorageService::class)->projectContents($project);
+ } catch (Throwable) {
+ $contents = null;
+ }
+ if (! is_string($contents) || $contents === '') {
+ $warnings[] = "The icon of project {$project->name} cannot be read, so it is not transferred. Upload it again on the target instance.";
+
+ return null;
+ }
+ if (strlen($contents) > self::MAX_PROJECT_ICON_BYTES) {
+ $warnings[] = "The icon of project {$project->name} is larger than 2 MB, so it is not transferred. Upload it again on the target instance.";
+
+ return null;
+ }
+
+ return [
+ 'content_base64' => base64_encode($contents),
+ 'extension' => strtolower(pathinfo($project->icon_path, PATHINFO_EXTENSION)) ?: 'jpg',
+ 'mime' => (new \finfo(FILEINFO_MIME_TYPE))->buffer($contents) ?: 'application/octet-stream',
+ ];
+ }
+
+ /**
+ * Canvas links whose two resources are both exported. Each end is the kind and uuid of a resource of the
+ * environment, the same keys that the import uses for its resource maps.
+ *
+ * @param Collection $applications
+ * @param Collection $databases
+ * @param Collection $services
+ * @return list
+ */
+ private function exportResourceLinks(Environment $environment, Collection $applications, Collection $databases, Collection $services, int &$leftOut): array
+ {
+ $ends = collect();
+ foreach (['application' => $applications, 'database' => $databases, 'service' => $services] as $kind => $resources) {
+ foreach ($resources as $resource) {
+ $ends->put($resource->getMorphClass().':'.$resource->getKey(), ['kind' => $kind, 'uuid' => $resource->uuid]);
+ }
+ }
+
+ $out = [];
+ foreach ($environment->resourceLinks()->orderBy('id')->get() as $link) {
+ $source = $ends->get($link->source_type.':'.$link->source_id);
+ $target = $ends->get($link->target_type.':'.$link->target_id);
+ if (! $source || ! $target) {
+ $leftOut++;
+
+ continue;
+ }
+ $out[] = ['uuid' => $link->uuid, 'label' => $link->label, 'source' => $source, 'target' => $target];
+ }
+
+ return $out;
+ }
+
+ /**
+ * The target imports the config disabled. Enabling it there removes the runner containers of the source.
+ *
+ * @return array
+ */
+ private function exportGithubRunnerConfig(GithubRunnerConfig $config): array
+ {
+ return [
+ 'uuid' => $config->uuid,
+ 'github_app_uuid' => $config->githubApp?->uuid,
+ 'labels' => $config->labels ?? [],
+ 'is_enabled' => (bool) $config->is_enabled,
+ 'is_dedicated' => (bool) $config->is_dedicated,
+ 'allow_pull_requests' => (bool) $config->allow_pull_requests,
+ 'max_runners' => $config->max_runners,
+ 'docker_mode' => $config->docker_mode?->value,
+ 'runner_image' => $config->runner_image,
+ 'cpu_limit' => $config->cpu_limit,
+ 'memory_limit' => $config->memory_limit,
+ 'capacity_wait_timeout' => $config->capacity_wait_timeout,
+ 'idle_timeout' => $config->idle_timeout,
+ 'job_timeout' => $config->job_timeout,
+ ];
+ }
+
/**
* @param array $destinationUuidByKey
* @return array
@@ -530,6 +872,8 @@ class ServerTransferExporter
'docker_compose',
'docker_compose_raw',
'custom_labels',
+ 'is_maintenance_enabled',
+ 'maintenance_page',
] as $hidden) {
if (in_array($hidden, $columns, true)) {
$attrs[$hidden] = $application->{$hidden};
@@ -550,9 +894,9 @@ class ServerTransferExporter
$source = null;
if ($application->source_type === GithubApp::class && $application->source) {
- $source = ['type' => 'github_app', 'uuid' => $application->source->uuid];
+ $source = ['type' => 'github_app', 'uuid' => $application->source->uuid, 'system_wide' => (bool) $application->source->is_system_wide];
} elseif ($application->source_type === GitlabApp::class && $application->source) {
- $source = ['type' => 'gitlab_app', 'uuid' => $application->source->uuid];
+ $source = ['type' => 'gitlab_app', 'uuid' => $application->source->uuid, 'system_wide' => (bool) $application->source->is_system_wide];
}
return [
@@ -568,6 +912,7 @@ class ServerTransferExporter
'scheduled_tasks' => $this->exportScheduledTasks($application->scheduled_tasks()->get()),
'tags' => $this->exportTags($application->tags),
'previews' => $this->exportPreviews($application->previews),
+ 'secret_manager_link' => $this->exportSecretManagerLink($application),
'source' => $source,
'private_key_uuid' => $application->private_key?->uuid,
'had_git_source' => filled($application->source_id),
@@ -635,6 +980,7 @@ class ServerTransferExporter
'scheduled_backups' => method_exists($database, 'scheduledBackups')
? $this->exportScheduledBackups($database->scheduledBackups)
: [],
+ 'secret_manager_link' => $this->exportSecretManagerLink($database),
];
}
@@ -668,8 +1014,8 @@ class ServerTransferExporter
$attrs[$field] = $service->{$field};
}
- // Hidden compose fields
- foreach (['docker_compose', 'docker_compose_raw'] as $hidden) {
+ // Hidden compose fields and maintenance mode (not fillable)
+ foreach (['docker_compose', 'docker_compose_raw', 'is_maintenance_enabled', 'maintenance_page'] as $hidden) {
if (in_array($hidden, $columns, true)) {
$attrs[$hidden] = $service->{$hidden};
}
@@ -685,6 +1031,7 @@ class ServerTransferExporter
'environment_variables' => $this->exportEnvVars($service->environment_variables()->get()),
'scheduled_tasks' => $this->exportScheduledTasks($service->scheduled_tasks()->get()),
'tags' => $this->exportTags($service->tags),
+ 'secret_manager_link' => $this->exportSecretManagerLink($service),
'applications' => $service->applications
->map(fn ($app) => $this->exportServiceApplication($app))
->values()
@@ -861,8 +1208,11 @@ class ServerTransferExporter
'is_shared' => (bool) $variable->is_shared,
'is_required' => (bool) ($variable->is_required ?? false),
'uses_legacy_escaping' => (bool) $variable->uses_legacy_escaping,
+ 'preserve_compose_null' => (bool) $variable->preserve_compose_null,
'comment' => $variable->comment,
'order' => $variable->order,
+ // The Coolify version that created the variable.
+ 'version' => $variable->version,
];
}
@@ -979,6 +1329,7 @@ class ServerTransferExporter
'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),
+ 'missing_backup_notification_days' => $backup->missing_backup_notification_days,
's3_storage_uuid' => $backup->s3?->uuid,
's3_storage_uuids' => $backup->selectedS3Storages()->pluck('uuid')->values()->all(),
'had_s3_storage' => filled($backup->s3_storage_id),
@@ -1012,6 +1363,10 @@ class ServerTransferExporter
'metadata' => $app->metadata,
'pull_requests' => $app->pull_requests,
'administration' => $app->administration,
+ 'organization_self_hosted_runners' => $app->organization_self_hosted_runners,
+ 'actions' => $app->actions,
+ 'webhook_events' => $app->webhook_events,
+ 'runner_group_id' => $app->runner_group_id,
'private_key_uuid' => $app->privateKey?->uuid,
'webhook_url_hint' => '/webhooks/source/github/events',
];
@@ -1082,6 +1437,85 @@ class ServerTransferExporter
];
}
+ /**
+ * The token is read through its cast, so the bundle has it in plaintext like the other credentials.
+ *
+ * @return array
+ */
+ private function exportIntegrationToken(IntegrationToken $token): array
+ {
+ return [
+ 'uuid' => $token->uuid,
+ 'provider' => $token->provider,
+ 'name' => $token->name,
+ 'token' => $token->token,
+ 'capabilities' => $token->capabilities,
+ 'metadata' => $token->metadata,
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ private function exportDnsZone(DnsProviderZone $zone): array
+ {
+ return [
+ 'uuid' => $zone->uuid,
+ 'integration_token_uuid' => $zone->integrationToken->uuid,
+ 'provider_zone_id' => $zone->provider_zone_id,
+ 'name' => $zone->name,
+ 'account_id' => $zone->account_id,
+ 'account_name' => $zone->account_name,
+ ];
+ }
+
+ /**
+ * The ownership comment proves at the provider that this instance created the record. The target compares it
+ * with the remote comment before it takes ownership.
+ *
+ * @param array $resourceKeys
+ * @return array
+ */
+ private function exportManagedDnsRecord(ManagedDnsRecord $record, array $resourceKeys): array
+ {
+ return [
+ 'uuid' => $record->uuid,
+ 'integration_token_uuid' => $record->integrationToken->uuid,
+ 'dns_provider_zone_uuid' => $record->zone->uuid,
+ 'provider_record_id' => $record->provider_record_id,
+ 'type' => $record->type,
+ 'name' => $record->name,
+ 'content' => $record->content,
+ 'owned' => (bool) $record->owned,
+ 'ownership_comment' => $record->ownershipComment(),
+ 'references' => $record->references
+ ->map(fn (ManagedDnsRecordReference $reference) => $resourceKeys[$reference->resource_type.':'.$reference->resource_id] ?? null)
+ ->filter()
+ ->values()
+ ->all(),
+ ];
+ }
+
+ /**
+ * @return array{uuid: string, integration_token_uuid: string, settings: array|null}|null
+ */
+ private function exportSecretManagerLink(Model $resource): ?array
+ {
+ if (! method_exists($resource, 'secretManagerLink')) {
+ return null;
+ }
+ $link = $resource->secretManagerLink()->with('integrationToken')->first();
+ if (! $link) {
+ return null;
+ }
+
+ return [
+ 'uuid' => $link->uuid,
+ 'integration_token_uuid' => $link->integrationToken->uuid,
+ 'settings' => $link->settings,
+ ];
+ }
+
/**
* @return list>
*/
@@ -1138,6 +1572,8 @@ class ServerTransferExporter
'git_type' => $preview->git_type,
'docker_compose_domains' => $preview->docker_compose_domains,
'docker_registry_image_tag' => $preview->docker_registry_image_tag,
+ 'domain_port_overrides' => $preview->domain_port_overrides,
+ 'max_restart_count' => $preview->max_restart_count,
'persistent_storages' => method_exists($preview, 'persistentStorages')
? $this->exportPersistentStorages($preview->persistentStorages)
: [],
@@ -1192,6 +1628,7 @@ class ServerTransferExporter
'retention_days_s3' => $backup->retention_days_s3,
'retention_max_storage_s3' => $backup->retention_max_storage_s3,
'timeout' => $backup->timeout,
+ 'missing_backup_notification_days' => $backup->missing_backup_notification_days,
's3_storage_uuid' => $backup->s3?->uuid,
's3_storage_uuids' => $backup->selectedS3Storages()->pluck('uuid')->values()->all(),
'had_s3_storage' => filled($backup->s3_storage_id),
diff --git a/app/Services/ServerTransfer/ServerTransferImporter.php b/app/Services/ServerTransfer/ServerTransferImporter.php
index 4c175fc8af..80cf44efd3 100644
--- a/app/Services/ServerTransfer/ServerTransferImporter.php
+++ b/app/Services/ServerTransfer/ServerTransferImporter.php
@@ -2,22 +2,30 @@
namespace App\Services\ServerTransfer;
+use App\Enums\GithubRunnerDockerMode;
use App\Enums\ServerRole;
+use App\Jobs\ValidateAndInstallServerJob;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\CloudProviderToken;
+use App\Models\DnsProviderZone;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\GithubApp;
+use App\Models\GithubRunnerConfig;
use App\Models\GitlabApp;
+use App\Models\IntegrationToken;
use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
+use App\Models\ManagedDnsRecord;
use App\Models\PrivateKey;
use App\Models\Project;
+use App\Models\ResourceLink;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledTask;
use App\Models\ScheduledVolumeBackup;
+use App\Models\SecretManagerLink;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
@@ -37,9 +45,18 @@ use App\Models\StandaloneSqlite;
use App\Models\SwarmDocker;
use App\Models\Tag;
use App\Models\Team;
+use App\Notifications\Internal\GeneralNotification;
+use App\Rules\DockerImageFormat;
+use App\Rules\ValidServerIp;
+use App\Services\Dns\CloudflareDnsProvider;
+use App\Services\ProjectIconStorageService;
+use App\Support\ValidationPatterns;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Schema;
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use RuntimeException;
use Throwable;
@@ -76,15 +93,43 @@ class ServerTransferImporter
/** @var array */
private array $cloudTokenMap = [];
+ /** @var array */
+ private array $integrationTokenMap = [];
+
+ /** @var array */
+ private array $dnsZoneMap = [];
+
+ /**
+ * Imported owned DNS records that carry the ownership comment of the source instance. This instance takes
+ * ownership at the provider after the import transaction commits.
+ *
+ * @var list
+ */
+ private array $pendingDnsAdoptions = [];
+
+ /**
+ * Icons of imported projects. They are stored after the import transaction commits, so a storage error
+ * cannot roll back DB rows.
+ *
+ * @var list}>
+ */
+ private array $pendingProjectIcons = [];
+
/** @var array */
private array $volumeMap = [];
/** @var array */
private array $applicationMap = [];
+ /** @var array */
+ private array $previewMap = [];
+
/** @var array */
private array $serviceMap = [];
+ /** @var array */
+ private array $serviceApplicationMap = [];
+
/** @var array */
private array $databaseMap = [];
@@ -95,6 +140,21 @@ class ServerTransferImporter
*/
private array $pendingSqliteLinks = [];
+ /**
+ * Warnings about references of imported resources that this instance cannot resolve.
+ *
+ * @var list
+ */
+ private array $importWarnings = [];
+
+ /**
+ * Applications whose git source needs a check after the transfer: a system-wide source of the source
+ * instance, or a source that this instance does not have.
+ *
+ * @var list
+ */
+ private array $gitSourceApplications = [];
+
/**
* @param array $bundle
* @return array{
@@ -106,7 +166,8 @@ class ServerTransferImporter
* preserved_uuids: bool,
* export_id: string|null,
* claimed: bool,
- * claim: array|null
+ * claim: array|null,
+ * checklist: list
* }
*/
public function import(
@@ -117,6 +178,13 @@ class ServerTransferImporter
bool $adoptMode = true,
): array {
ServerTransferBundle::assertValid($bundle);
+ $this->assertValidServerPayload((array) data_get($bundle, 'server', []));
+
+ if ($adoptMode && ! $preserveUuids) {
+ throw ValidationException::withMessages([
+ 'preserve_uuids' => ['Adopt mode keeps the running containers, so it needs the original UUIDs. Import with preserve_uuids=true.'],
+ ]);
+ }
$validation = ServerTransferBundle::validate($bundle);
$warnings = $validation['warnings'];
@@ -127,27 +195,20 @@ class ServerTransferImporter
$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', Server::normalizeIp($serverIp))->first();
- $ipConflict = $existingIp !== null;
+ // A server that comes back replaces the archived copy that this instance kept from the first transfer.
+ $archive = $preserveUuids ? $this->replaceableArchive($serverUuid, $teamId) : null;
+ $uuidConflict = $archive === null && $preserveUuids && Server::withTrashed()->where('uuid', $serverUuid)->exists();
+ $ipConflict = Server::where('ip', Server::normalizeIp($serverIp))->when($archive, fn ($query) => $query->whereKeyNot($archive->id))->exists();
- if (! $dryRun && $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 (! $dryRun) {
+ $this->assertCanImport($serverUuid, $serverIp, $teamId, $preserveUuids);
}
if ($uuidConflict) {
$warnings[] = "A server with UUID {$serverUuid} already exists on this instance.";
}
if ($ipConflict) {
- $warnings[] = "A server with IP/domain {$serverIp} already exists (uuid={$existingIp->uuid}).";
+ $warnings[] = "A server with IP/domain {$serverIp} already exists on this instance.";
}
$created = [
@@ -162,6 +223,8 @@ class ServerTransferImporter
'gitlab_apps' => 0,
's3_storages' => 0,
'cloud_provider_tokens' => 0,
+ 'integration_tokens' => 0,
+ 'managed_dns_records' => 0,
'ssl_certificates' => 0,
'volume_backups' => 0,
'previews' => 0,
@@ -189,18 +252,11 @@ class ServerTransferImporter
$created['gitlab_apps'] = count(data_get($bundle, 'gitlab_apps', []));
$created['s3_storages'] = count(data_get($bundle, 's3_storages', []));
$created['cloud_provider_tokens'] = count(data_get($bundle, 'cloud_provider_tokens', []));
+ $created['integration_tokens'] = count(data_get($bundle, 'integration_tokens', []));
+ $created['managed_dns_records'] = count(data_get($bundle, 'managed_dns_records', []));
$created['ssl_certificates'] = count(data_get($bundle, 'ssl_certificates', []));
$created['volume_backups'] = count(data_get($bundle, 'volume_backups', []));
- // 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,
@@ -211,10 +267,15 @@ class ServerTransferImporter
'export_id' => data_get($bundle, 'export_id'),
'claimed' => false,
'claim' => null,
+ 'checklist' => $this->postTransferChecklist($bundle),
];
}
- $result = DB::transaction(function () use ($bundle, $teamId, $preserveUuids, $adoptMode, $warnings, &$created) {
+ $result = DB::transaction(function () use ($bundle, $teamId, $preserveUuids, $adoptMode, $warnings, $archive, &$created) {
+ // Removed in the same transaction as the import, so a failed import keeps the archive.
+ if ($archive) {
+ app(ServerTransferClaimer::class)->deleteArchive($archive);
+ }
Team::ensureServerCapacity($teamId);
$this->privateKeyMap = [];
@@ -222,11 +283,19 @@ class ServerTransferImporter
$this->gitlabAppMap = [];
$this->s3StorageMap = [];
$this->cloudTokenMap = [];
+ $this->integrationTokenMap = [];
+ $this->dnsZoneMap = [];
+ $this->pendingDnsAdoptions = [];
+ $this->pendingProjectIcons = [];
$this->volumeMap = [];
$this->applicationMap = [];
+ $this->previewMap = [];
$this->serviceMap = [];
+ $this->serviceApplicationMap = [];
$this->databaseMap = [];
$this->pendingSqliteLinks = [];
+ $this->importWarnings = [];
+ $this->gitSourceApplications = [];
// Import shared dependencies first
$keyPayloads = data_get($bundle, 'private_keys', []);
@@ -276,6 +345,13 @@ class ServerTransferImporter
$created['cloud_provider_tokens']++;
}
+ foreach (data_get($bundle, 'integration_tokens', []) as $tokenPayload) {
+ $token = $this->importIntegrationToken($tokenPayload, $teamId, $preserveUuids);
+ $this->integrationTokenMap[(string) data_get($tokenPayload, 'uuid', $token->uuid)] = $token;
+ $this->integrationTokenMap[$token->uuid] = $token;
+ $created['integration_tokens']++;
+ }
+
$serverKeyUuid = (string) data_get($bundle, 'private_key.uuid', '');
$privateKey = $this->privateKeyMap[$serverKeyUuid]
?? $this->importPrivateKey(data_get($bundle, 'private_key', []), $teamId, $preserveUuids);
@@ -283,8 +359,10 @@ class ServerTransferImporter
$server = $this->importServer(data_get($bundle, 'server', []), $privateKey, $teamId, $preserveUuids, $adoptMode);
$destinationMap = $this->importDestinations(data_get($bundle, 'destinations', []), $server, $preserveUuids);
$created['destinations'] = count($destinationMap);
+ $this->importGithubRunnerConfig(data_get($bundle, 'github_runner_config'), $server, $teamId, $preserveUuids);
$this->importServerSharedEnvVars(data_get($bundle, 'shared_environment_variables.server', []), $server, $teamId);
+ $this->importTeamSharedEnvVars(data_get($bundle, 'shared_environment_variables.team', []), $teamId);
foreach (data_get($bundle, 'projects', []) as $projectPayload) {
$project = $this->importProject($projectPayload, $teamId, $preserveUuids, $created);
@@ -311,21 +389,20 @@ class ServerTransferImporter
$this->serviceMap[(string) data_get($servicePayload, 'uuid', $service->uuid)] = $service;
$created['services']++;
}
+
+ $this->importCanvasPositions((array) data_get($environmentPayload, 'canvas_positions', []), $environment);
+ $this->importResourceLinks((array) data_get($environmentPayload, 'resource_links', []), $environment, $preserveUuids);
}
}
$warnings = array_merge($warnings, $this->linkSqliteVolumes());
+ $this->importDnsZones(data_get($bundle, 'dns_zones', []), $preserveUuids);
+ $created['managed_dns_records'] = $this->importManagedDnsRecords(data_get($bundle, 'managed_dns_records', []), $teamId, $preserveUuids);
+
$created['ssl_certificates'] = $this->importSslCertificates(data_get($bundle, 'ssl_certificates', []), $server);
$created['volume_backups'] = $this->importVolumeBackups(data_get($bundle, 'volume_backups', []), $teamId);
-
- $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.";
- }
+ $warnings = array_merge($warnings, $this->importWarnings);
$metadata = $server->server_metadata ?? [];
$metadata['transfer'] = [
@@ -334,6 +411,7 @@ class ServerTransferImporter
'source_instance_url' => data_get($bundle, 'source_instance.url'),
'imported_at' => now()->toIso8601String(),
'adopt_mode' => $adoptMode,
+ 'git_source_applications' => array_values(array_unique($this->gitSourceApplications)),
];
$server->server_metadata = $metadata;
$server->save();
@@ -348,9 +426,13 @@ class ServerTransferImporter
'export_id' => data_get($bundle, 'export_id'),
'claimed' => false,
'claim' => null,
+ 'checklist' => $this->postTransferChecklist($bundle),
];
});
+ // Provider and storage calls run after the import transaction commits, so an error cannot roll back DB rows.
+ $result['warnings'] = array_values(array_unique(array_merge($result['warnings'], $this->adoptManagedDnsRecords(), $this->storeProjectIcons())));
+
// Claim after the import transaction commits so a claim failure cannot roll back DB rows.
if (filled(data_get($result, 'server_uuid'))) {
$server = Server::where('uuid', $result['server_uuid'])->where('team_id', $teamId)->first();
@@ -358,6 +440,10 @@ class ServerTransferImporter
try {
$result['claim'] = app(ServerTransferClaimer::class)->claim($server);
$result['claimed'] = true;
+ // Same check as the Validate button. A running proxy and Docker are kept as they are.
+ ValidateAndInstallServerJob::dispatch($server);
+ $this->startFinishChecklist($server);
+ $result['server_url'] = rtrim((string) (instanceSettings()->fqdn ?: config('app.url')), '/').'/server/'.$server->uuid;
} catch (Throwable $e) {
$result['claimed'] = false;
$result['claim'] = null;
@@ -369,6 +455,130 @@ class ServerTransferImporter
return $result;
}
+ /**
+ * Check the server connection fields with the same rules as the create server API.
+ *
+ * @param array $server
+ */
+ private function assertValidServerPayload(array $server): void
+ {
+ Validator::make($server, [
+ 'name' => 'nullable|string|max:255',
+ 'ip' => ['required', 'string', new ValidServerIp],
+ 'port' => 'required|integer|between:1,65535',
+ 'user' => ValidationPatterns::serverUsernameRules(required: false),
+ ], [], [
+ 'ip' => 'server.ip',
+ 'port' => 'server.port',
+ 'user' => 'server.user',
+ ])->validate();
+ }
+
+ /**
+ * Checks that a server can be imported into the team, without the bundle. Every check runs, so the
+ * source instance can show which checks are OK and which failed.
+ *
+ * @return list
+ */
+ public function importChecks(string $serverUuid, string $serverIp, int $teamId, bool $preserveUuids = true): array
+ {
+ $team = Team::find($teamId);
+ $archive = $preserveUuids ? $this->replaceableArchive($serverUuid, $teamId) : null;
+ // The archive is replaced, so the import does not add a server.
+ $limitReached = ! $team || ($archive === null && Team::serverLimitReached($team));
+
+ $existingServer = $preserveUuids && $archive === null ? Server::withTrashed()->where('uuid', $serverUuid)->first() : null;
+ $uuidMessage = match (true) {
+ $existingServer === null => null,
+ $existingServer->trashed() => "The server with UUID {$serverUuid} is still being deleted on this instance. Wait a minute, then transfer again.",
+ $existingServer->isTransferredAway() => "Another team of this instance has the archived copy of server {$existingServer->name}. Transfer the server into that team, or delete the archive there first.",
+ default => "A server with UUID {$serverUuid} already exists on this instance. Delete/transfer it first, or import with preserve_uuids=false.",
+ };
+
+ $ipExists = Server::where('ip', Server::normalizeIp($serverIp))->when($archive, fn ($query) => $query->whereKeyNot($archive->id))->exists();
+
+ return [
+ [
+ 'field' => 'server',
+ 'label' => 'the team can have one more server',
+ 'ok' => ! $limitReached,
+ 'message' => $limitReached ? 'Server limit reached for your subscription.' : null,
+ ],
+ [
+ 'field' => 'server.uuid',
+ 'label' => $archive ? 'the archived copy of this server on this instance is replaced' : 'no server with the same UUID exists',
+ 'ok' => $uuidMessage === null,
+ 'message' => $uuidMessage,
+ ],
+ [
+ 'field' => 'server.ip',
+ 'label' => 'no server with the same IP exists',
+ 'ok' => ! $ipExists,
+ 'message' => $ipExists ? "A server with IP/domain {$serverIp} already exists on this instance. Remove it first." : null,
+ ],
+ ];
+ }
+
+ /**
+ * The steps that finish the transfer here, and one notification that points the team to them.
+ * A failure here never fails the import: the server is imported and claimed already.
+ */
+ private function startFinishChecklist(Server $server): void
+ {
+ try {
+ $items = app(TransferFinishChecklist::class)->start($server);
+ $server->team?->notify(new GeneralNotification(
+ "Server '{$server->name}' was transferred to this instance. Open the server to finish the transfer: ".count($items).' steps to check.'
+ ));
+ } catch (Throwable $e) {
+ Log::warning('Could not start the transfer finish checklist', ['server_uuid' => $server->uuid, 'error' => $e->getMessage()]);
+ }
+ }
+
+ /**
+ * The archive that this instance kept when it transferred the server away. A transfer back into the same team
+ * replaces it.
+ */
+ private function replaceableArchive(string $serverUuid, int $teamId): ?Server
+ {
+ $server = Server::query()->where('uuid', $serverUuid)->where('team_id', $teamId)->first();
+
+ return $server?->isTransferredAway() ? $server : null;
+ }
+
+ /**
+ * @throws ValidationException with the first failed import check
+ */
+ public function assertCanImport(string $serverUuid, string $serverIp, int $teamId, bool $preserveUuids = true): void
+ {
+ foreach ($this->importChecks($serverUuid, $serverIp, $teamId, $preserveUuids) as $check) {
+ if (! $check['ok']) {
+ throw ValidationException::withMessages([$check['field'] => [$check['message']]]);
+ }
+ }
+ }
+
+ /**
+ * Running containers are found by the resource UUID. In adopt mode a new UUID would leave them
+ * without a resource, so a conflict stops the import instead.
+ *
+ * @param class-string $modelClass
+ */
+ private function resolveResourceUuid(string $modelClass, string $uuid, bool $adoptMode, string $label): string
+ {
+ if (! $modelClass::withTrashed()->where('uuid', $uuid)->exists()) {
+ return $uuid;
+ }
+
+ if ($adoptMode) {
+ throw ValidationException::withMessages([
+ 'bundle' => ["The {$label} UUID {$uuid} is already used on this instance. Delete that {$label} permanently first, because adopt mode needs the same UUID to keep the running containers."],
+ ]);
+ }
+
+ return new_public_id();
+ }
+
/**
* @param array $payload
*/
@@ -450,6 +660,12 @@ class ServerTransferImporter
'private_key_id' => $privateKey->id,
'team_id' => $teamId,
'cloud_provider_token_id' => $cloudTokenId,
+ 'hetzner_server_id' => data_get($payload, 'hetzner_server_id'),
+ 'vultr_instance_id' => data_get($payload, 'vultr_instance_id'),
+ 'digitalocean_droplet_id' => data_get($payload, 'digitalocean_droplet_id'),
+ 'hostinger_virtual_machine_id' => data_get($payload, 'hostinger_virtual_machine_id'),
+ 'proxy_error_page' => data_get($payload, 'proxy_error_page'),
+ 'swarm_cluster' => data_get($payload, 'swarm_cluster'),
]);
$server->uuid = $uuid;
$server->save();
@@ -479,9 +695,16 @@ class ServerTransferImporter
'id', 'server_id', 'created_at', 'updated_at',
'is_reachable', 'is_usable', 'force_disabled',
'sentinel_token', 'sentinel_custom_url',
+ // A foreign key of the source instance; older bundles still contain it.
+ 'cloudflare_tunnel_integration_token_id',
+ 'cloudflare_tunnel_integration_token_uuid',
])
->all();
$server->settings->fill($safe);
+ $server->settings->cloudflare_tunnel_integration_token_id = $this->importedIntegrationTokenId(
+ data_get($settingsPayload, 'cloudflare_tunnel_integration_token_uuid'),
+ 'The Cloudflare tunnel of this server: its Cloudflare API token was not found on this instance; select it again in the Cloudflare tunnel settings.',
+ );
// Imported servers start not validated; claim will rebind sentinel.
$server->settings->is_reachable = false;
$server->settings->is_usable = false;
@@ -593,6 +816,38 @@ class ServerTransferImporter
}
}
+ /**
+ * A team variable that already exists keeps the value of this instance.
+ *
+ * @param list> $variables
+ */
+ private function importTeamSharedEnvVars(array $variables, int $teamId): void
+ {
+ foreach ($variables as $variable) {
+ $key = (string) data_get($variable, 'key');
+ $exists = SharedEnvironmentVariable::query()
+ ->where('type', 'team')
+ ->where('team_id', $teamId)
+ ->where('key', $key)
+ ->exists();
+ if ($exists) {
+ $this->importWarnings[] = "Team shared variable {$key} already exists on this instance. It keeps its value here, which can differ from the value on the source instance.";
+
+ continue;
+ }
+ SharedEnvironmentVariable::create([
+ 'key' => $key,
+ 'value' => data_get($variable, 'value'),
+ 'is_multiline' => (bool) data_get($variable, 'is_multiline', false),
+ 'is_literal' => (bool) data_get($variable, 'is_literal', false),
+ 'is_shown_once' => (bool) data_get($variable, 'is_shown_once', false),
+ 'comment' => data_get($variable, 'comment'),
+ 'type' => 'team',
+ 'team_id' => $teamId,
+ ]);
+ }
+ }
+
/**
* @param array $payload
* @param array $created
@@ -603,9 +858,14 @@ class ServerTransferImporter
? (string) data_get($payload, 'uuid')
: new_public_id();
+ $icon = data_get($payload, 'icon');
$existing = Project::where('uuid', $uuid)->where('team_id', $teamId)->first();
if ($existing) {
$this->importProjectSharedEnvVars(data_get($payload, 'shared_environment_variables', []), $existing, $teamId);
+ // An icon that the project already has on this instance is kept.
+ if (is_array($icon) && blank($existing->icon_path)) {
+ $this->pendingProjectIcons[] = ['project' => $existing, 'icon' => $icon];
+ }
return $existing;
}
@@ -624,7 +884,124 @@ class ServerTransferImporter
$this->importProjectSharedEnvVars(data_get($payload, 'shared_environment_variables', []), $project, $teamId);
- return $project->fresh(['environments']);
+ $project = $project->fresh(['environments']);
+ if (is_array($icon)) {
+ $this->pendingProjectIcons[] = ['project' => $project, 'icon' => $icon];
+ }
+
+ return $project;
+ }
+
+ /**
+ * Stores the icons of the imported projects in the icon storage of this instance. Coolify stores every
+ * icon as a JPEG file and serves it as one, so other content is not stored.
+ *
+ * @return list warnings
+ */
+ private function storeProjectIcons(): array
+ {
+ $warnings = [];
+ foreach ($this->pendingProjectIcons as ['project' => $project, 'icon' => $icon]) {
+ $contents = base64_decode((string) data_get($icon, 'content_base64', ''), true);
+ $problem = match (true) {
+ ! is_string($contents) || $contents === '' => 'its content is not valid',
+ strlen($contents) > ServerTransferExporter::MAX_PROJECT_ICON_BYTES => 'it is larger than 2 MB',
+ (new \finfo(FILEINFO_MIME_TYPE))->buffer($contents) !== 'image/jpeg' => 'it is not a JPEG image',
+ default => null,
+ };
+ if ($problem === null) {
+ try {
+ app(ProjectIconStorageService::class)->storeProjectContents($project, $contents);
+
+ continue;
+ } catch (Throwable) {
+ $problem = 'the icon storage of this instance is not available';
+ }
+ }
+ $warnings[] = "The icon of project {$project->name} was not imported, because {$problem}. Upload it again in the project settings.";
+ }
+ $this->pendingProjectIcons = [];
+
+ return $warnings;
+ }
+
+ /**
+ * Manual steps after the transfer, with the URL of this instance in place of {target_url}.
+ *
+ * @param array $bundle
+ * @return list
+ */
+ private function postTransferChecklist(array $bundle): array
+ {
+ $targetUrl = rtrim((string) (instanceSettings()->fqdn ?: config('app.url')), '/');
+
+ return array_values(array_unique(array_map(
+ fn (string $step) => str_replace('{target_url}', $targetUrl, $step),
+ array_filter((array) data_get($bundle, 'post_transfer_checklist', []), 'is_string'),
+ )));
+ }
+
+ /**
+ * The config is imported disabled: an enabled config makes this instance remove every runner container on
+ * the server that it does not know, also the runners that the source started. The user enables it after
+ * the GitHub App sends its webhooks here.
+ */
+ private function importGithubRunnerConfig(mixed $payload, Server $server, int $teamId, bool $preserveUuids): void
+ {
+ if (! is_array($payload)) {
+ return;
+ }
+
+ $appUuid = (string) data_get($payload, 'github_app_uuid', '');
+ $githubApp = $appUuid === '' ? null : ($this->githubAppMap[$appUuid]
+ ?? GithubApp::query()->where('uuid', $appUuid)->where('is_system_wide', true)->first()
+ ?? GithubApp::query()->where('uuid', $appUuid)->where('team_id', $teamId)->first());
+ if (! $githubApp) {
+ $this->importWarnings[] = 'The GitHub runner settings of this server were not imported, because their GitHub App was not found on this instance. Set up GitHub runners on the server again.';
+
+ return;
+ }
+
+ $validator = Validator::make($payload, [
+ 'labels' => ['required', 'array', 'min:1'],
+ 'labels.*' => ['string', 'regex:/\A[a-z0-9][a-z0-9._-]{0,99}\z/'],
+ 'max_runners' => ['required', 'integer', 'min:1', 'max:32'],
+ 'docker_mode' => ['required', Rule::enum(GithubRunnerDockerMode::class)],
+ 'runner_image' => ['nullable', 'string', 'max:255', new DockerImageFormat],
+ 'cpu_limit' => ['nullable', 'string', 'regex:/\A\d+(\.\d+)?\z/'],
+ 'memory_limit' => ['nullable', 'string', 'regex:/\A\d+[bkmg]?\z/i'],
+ 'capacity_wait_timeout' => ['required', 'integer', 'min:1', 'max:1440'],
+ 'idle_timeout' => ['required', 'integer', 'min:1', 'max:1440'],
+ 'job_timeout' => ['required', 'integer', 'min:1', 'max:7200'],
+ ]);
+ if ($validator->fails()) {
+ $this->importWarnings[] = 'The GitHub runner settings of this server were not imported, because they are not valid: '.$validator->errors()->first().' Set up GitHub runners on the server again.';
+
+ return;
+ }
+
+ $uuid = (string) data_get($payload, 'uuid', '');
+ $config = new GithubRunnerConfig;
+ $config->forceFill([
+ 'server_id' => $server->id,
+ 'github_app_id' => $githubApp->id,
+ 'labels' => array_values($payload['labels']),
+ 'is_enabled' => false,
+ 'is_dedicated' => (bool) data_get($payload, 'is_dedicated', false),
+ 'allow_pull_requests' => (bool) data_get($payload, 'allow_pull_requests', false),
+ 'max_runners' => (int) $payload['max_runners'],
+ 'docker_mode' => GithubRunnerDockerMode::from($payload['docker_mode']),
+ 'runner_image' => data_get($payload, 'runner_image'),
+ 'cpu_limit' => data_get($payload, 'cpu_limit'),
+ 'memory_limit' => data_get($payload, 'memory_limit'),
+ 'capacity_wait_timeout' => (int) $payload['capacity_wait_timeout'],
+ 'idle_timeout' => (int) $payload['idle_timeout'],
+ 'job_timeout' => (int) $payload['job_timeout'],
+ ]);
+ if ($preserveUuids && $uuid !== '' && ! GithubRunnerConfig::where('uuid', $uuid)->exists()) {
+ $config->uuid = $uuid;
+ }
+ $config->save();
}
/**
@@ -733,6 +1110,86 @@ class ServerTransferImporter
}
}
+ /**
+ * Canvas positions are keyed by resource uuid. Resources may get a new uuid on import.
+ *
+ * @param array $positions
+ */
+ private function importCanvasPositions(array $positions, Environment $environment): void
+ {
+ $imported = [];
+ foreach ($positions as $uuid => $position) {
+ $resource = $this->applicationMap[$uuid] ?? $this->databaseMap[$uuid] ?? $this->serviceMap[$uuid] ?? null;
+ if ($resource) {
+ $imported[$resource->uuid] = $position;
+ }
+ }
+ if ($imported === []) {
+ return;
+ }
+
+ $environment->canvas_positions = array_merge($environment->canvas_positions ?? [], $imported);
+ $environment->save();
+ }
+
+ /**
+ * Links between two imported resources of the environment. The user who drew the link is a user of the
+ * source instance, so created_by stays empty.
+ *
+ * @param list> $links
+ */
+ private function importResourceLinks(array $links, Environment $environment, bool $preserveUuids): void
+ {
+ foreach ($links as $link) {
+ $source = $this->linkedResource(data_get($link, 'source'));
+ $target = $this->linkedResource(data_get($link, 'target'));
+ if (! $source || ! $target || $source->is($target)
+ || (int) $source->environment_id !== $environment->id || (int) $target->environment_id !== $environment->id) {
+ $this->importWarnings[] = "A link between resources on the canvas of environment {$environment->name} was not imported, because one of its resources was not imported.";
+
+ continue;
+ }
+
+ $attributes = [
+ 'environment_id' => $environment->id,
+ 'source_type' => $source->getMorphClass(),
+ 'source_id' => $source->getKey(),
+ 'target_type' => $target->getMorphClass(),
+ 'target_id' => $target->getKey(),
+ ];
+ if (ResourceLink::query()->where($attributes)->exists()) {
+ continue;
+ }
+
+ $label = data_get($link, 'label');
+ $resourceLink = new ResourceLink([
+ ...$attributes,
+ 'label' => is_string($label) && trim($label) !== '' ? mb_substr(trim($label), 0, 64) : null,
+ 'created_by' => null,
+ ]);
+ $uuid = (string) data_get($link, 'uuid', '');
+ if ($preserveUuids && $uuid !== '' && ! ResourceLink::where('uuid', $uuid)->exists()) {
+ $resourceLink->uuid = $uuid;
+ }
+ $resourceLink->save();
+ }
+ }
+
+ /**
+ * An end of a canvas link: the kind and the original uuid of an imported resource.
+ */
+ private function linkedResource(mixed $end): ?Model
+ {
+ $uuid = (string) data_get($end, 'uuid', '');
+
+ return match (data_get($end, 'kind')) {
+ 'application' => $this->applicationMap[$uuid] ?? null,
+ 'database' => $this->databaseMap[$uuid] ?? null,
+ 'service' => $this->serviceMap[$uuid] ?? null,
+ default => null,
+ };
+ }
+
/**
* @param array $payload
* @param array $destinationMap
@@ -750,9 +1207,7 @@ class ServerTransferImporter
? (string) data_get($payload, 'uuid')
: new_public_id();
- if (Application::withTrashed()->where('uuid', $uuid)->exists()) {
- $uuid = new_public_id();
- }
+ $uuid = $this->resolveResourceUuid(Application::class, $uuid, $adoptMode, 'application');
$attributes = (array) data_get($payload, 'attributes', []);
unset($attributes['id'], $attributes['environment_id'], $attributes['destination_id'], $attributes['destination_type'], $attributes['source_id'], $attributes['source_type'], $attributes['private_key_id']);
@@ -763,6 +1218,8 @@ class ServerTransferImporter
$attributes['destination_id'] = $destination->id;
$attributes['destination_type'] = $destination->getMorphClass();
+ $name = (string) data_get($attributes, 'name', $uuid);
+
// Re-link git source
$source = data_get($payload, 'source');
if (is_array($source) && filled(data_get($source, 'uuid'))) {
@@ -786,12 +1243,27 @@ class ServerTransferImporter
}
}
}
+ $sourceMissing = data_get($payload, 'had_git_source') && ! isset($attributes['source_id']);
+ if ($sourceMissing || data_get($payload, 'source.system_wide') === true) {
+ $this->gitSourceApplications[] = $uuid;
+ }
+ if ($sourceMissing) {
+ $sourceLabel = match (data_get($payload, 'source.type')) {
+ 'github_app' => 'GitHub App',
+ 'gitlab_app' => 'GitLab App',
+ default => 'git source',
+ };
+ $this->importWarnings[] = "Application {$name}: its {$sourceLabel} was not found on this instance; set the source again.";
+ }
// Re-link deploy key
$appKeyUuid = data_get($payload, 'private_key_uuid');
if ($appKeyUuid && isset($this->privateKeyMap[$appKeyUuid])) {
$attributes['private_key_id'] = $this->privateKeyMap[$appKeyUuid]->id;
}
+ if (data_get($payload, 'had_private_key') && ! isset($attributes['private_key_id'])) {
+ $this->importWarnings[] = "Application {$name}: its private key was not found on this instance; select the private key again.";
+ }
if ($adoptMode) {
// Keep runtime status if provided so inventory can match, but default exited if empty.
@@ -800,7 +1272,10 @@ class ServerTransferImporter
$attributes['status'] = 'exited';
}
- $application = Application::create($attributes);
+ $application = new Application($attributes);
+ // Maintenance mode is not fillable.
+ $application->forceFill(collect($attributes)->only(['is_maintenance_enabled', 'maintenance_page'])->all());
+ $application->save();
$settings = (array) data_get($payload, 'settings', []);
if ($settings !== [] && $application->settings) {
@@ -815,6 +1290,7 @@ class ServerTransferImporter
$this->importScheduledTasks(data_get($payload, 'scheduled_tasks', []), $application, $teamId);
$this->importTags(data_get($payload, 'tags', []), $application, $teamId);
$this->importPreviews(data_get($payload, 'previews', []), $application, $preserveUuids, $adoptMode, $teamId);
+ $this->importSecretManagerLink(data_get($payload, 'secret_manager_link'), $application, "Application {$name}");
return $application;
}
@@ -832,12 +1308,9 @@ class ServerTransferImporter
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);
+ $modelClass = self::DATABASE_MODELS[$type] ?? null;
- if (! $modelClass || ! class_exists($modelClass)) {
+ if (! $modelClass) {
throw new RuntimeException("Unsupported database type: {$type}");
}
@@ -846,9 +1319,7 @@ class ServerTransferImporter
? (string) data_get($payload, 'uuid')
: new_public_id();
- if ($modelClass::withTrashed()->where('uuid', $uuid)->exists()) {
- $uuid = new_public_id();
- }
+ $uuid = $this->resolveResourceUuid($modelClass, $uuid, $adoptMode, 'database');
$attributes = (array) data_get($payload, 'attributes', []);
unset($attributes['id'], $attributes['environment_id'], $attributes['destination_id'], $attributes['destination_type']);
@@ -878,6 +1349,7 @@ class ServerTransferImporter
$this->importTags(data_get($payload, 'tags', []), $database, $teamId);
}
$this->importScheduledBackups(data_get($payload, 'scheduled_backups', []), $database, $teamId);
+ $this->importSecretManagerLink(data_get($payload, 'secret_manager_link'), $database, "Database {$database->name}");
return $database;
}
@@ -900,9 +1372,7 @@ class ServerTransferImporter
? (string) data_get($payload, 'uuid')
: new_public_id();
- if (Service::withTrashed()->where('uuid', $uuid)->exists()) {
- $uuid = new_public_id();
- }
+ $uuid = $this->resolveResourceUuid(Service::class, $uuid, $adoptMode, 'service');
$attributes = (array) data_get($payload, 'attributes', []);
unset($attributes['id'], $attributes['environment_id'], $attributes['destination_id'], $attributes['destination_type'], $attributes['server_id']);
@@ -913,14 +1383,19 @@ class ServerTransferImporter
$attributes['destination_id'] = $destination->id;
$attributes['destination_type'] = $destination->getMorphClass();
- $service = Service::create($attributes);
+ $service = new Service($attributes);
+ // Maintenance mode is not fillable.
+ $service->forceFill(collect($attributes)->only(['is_maintenance_enabled', 'maintenance_page'])->all());
+ $service->save();
$this->importEnvVars(data_get($payload, 'environment_variables', []), $service, false);
$this->importScheduledTasks(data_get($payload, 'scheduled_tasks', []), $service, $teamId);
$this->importTags(data_get($payload, 'tags', []), $service, $teamId);
+ $this->importSecretManagerLink(data_get($payload, 'secret_manager_link'), $service, "Service {$service->name}");
foreach (data_get($payload, 'applications', []) as $appPayload) {
- $this->importServiceApplication($appPayload, $service, $preserveUuids, $adoptMode);
+ $serviceApp = $this->importServiceApplication($appPayload, $service, $preserveUuids, $adoptMode);
+ $this->serviceApplicationMap[(string) data_get($appPayload, 'uuid', $serviceApp->uuid)] = $serviceApp;
}
foreach (data_get($payload, 'databases', []) as $dbPayload) {
@@ -1033,11 +1508,16 @@ class ServerTransferImporter
'is_required' => (bool) data_get($variable, 'is_required', false),
// Exports from older versions have no flag; their values use the old escaping.
'uses_legacy_escaping' => (bool) data_get($variable, 'uses_legacy_escaping', true),
+ 'preserve_compose_null' => (bool) data_get($variable, 'preserve_compose_null', false),
'comment' => data_get($variable, 'comment'),
'order' => data_get($variable, 'order'),
'resourceable_type' => $resource->getMorphClass(),
'resourceable_id' => $resource->id,
]);
+ // Older bundles have no version; the column default applies.
+ if (filled(data_get($variable, 'version'))) {
+ $env->version = (string) data_get($variable, 'version');
+ }
$env->uuid = $uuid;
$env->save();
});
@@ -1241,6 +1721,7 @@ class ServerTransferImporter
$s3Ids = $this->importedBackupS3StorageIds($backup);
$s3Id = $s3Ids[0] ?? null;
+ $this->warnAboutMissingS3Storage($backup, $s3Id, "database {$database->name}");
$importedBackup = ScheduledDatabaseBackup::create([
'uuid' => $uuid,
@@ -1259,6 +1740,7 @@ class ServerTransferImporter
'database_backup_retention_max_storage_s3' => data_get($backup, 'database_backup_retention_max_storage_s3'),
'timeout' => data_get($backup, 'timeout'),
'disable_local_backup' => (bool) data_get($backup, 'disable_local_backup', false),
+ 'missing_backup_notification_days' => (int) data_get($backup, 'missing_backup_notification_days', 0),
's3_storage_id' => $s3Id,
'database_type' => $database->getMorphClass(),
'database_id' => $database->id,
@@ -1267,6 +1749,20 @@ class ServerTransferImporter
}
}
+ /**
+ * @param array $backup
+ */
+ private function warnAboutMissingS3Storage(array $backup, ?int $s3Id, string $backupOf): void
+ {
+ if (! data_get($backup, 'had_s3_storage') || $s3Id !== null) {
+ return;
+ }
+
+ $this->importWarnings[] = data_get($backup, 'save_s3')
+ ? "Backup of {$backupOf}: S3 upload was turned off because its S3 storage was not found."
+ : "Backup of {$backupOf}: its S3 storage was not found; select it again.";
+ }
+
/**
* Maps the exported S3 destinations of a backup to imported storages, primary first. Older bundles only have
* `s3_storage_uuid`.
@@ -1327,6 +1823,10 @@ class ServerTransferImporter
'metadata' => data_get($payload, 'metadata'),
'pull_requests' => data_get($payload, 'pull_requests'),
'administration' => data_get($payload, 'administration'),
+ 'organization_self_hosted_runners' => data_get($payload, 'organization_self_hosted_runners'),
+ 'actions' => data_get($payload, 'actions'),
+ 'webhook_events' => data_get($payload, 'webhook_events'),
+ 'runner_group_id' => data_get($payload, 'runner_group_id'),
]);
$app->uuid = $uuid;
$app->save();
@@ -1464,6 +1964,222 @@ class ServerTransferImporter
return $token;
}
+ /**
+ * A token with the same UUID in the target team is reused. A UUID that another team uses gets a new UUID.
+ *
+ * @param array $payload
+ */
+ private function importIntegrationToken(array $payload, int $teamId, bool $preserveUuids): IntegrationToken
+ {
+ $uuid = $preserveUuids && filled(data_get($payload, 'uuid'))
+ ? (string) data_get($payload, 'uuid')
+ : new_public_id();
+
+ $existing = IntegrationToken::where('uuid', $uuid)->where('team_id', $teamId)->first();
+ if ($existing) {
+ return $existing;
+ }
+ if (IntegrationToken::where('uuid', $uuid)->exists()) {
+ $uuid = new_public_id();
+ }
+
+ $token = new IntegrationToken;
+ $token->forceFill([
+ 'team_id' => $teamId,
+ 'provider' => (string) data_get($payload, 'provider'),
+ 'name' => data_get($payload, 'name') ?: 'Imported integration token',
+ 'token' => (string) data_get($payload, 'token'),
+ 'capabilities' => (array) data_get($payload, 'capabilities', []),
+ 'metadata' => data_get($payload, 'metadata'),
+ ]);
+ $token->uuid = $uuid;
+ $token->save();
+
+ return $token;
+ }
+
+ /**
+ * The ID of the imported token, or null with a warning when the bundle names a token that it does not contain.
+ */
+ private function importedIntegrationTokenId(mixed $tokenUuid, string $missingWarning): ?int
+ {
+ if (! is_string($tokenUuid) || $tokenUuid === '') {
+ return null;
+ }
+ if (! isset($this->integrationTokenMap[$tokenUuid])) {
+ $this->importWarnings[] = $missingWarning;
+
+ return null;
+ }
+
+ return $this->integrationTokenMap[$tokenUuid]->id;
+ }
+
+ private function importSecretManagerLink(mixed $payload, Model $resource, string $label): void
+ {
+ if (! is_array($payload) || ! method_exists($resource, 'secretManagerLink')) {
+ return;
+ }
+
+ $tokenId = $this->importedIntegrationTokenId(
+ data_get($payload, 'integration_token_uuid'),
+ "{$label}: its secret manager token was not found on this instance; link the secret manager source again.",
+ );
+ if ($tokenId === null) {
+ return;
+ }
+
+ $uuid = filled(data_get($payload, 'uuid')) ? (string) data_get($payload, 'uuid') : new_public_id();
+ if (SecretManagerLink::where('uuid', $uuid)->exists()) {
+ $uuid = new_public_id();
+ }
+
+ $link = new SecretManagerLink;
+ $link->forceFill([
+ 'resourceable_type' => $resource->getMorphClass(),
+ 'resourceable_id' => $resource->getKey(),
+ 'integration_token_id' => $tokenId,
+ 'settings' => data_get($payload, 'settings'),
+ ]);
+ $link->uuid = $uuid;
+ $link->save();
+ }
+
+ /**
+ * A zone of the same token and provider zone that the target team already has is reused.
+ *
+ * @param list> $zones
+ */
+ private function importDnsZones(array $zones, bool $preserveUuids): void
+ {
+ foreach ($zones as $payload) {
+ $token = $this->integrationTokenMap[(string) data_get($payload, 'integration_token_uuid')] ?? null;
+ if (! $token) {
+ continue;
+ }
+
+ $zone = DnsProviderZone::query()
+ ->where('integration_token_id', $token->id)
+ ->where('provider_zone_id', (string) data_get($payload, 'provider_zone_id'))
+ ->first();
+ if (! $zone) {
+ $uuid = $preserveUuids && filled(data_get($payload, 'uuid')) ? (string) data_get($payload, 'uuid') : new_public_id();
+ if (DnsProviderZone::where('uuid', $uuid)->exists()) {
+ $uuid = new_public_id();
+ }
+ $zone = new DnsProviderZone;
+ $zone->forceFill([
+ 'integration_token_id' => $token->id,
+ 'provider_zone_id' => (string) data_get($payload, 'provider_zone_id'),
+ 'name' => strtolower((string) data_get($payload, 'name')),
+ 'account_id' => data_get($payload, 'account_id'),
+ 'account_name' => data_get($payload, 'account_name'),
+ ]);
+ $zone->uuid = $uuid;
+ $zone->save();
+ }
+
+ $this->dnsZoneMap[(string) data_get($payload, 'uuid', $zone->uuid)] = $zone;
+ }
+ }
+
+ /**
+ * Imports the managed DNS records and links them to the imported resources. An owned record keeps the ownership
+ * comment of the source at the provider until adoptManagedDnsRecords() replaces it.
+ *
+ * @param list> $records
+ */
+ private function importManagedDnsRecords(array $records, int $teamId, bool $preserveUuids): int
+ {
+ $count = 0;
+ foreach ($records as $payload) {
+ $name = (string) data_get($payload, 'name');
+ $zone = $this->dnsZoneMap[(string) data_get($payload, 'dns_provider_zone_uuid')] ?? null;
+ $resources = collect(data_get($payload, 'references', []))
+ ->map(fn ($reference) => match (data_get($reference, 'kind')) {
+ 'application' => $this->applicationMap[(string) data_get($reference, 'uuid')] ?? null,
+ 'preview' => $this->previewMap[(string) data_get($reference, 'uuid')] ?? null,
+ 'service_application' => $this->serviceApplicationMap[(string) data_get($reference, 'uuid')] ?? null,
+ default => null,
+ })
+ ->filter()
+ ->values();
+ if (! $zone || $resources->isEmpty()) {
+ $this->importWarnings[] = "DNS record {$name}: its DNS zone or resources were not imported, so this instance does not manage it.";
+
+ continue;
+ }
+
+ $record = ManagedDnsRecord::query()
+ ->where('dns_provider_zone_id', $zone->id)
+ ->where('provider_record_id', (string) data_get($payload, 'provider_record_id'))
+ ->first();
+ if (! $record) {
+ $uuid = $preserveUuids && filled(data_get($payload, 'uuid')) ? (string) data_get($payload, 'uuid') : new_public_id();
+ if (ManagedDnsRecord::where('uuid', $uuid)->exists()) {
+ $uuid = new_public_id();
+ }
+ $previousComment = data_get($payload, 'ownership_comment');
+ // Without the source comment there is no proof of ownership at the provider.
+ $owned = (bool) data_get($payload, 'owned', false) && is_string($previousComment) && $previousComment !== '';
+
+ $record = ManagedDnsRecord::query()->create([
+ 'uuid' => $uuid,
+ 'team_id' => $teamId,
+ 'integration_token_id' => $zone->integration_token_id,
+ 'dns_provider_zone_id' => $zone->id,
+ 'provider_record_id' => (string) data_get($payload, 'provider_record_id'),
+ 'type' => (string) data_get($payload, 'type'),
+ 'name' => $name,
+ 'content' => (string) data_get($payload, 'content'),
+ 'owned' => $owned,
+ ]);
+ if ($owned && $previousComment !== $record->ownershipComment()) {
+ $this->pendingDnsAdoptions[] = ['record_id' => $record->id, 'previous_comment' => $previousComment];
+ }
+ $count++;
+ }
+
+ foreach ($resources as $resource) {
+ $record->addReference($resource);
+ }
+ }
+
+ return $count;
+ }
+
+ /**
+ * Replaces the ownership comment of the source instance with the comment of this instance at the provider.
+ * A record that cannot be adopted is no longer owned, so this instance never changes or deletes it.
+ *
+ * @return list warnings
+ */
+ private function adoptManagedDnsRecords(): array
+ {
+ $warnings = [];
+ foreach ($this->pendingDnsAdoptions as $adoption) {
+ $record = ManagedDnsRecord::query()->find($adoption['record_id']);
+ if (! $record) {
+ continue;
+ }
+
+ try {
+ $adopted = app(CloudflareDnsProvider::class)->adoptRecord($record, $adoption['previous_comment']);
+ } catch (Throwable) {
+ $adopted = false;
+ }
+ if ($adopted) {
+ continue;
+ }
+
+ $record->forceFill(['owned' => false])->save();
+ $warnings[] = "DNS record {$record->name}: this instance could not take ownership; it will not change or delete it.";
+ }
+ $this->pendingDnsAdoptions = [];
+
+ return $warnings;
+ }
+
/**
* @param list> $previews
*/
@@ -1531,6 +2247,8 @@ class ServerTransferImporter
'git_type' => data_get($previewPayload, 'git_type'),
'docker_compose_domains' => data_get($previewPayload, 'docker_compose_domains'),
'docker_registry_image_tag' => data_get($previewPayload, 'docker_registry_image_tag'),
+ 'domain_port_overrides' => data_get($previewPayload, 'domain_port_overrides'),
+ 'max_restart_count' => (int) data_get($previewPayload, 'max_restart_count', 0),
];
if ($preview) {
@@ -1551,6 +2269,8 @@ class ServerTransferImporter
$preview->save();
}
+ $this->previewMap[(string) data_get($previewPayload, 'uuid', $preview->uuid)] = $preview;
+
// Avoid duplicating volumes when re-importing the same preview.
if ($preview->wasRecentlyCreated || $preview->persistentStorages()->count() === 0) {
$this->importPersistentStorages(data_get($previewPayload, 'persistent_storages', []), $preview);
@@ -1630,6 +2350,7 @@ class ServerTransferImporter
$s3Ids = $this->importedBackupS3StorageIds($backupPayload);
$s3Id = $s3Ids[0] ?? null;
+ $this->warnAboutMissingS3Storage($backupPayload, $s3Id, 'volume '.($volume->name ?? $volume->mount_path));
$importedBackup = ScheduledVolumeBackup::create([
'uuid' => $uuid,
@@ -1649,6 +2370,7 @@ class ServerTransferImporter
'retention_days_s3' => data_get($backupPayload, 'retention_days_s3'),
'retention_max_storage_s3' => data_get($backupPayload, 'retention_max_storage_s3'),
'timeout' => data_get($backupPayload, 'timeout'),
+ 'missing_backup_notification_days' => (int) data_get($backupPayload, 'missing_backup_notification_days', 0),
]);
$importedBackup->syncS3Storages($s3Ids);
$count++;
diff --git a/app/Services/ServerTransfer/ServerTransferMigrator.php b/app/Services/ServerTransfer/ServerTransferMigrator.php
index 1c2571e3d0..bb51b563f5 100644
--- a/app/Services/ServerTransfer/ServerTransferMigrator.php
+++ b/app/Services/ServerTransfer/ServerTransferMigrator.php
@@ -2,23 +2,402 @@
namespace App\Services\ServerTransfer;
+use App\Enums\ApplicationDeploymentStatus;
+use App\Jobs\MigrateServerToInstanceJob;
+use App\Models\Application;
+use App\Models\ApplicationDeploymentQueue;
+use App\Models\Environment;
+use App\Models\GithubApp;
+use App\Models\GitlabApp;
+use App\Models\Project;
use App\Models\Server;
+use App\Models\Service;
+use App\Models\User;
use App\Rules\SafeWebhookUrl;
use App\Services\Security\SensitiveDataRedactor;
+use Carbon\Carbon;
+use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\QueryException;
use Illuminate\Http\Client\ConnectionException;
+use Illuminate\Http\Client\PendingRequest;
+use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Validator;
+use Illuminate\Support\Sleep;
+use Illuminate\Support\Str;
use RuntimeException;
use Throwable;
class ServerTransferMigrator
{
+ /** Time the target gets for one import request. Its nginx can stop waiting earlier (99 s). */
+ public const IMPORT_TIMEOUT_SECONDS = 300;
+
+ /** How often and how long to ask the target for the result after the import request failed. */
+ public const STATUS_PROBE_ATTEMPTS = 20;
+
+ public const STATUS_PROBE_INTERVAL_SECONDS = 15;
+
+ public const STATUS_REQUEST_TIMEOUT_SECONDS = 15;
+
+ public const CHECK_TIMEOUT_SECONDS = 15;
+
+ /**
+ * Time the queued job gets. It must be longer than the import request and all status probes together,
+ * plus time for the export, so the worker does not stop the job after the target took the server.
+ */
+ public const MIGRATION_TIMEOUT_SECONDS = 1500;
+
+ /** Longer than the job timeout, so a running state older than this one is stale. */
+ public const STALE_MIGRATION_SECONDS = 1800;
+
public function __construct(
private ServerTransferExporter $exporter,
private ServerTransferClaimer $claimer,
) {}
+ /** The UI shows this many of the latest log lines. */
+ public const MAX_LOG_ENTRIES = 100;
+
+ /**
+ * System-wide git sources that the target of the last precheck does not have.
+ *
+ * @var array{github: list, gitlab: list}
+ */
+ private array $missingSystemSources = ['github' => [], 'gitlab' => []];
+
+ /**
+ * Run the prechecks now, so the user sees problems at once, then run the transfer in a queued job.
+ * Every check writes a line to the transfer log; a failed check stops the transfer before it starts.
+ */
+ public function start(Server $server, string $targetUrl, string $targetToken): void
+ {
+ $targetUrl = $this->normalizeTargetUrl($targetUrl);
+ $token = $this->normalizeToken($targetToken);
+ if ($this->hasRunningMigration($server->fresh())) {
+ throw new RuntimeException('A transfer of this server is already running.');
+ }
+
+ $startedAt = now()->toIso8601String();
+ $log = [$this->logEntry("Running checks before the transfer to {$targetUrl}.")];
+ $checks = [
+ ...$this->migrationChecks($server),
+ $this->sshCheck($server),
+ ...$this->targetChecks($server, $targetUrl, $token),
+ ];
+ foreach ($checks as $check) {
+ $log[] = match ($check['ok']) {
+ true => ['at' => $check['at'], 'level' => 'success', 'message' => "OK: {$check['label']}."],
+ false => ['at' => $check['at'], 'level' => 'error', 'message' => "FAILED: {$check['label']}. {$check['message']}"],
+ null => ['at' => $check['at'], 'level' => 'warning', 'message' => "NOT CHECKED: {$check['label']}. {$check['message']}"],
+ };
+ }
+ foreach ([...$this->sharedProjectNotes($server), ...$this->systemSourceNotes($server)] as $note) {
+ $log[] = $this->logEntry($note);
+ }
+
+ $failed = array_values(array_filter($checks, fn (array $check) => $check['ok'] === false));
+ if ($failed !== []) {
+ $error = count($failed).' of '.count($checks).' checks failed: '.implode(' ', array_column($failed, 'message'));
+ $log[] = $this->logEntry('The transfer did not start, because '.count($failed).' check(s) failed. Fix them, then transfer again.', 'error');
+ DB::transaction(function () use ($server, $targetUrl, $startedAt, $error, $log) {
+ $this->writeMigration(Server::query()->lockForUpdate()->findOrFail($server->id), [
+ 'status' => 'failed',
+ 'phase' => 'checks',
+ 'target_url' => $targetUrl,
+ 'started_at' => $startedAt,
+ 'finished_at' => now()->toIso8601String(),
+ 'error' => $error,
+ 'log' => $log,
+ ]);
+ });
+
+ throw new RuntimeException($error);
+ }
+
+ $log[] = $this->logEntry('All checks passed. The transfer is queued.');
+ DB::transaction(function () use ($server, $targetUrl, $startedAt, $log) {
+ $locked = Server::query()->lockForUpdate()->findOrFail($server->id);
+ if ($this->hasRunningMigration($locked)) {
+ throw new RuntimeException('A transfer of this server is already running.');
+ }
+ $this->writeMigration($locked, [
+ 'status' => 'running',
+ 'target_url' => $targetUrl,
+ 'started_at' => $startedAt,
+ 'log' => $log,
+ ]);
+ });
+
+ MigrateServerToInstanceJob::dispatch($server, $targetUrl, $token);
+ }
+
+ /**
+ * Save the state of the queued transfer on the source server, so the UI and the API can show it.
+ *
+ * @param array $details
+ */
+ public function recordMigration(Server $server, string $status, array $details = []): void
+ {
+ $entry = match ($status) {
+ 'succeeded' => $this->logEntry('Transfer finished. The target instance manages the server now.', 'success'),
+ 'unknown' => $this->logEntry('Transfer result unknown: '.data_get($details, 'error', 'unknown error'), 'warning'),
+ default => $this->logEntry('Transfer failed: '.data_get($details, 'error', 'unknown error'), 'error'),
+ };
+
+ DB::transaction(function () use ($server, $status, $details, $entry) {
+ $locked = Server::query()->lockForUpdate()->find($server->id);
+ if (! $locked) {
+ return;
+ }
+ $migration = array_merge((array) data_get($locked->server_metadata, 'migration', []), $details, [
+ 'status' => $status,
+ 'finished_at' => now()->toIso8601String(),
+ ]);
+ $migration['log'] = $this->appendToLog((array) data_get($migration, 'log', []), $entry);
+ $this->writeMigration($locked, $migration);
+ });
+ }
+
+ /**
+ * Record the result of a transfer job that the worker stopped, for example at its timeout. When the export
+ * was already sent, ask the target once, because the target can manage the server already.
+ */
+ public function recordStoppedMigration(Server $server, string $targetUrl, string $targetToken, string $error): void
+ {
+ $exportId = data_get($server->fresh()?->server_metadata, 'migration.export_id');
+ if (! is_string($exportId)) {
+ $this->recordMigration($server, 'failed', ['error' => $error]);
+
+ return;
+ }
+
+ $targetUrl = $this->normalizeTargetUrl($targetUrl);
+ $status = $this->targetTransferStatus($targetUrl, $this->normalizeToken($targetToken), $server->uuid, $exportId);
+
+ if ($status === 'claimed') {
+ try {
+ $this->claimer->markTransferred($server, exportId: $exportId, targetInstanceUrl: $targetUrl);
+ $this->recordMigration($server, 'succeeded', [
+ 'warnings' => ['The transfer job stopped before it finished. The target status confirms that the target manages the server.'],
+ ]);
+ } catch (Throwable) {
+ $this->recordMigration($server, 'unknown', [
+ 'error' => "{$error} The target manages the server, but this instance could not mark it as transferred. Call POST /api/v1/servers/{$server->uuid}/transfer/complete on this instance.",
+ ]);
+ }
+
+ return;
+ }
+
+ // The target answered that it does not manage this export, so this instance still manages the server.
+ if (in_array($status, ['imported', 'absent'], true)) {
+ $this->recordMigration($server, 'failed', ['error' => $error]);
+
+ return;
+ }
+
+ $this->recordMigration($server, 'unknown', [
+ 'error' => "{$error} This instance could not ask {$targetUrl} whether it imported the server. ".
+ "Check the server on the target. If the target manages it, call POST /api/v1/servers/{$server->uuid}/transfer/complete on this instance. ".
+ 'If not, transfer again.',
+ ]);
+ }
+
+ /**
+ * Add one line to the transfer log, so the UI can show where the transfer is.
+ *
+ * @param array $fields values to save in the transfer state with the line
+ */
+ public function log(Server $server, string $message, string $level = 'info', array $fields = []): void
+ {
+ $entry = $this->logEntry($message, $level);
+
+ DB::transaction(function () use ($server, $entry, $fields) {
+ $locked = Server::query()->lockForUpdate()->find($server->id);
+ if (! $locked) {
+ return;
+ }
+ $migration = array_merge((array) data_get($locked->server_metadata, 'migration', []), $fields);
+ $migration['log'] = $this->appendToLog((array) data_get($migration, 'log', []), $entry);
+ $this->writeMigration($locked, $migration);
+ });
+ }
+
+ /**
+ * @return array{at: string, level: string, message: string}
+ */
+ private function logEntry(string $message, string $level = 'info'): array
+ {
+ return ['at' => now()->toIso8601String(), 'level' => $level, 'message' => $message];
+ }
+
+ /**
+ * @param list> $log
+ * @param array $entry
+ * @return list>
+ */
+ private function appendToLog(array $log, array $entry): array
+ {
+ return array_slice([...$log, $entry], -self::MAX_LOG_ENTRIES);
+ }
+
+ /**
+ * @return array{label: string, ok: bool|null, message: string|null, at: string}
+ */
+ private function check(string $label, ?bool $ok, ?string $message = null): array
+ {
+ return ['label' => $label, 'ok' => $ok, 'message' => $message, 'at' => now()->toIso8601String()];
+ }
+
+ /**
+ * @return array{label: string, ok: bool|null, message: string|null, at: string}
+ */
+ private function sshCheck(Server $server): array
+ {
+ ['uptime' => $reachable, 'error' => $error] = $server->validateConnection();
+
+ return $this->check(
+ 'this instance can connect to the server over SSH',
+ (bool) $reachable,
+ $reachable ? null : 'This instance cannot connect to the server over SSH: '.rtrim($error ?: 'unknown error', '. ').'. Fix the connection, then transfer again.',
+ );
+ }
+
+ /**
+ * A running state older than the job can run is left over from a stopped worker, so it does not block a new start.
+ */
+ private function hasRunningMigration(Server $server): bool
+ {
+ $startedAt = data_get($server->server_metadata, 'migration.started_at');
+
+ return data_get($server->server_metadata, 'migration.status') === 'running'
+ && is_string($startedAt)
+ && Carbon::parse($startedAt)->gt(now()->subSeconds(self::STALE_MIGRATION_SECONDS));
+ }
+
+ /**
+ * @param array $migration
+ */
+ private function writeMigration(Server $server, array $migration): void
+ {
+ $metadata = $server->server_metadata ?? [];
+ $metadata['migration'] = $migration;
+ $server->server_metadata = $metadata;
+ $server->save();
+ }
+
+ /**
+ * @return list
+ */
+ private function migrationChecks(Server $server): array
+ {
+ $activeDeployments = ApplicationDeploymentQueue::query()
+ ->where('server_id', $server->id)
+ ->whereIn('status', [ApplicationDeploymentStatus::QUEUED->value, ApplicationDeploymentStatus::IN_PROGRESS->value])
+ ->count();
+
+ return [
+ $this->check('the server is not the Coolify host', $server->id !== 0,
+ 'The Coolify host (localhost) cannot be transferred between instances.'),
+ $this->check('this instance manages the server', ! $server->isTransferredAway(),
+ 'This server was already transferred to another instance.'),
+ $this->check('no deployment is queued or running on the server', $activeDeployments === 0,
+ "{$activeDeployments} deployment(s) on this server are queued or running. Wait until they finish or cancel them, then transfer the server."),
+ ];
+ }
+
+ /**
+ * System-wide git sources are not transferred: an application re-links only to one with the same uuid on the
+ * target. One note per source that the target does not have, with the applications that lose their source.
+ *
+ * @return list
+ */
+ private function systemSourceNotes(Server $server): array
+ {
+ $notes = [];
+ foreach ($this->systemSources($server) as $kind => $sources) {
+ foreach ($sources as $uuid => $source) {
+ if (in_array($uuid, $this->missingSystemSources[$kind], true)) {
+ $label = $kind === 'github' ? 'GitHub App' : 'GitLab App';
+ $notes[] = "NOTE: the system-wide {$label} '{$source['name']}' of this instance is not transferred, and the target has no matching one. "
+ .'These applications need a new git source on the target: '.implode(', ', $source['applications']).'. The finish list on the target shows them.';
+ }
+ }
+ }
+
+ return $notes;
+ }
+
+ /**
+ * @return array{github: array}>, gitlab: array}>}
+ */
+ private function systemSources(Server $server): array
+ {
+ $sources = ['github' => [], 'gitlab' => []];
+ foreach ($server->applications()->unique('id') as $application) {
+ $source = $application->source;
+ $kind = match (true) {
+ $source instanceof GithubApp => 'github',
+ $source instanceof GitlabApp => 'gitlab',
+ default => null,
+ };
+ if ($kind === null || ! $source->is_system_wide) {
+ continue;
+ }
+ $sources[$kind][$source->uuid]['name'] = $source->name;
+ $sources[$kind][$source->uuid]['applications'][] = $application->name;
+ }
+
+ return $sources;
+ }
+
+ /**
+ * One note for every project that has resources on this server and on other servers. Only the
+ * resources of this server are transferred, so the user must know that the project is split.
+ *
+ * @return list
+ */
+ private function sharedProjectNotes(Server $server): array
+ {
+ $resourcesOnServer = collect([
+ ...$server->applications(),
+ ...$server->databases(),
+ ...$server->services()->get(),
+ ]);
+ $keysOnServer = $resourcesOnServer->map(fn (Model $resource) => $resource::class.':'.$resource->getKey())->flip();
+
+ $projectIds = Environment::query()
+ ->whereIn('id', $resourcesOnServer->pluck('environment_id')->filter()->unique())
+ ->distinct()
+ ->pluck('project_id');
+ $projectIdByEnvironment = Environment::query()->whereIn('project_id', $projectIds)->pluck('project_id', 'id');
+
+ $otherServerCounts = collect([Application::class, Service::class, ...array_values(STANDALONE_DATABASE_MODELS)])
+ ->flatMap(fn (string $model) => $model::query()
+ ->whereIn('environment_id', $projectIdByEnvironment->keys())
+ ->pluck('environment_id', 'id')
+ ->reject(fn (int|string $environmentId, int|string $id) => $keysOnServer->has("{$model}:{$id}")))
+ ->countBy(fn (int|string $environmentId) => $projectIdByEnvironment[$environmentId]);
+
+ return Project::query()
+ ->whereIn('id', $otherServerCounts->keys())
+ ->orderBy('name')
+ ->get(['id', 'name'])
+ ->map(fn (Project $project) => "NOTE: project '{$project->name}' also has {$otherServerCounts[$project->id]} ".
+ Str::plural('resource', $otherServerCounts[$project->id]).' on other servers. They stay on this instance; only the resources of this server are transferred.')
+ ->all();
+ }
+
+ private function assertCanMigrate(Server $server): void
+ {
+ foreach ($this->migrationChecks($server) as $check) {
+ if (! $check['ok']) {
+ throw new RuntimeException($check['message']);
+ }
+ }
+ }
+
/**
* One-shot migrate: export on this instance → import+claim on target → complete on this instance.
*
@@ -29,37 +408,41 @@ class ServerTransferMigrator
* import: array,
* complete: array,
* warnings: list,
+ * checklist: list,
* message: string
* }
*/
- public function migrate(
- Server $server,
- string $targetUrl,
- string $targetToken,
- bool $preserveUuids = true,
- bool $adoptMode = true,
- ): array {
- if ($server->id === 0) {
- throw new RuntimeException('The Coolify host (localhost) cannot be transferred between instances.');
- }
+ public function migrate(Server $server, string $targetUrl, string $targetToken): array
+ {
+ $server->refresh();
+ $this->assertCanMigrate($server);
$targetUrl = $this->normalizeTargetUrl($targetUrl);
$token = $this->normalizeToken($targetToken);
+ $this->log($server, 'Exporting the server configuration and its resources.');
$bundle = $this->exporter->export($server, includeSensitive: true);
$exportId = data_get($bundle, 'export_id');
$warnings = array_values((array) data_get($bundle, 'warnings', []));
+ $checklist = $this->postTransferChecklist($bundle, $targetUrl);
+ // A job that the worker stops after this line reads the export ID to ask the target for the result.
+ $this->log($server, "Export {$exportId} created. Sending it to {$targetUrl}. The target imports the server and takes over management.", fields: ['export_id' => $exportId]);
// 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(
+ server: $server,
targetUrl: $targetUrl,
token: $token,
bundle: $bundle,
- preserveUuids: $preserveUuids,
- adoptMode: $adoptMode,
);
+ // The target fills in its own public URL; the URL typed here can be an internal address.
+ $targetChecklist = array_values(array_filter((array) data_get($importBody, 'checklist', []), 'is_string'));
+ if ($targetChecklist !== []) {
+ $checklist = $targetChecklist;
+ }
+
if (is_array(data_get($importBody, 'warnings'))) {
$warnings = array_values(array_unique(array_merge($warnings, $importBody['warnings'])));
}
@@ -68,13 +451,15 @@ class ServerTransferMigrator
if (data_get($importBody, 'claimed') !== true) {
throw new RuntimeException(
"Server was imported on {$targetUrl}, but the target could not take management of it. ".
- 'This instance still manages the server. Fix the problem on the target, click Enable management there, then click Disable management here. '.
+ 'This instance still manages the server. Delete the imported server on the target, fix the problem, then transfer again. '.
'Target warnings: '.(implode(' ', (array) data_get($importBody, 'warnings', [])) ?: 'none')
);
}
+ $this->log($server, 'The target imported the server and manages it now. Disabling management on this instance.', 'success');
+
// 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 disable management here.
+ // already owns the server. Surface that clearly so the operator can complete the transfer here.
try {
$complete = $this->claimer->markTransferred(
$server,
@@ -86,7 +471,7 @@ class ServerTransferMigrator
$reason = $e instanceof QueryException ? 'a database error occurred' : remove_iip($e->getMessage());
throw new RuntimeException(
"Server was imported on {$targetUrl}, but this instance could not mark it as transferred: {$reason}. ".
- 'Disable management of this server here (or API: POST /api/v1/servers/{uuid}/transfer/complete) so automations stay disabled. Do not re-import on the target.',
+ 'Call POST /api/v1/servers/{uuid}/transfer/complete on this instance so automations stay disabled here. Do not re-import on the target.',
previous: $e
);
}
@@ -98,10 +483,77 @@ class ServerTransferMigrator
'import' => app(SensitiveDataRedactor::class)->redactValue($importBody),
'complete' => $complete,
'warnings' => $warnings,
+ 'checklist' => $checklist,
+ 'target_server_url' => is_string(data_get($importBody, 'server_url')) ? data_get($importBody, 'server_url') : null,
'message' => 'Server migrated to '.$targetUrl.'. Automations disabled on this instance.',
];
}
+ /**
+ * Manual steps the user must do after the transfer. The exporter writes {target_url} where the URL of the target belongs.
+ *
+ * @param array $bundle
+ * @return list
+ */
+ private function postTransferChecklist(array $bundle, string $targetUrl): array
+ {
+ $steps = array_filter((array) data_get($bundle, 'post_transfer_checklist', []), 'is_string');
+
+ return array_values(array_unique(array_map(
+ fn (string $step) => str_replace('{target_url}', $targetUrl, $step),
+ $steps,
+ )));
+ }
+
+ /**
+ * Undo a transfer: ask the instance that manages the server now to transfer it back to this instance. That
+ * instance runs its prechecks at once and then a normal transfer; the import here replaces the archive.
+ * This instance creates a short-lived token for the import, scoped to the team of the archive.
+ */
+ public function requestTransferBack(Server $archive, User $user, string $managerUrl, string $managerToken, string $thisInstanceUrl): void
+ {
+ if (! $archive->isTransferredAway()) {
+ throw new RuntimeException('Only a server that was transferred to another instance can be transferred back.');
+ }
+
+ $managerUrl = $this->normalizeTargetUrl($managerUrl);
+ $managerToken = $this->normalizeToken($managerToken);
+ $thisInstanceUrl = rtrim(trim($thisInstanceUrl), '/');
+ if (! filter_var($thisInstanceUrl, FILTER_VALIDATE_URL)) {
+ throw new RuntimeException('Enter the URL at which the other instance reaches this instance.');
+ }
+
+ $importToken = $user->createToken("transfer back {$archive->uuid}", ['read', 'write'], now()->addHours(3), $archive->team_id);
+ $migrateUrl = $managerUrl.'/api/v1/servers/'.rawurlencode($archive->uuid).'/migrate';
+
+ try {
+ $response = $this->targetRequest($migrateUrl, $managerToken, self::CHECK_TIMEOUT_SECONDS * 4)
+ ->post($migrateUrl, ['target_url' => $thisInstanceUrl, 'target_token' => $importToken->plainTextToken]);
+ } catch (ConnectionException $e) {
+ $importToken->accessToken->delete();
+ throw new RuntimeException('This instance cannot reach '.remove_iip($managerUrl).': '.remove_iip($e->getMessage()), previous: $e);
+ }
+
+ if ($response->status() !== 202) {
+ $importToken->accessToken->delete();
+ $message = remove_iip((string) (data_get($response->json(), 'message') ?: 'no details'));
+ throw new RuntimeException(match ($response->status()) {
+ 401 => 'The other instance does not accept the API token.',
+ 403 => 'The API token of the other instance needs root, or read:sensitive and write, from an admin or owner. '.$message,
+ 404 => 'The other instance does not know this server, or does not support transfers. '.$message,
+ default => "The other instance did not start the transfer back (HTTP {$response->status()}): {$message}",
+ });
+ }
+
+ DB::transaction(function () use ($archive, $managerUrl) {
+ $locked = Server::query()->lockForUpdate()->findOrFail($archive->id);
+ $metadata = $locked->server_metadata ?? [];
+ $metadata['transfer']['transfer_back'] = ['requested_at' => now()->toIso8601String(), 'from' => $managerUrl];
+ $locked->server_metadata = $metadata;
+ $locked->save();
+ });
+ }
+
private function normalizeTargetUrl(string $targetUrl): string
{
$targetUrl = rtrim(trim($targetUrl), '/');
@@ -121,6 +573,10 @@ class ServerTransferMigrator
return $targetUrl;
}
+ if (strtolower((string) parse_url($targetUrl, PHP_URL_SCHEME)) !== 'https') {
+ throw new RuntimeException('The target instance URL must use https, because the transfer sends credentials.');
+ }
+
Validator::make(['target_url' => $targetUrl], [
'target_url' => ['required', new SafeWebhookUrl],
])->validate();
@@ -152,31 +608,21 @@ class ServerTransferMigrator
* @param array $bundle
* @return array
*/
- private function postImportToTarget(
- string $targetUrl,
- string $token,
- array $bundle,
- bool $preserveUuids,
- bool $adoptMode,
- ): array {
+ private function postImportToTarget(Server $server, string $targetUrl, string $token, array $bundle): array
+ {
$importUrl = $targetUrl.'/api/v1/servers/import';
try {
- $response = Http::timeout(120)
- ->withOptions($this->isLocalDevelopmentTarget($importUrl)
- ? ['allow_redirects' => false]
- : SafeWebhookUrl::httpClientOptions($importUrl))
- ->acceptJson()
- ->withToken($token)
- ->asJson()
+ $response = $this->targetRequest($importUrl, $token, self::IMPORT_TIMEOUT_SECONDS)
->post($importUrl, [
'bundle' => $bundle,
'dry_run' => false,
- 'preserve_uuids' => $preserveUuids,
- 'adopt_mode' => $adoptMode,
+ 'preserve_uuids' => true,
+ 'adopt_mode' => true,
]);
} catch (ConnectionException $e) {
- throw new RuntimeException('Could not reach target instance at '.remove_iip($importUrl).': '.remove_iip($e->getMessage()), previous: $e);
+ return $this->resultFromTargetStatus($server, $targetUrl, $token, $bundle, 'the request could not reach the target or timed out')
+ ?? throw new RuntimeException('Could not reach target instance at '.remove_iip($importUrl).': '.remove_iip($e->getMessage()), previous: $e);
} catch (Throwable $e) {
throw new RuntimeException('Transfer to target failed: '.remove_iip($e->getMessage()), previous: $e);
}
@@ -186,6 +632,18 @@ class ServerTransferMigrator
$importBody = ['raw' => remove_iip($response->body())];
}
+ if ($response->status() === 404) {
+ throw new RuntimeException('The target instance does not support server transfer. Upgrade it to Coolify '.config('constants.coolify.version').' or newer.');
+ }
+
+ // A proxy timeout (502/504) or a server error does not mean that the import failed: the target can still finish it.
+ if ($response->serverError()) {
+ $result = $this->resultFromTargetStatus($server, $targetUrl, $token, $bundle, "the target answered with HTTP {$response->status()}");
+ if ($result !== null) {
+ return $result;
+ }
+ }
+
if (! $response->successful()) {
$message = data_get($importBody, 'message')
?? data_get($importBody, 'errors')
@@ -198,4 +656,155 @@ class ServerTransferMigrator
return $importBody;
}
+
+ /**
+ * Ask the target whether it accepts the token and can import this server, so a wrong token or a
+ * conflict on the target fails before the transfer starts instead of in the queued job. When a
+ * check fails that the target checks depend on, those checks are reported as not checked.
+ *
+ * @return list
+ */
+ private function targetChecks(Server $server, string $targetUrl, string $token): array
+ {
+ $checkUrl = $targetUrl.'/api/v1/servers/import/check';
+ $reachLabel = 'the target instance at '.remove_iip($targetUrl).' can be reached';
+ $notChecked = fn (string $label) => $this->check('the checks on the target instance', null, "They did not run, because \"{$label}\" failed.");
+
+ $systemSources = $this->systemSources($server);
+ $this->missingSystemSources = ['github' => [], 'gitlab' => []];
+ try {
+ $response = $this->targetRequest($checkUrl, $token, self::CHECK_TIMEOUT_SECONDS)
+ ->get($checkUrl, [
+ 'server_uuid' => $server->uuid,
+ 'ip' => $server->ip,
+ 'port' => $server->port,
+ 'schema_version' => ServerTransferBundle::SCHEMA_VERSION,
+ 'system_github_apps' => array_keys($systemSources['github']),
+ 'system_gitlab_apps' => array_keys($systemSources['gitlab']),
+ ]);
+ } catch (ConnectionException $e) {
+ return [
+ $this->check($reachLabel, false, 'This instance cannot reach the target instance: '.remove_iip($e->getMessage())),
+ $notChecked($reachLabel),
+ ];
+ }
+
+ $checks = [$this->check($reachLabel, true)];
+ $status = $response->status();
+ $message = remove_iip((string) (data_get($response->json(), 'message') ?: 'no details'));
+
+ // An unknown route answers 404 before the token is checked, so this check comes first.
+ $gates = [
+ [404, 'the target instance supports server transfer', 'The target instance does not support server transfer. Upgrade it to Coolify '.config('constants.coolify.version').' or newer.'],
+ [401, 'the target instance accepts the API token', 'The target instance does not accept the API token. Create a new token on the target instance and paste it here.'],
+ [403, 'the API token can create servers on the target instance', 'Use a token with the write permission from an admin or owner. Target response: '.$message],
+ ];
+ foreach ($gates as [$failStatus, $label, $failMessage]) {
+ if ($status === $failStatus) {
+ return [...$checks, $this->check($label, false, $failMessage), $notChecked($label)];
+ }
+ $checks[] = $this->check($label, true);
+ }
+
+ foreach (['github', 'gitlab'] as $kind) {
+ $this->missingSystemSources[$kind] = array_values(array_filter((array) data_get($response->json(), "missing_system_sources.{$kind}", []), 'is_string'));
+ }
+
+ $remoteChecks = data_get($response->json(), 'checks');
+ if (! in_array($status, [200, 422], true) || ! is_array($remoteChecks)) {
+ return [...$checks, $this->check('the target instance answered the checks', false, "The target instance answered with HTTP {$status}: {$message}")];
+ }
+
+ foreach ($remoteChecks as $remoteCheck) {
+ $checks[] = $this->check(
+ 'on the target: '.data_get($remoteCheck, 'label', 'unknown check'),
+ (bool) data_get($remoteCheck, 'ok'),
+ is_string(data_get($remoteCheck, 'message')) ? remove_iip(data_get($remoteCheck, 'message')) : null,
+ );
+ }
+
+ return $checks;
+ }
+
+ /**
+ * Ask the target whether it imported this export. Returns null when the target does not know it.
+ *
+ * @param array $bundle
+ * @return array|null
+ */
+ private function resultFromTargetStatus(Server $server, string $targetUrl, string $token, array $bundle, string $reason): ?array
+ {
+ $serverUuid = (string) data_get($bundle, 'server.uuid');
+ $exportId = (string) data_get($bundle, 'export_id');
+
+ $this->log($server, "The import request did not finish ({$reason}). The target can still finish the import, so this instance asks the target for the result.", 'warning');
+
+ for ($attempt = 1; $attempt <= self::STATUS_PROBE_ATTEMPTS; $attempt++) {
+ Sleep::for(self::STATUS_PROBE_INTERVAL_SECONDS)->seconds();
+ $this->log($server, "Asking the target for the import result (attempt {$attempt} of ".self::STATUS_PROBE_ATTEMPTS.').');
+
+ $status = $this->targetTransferStatus($targetUrl, $token, $serverUuid, $exportId);
+
+ // A rejected token stays rejected, so more attempts cannot confirm the import.
+ if ($status === 'rejected') {
+ return null;
+ }
+
+ if (in_array($status, ['claimed', 'imported'], true)) {
+ return [
+ 'server_uuid' => $serverUuid,
+ 'export_id' => $exportId,
+ 'claimed' => $status === 'claimed',
+ 'warnings' => ['The import request to the target timed out. The target status confirms the import.'],
+ ];
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Ask the target once what it knows about this export: 'claimed' or 'imported', 'absent' when the target
+ * does not have it, 'rejected' when the target rejects the token, or 'unknown' when there is no clear answer.
+ *
+ * @return 'claimed'|'imported'|'absent'|'rejected'|'unknown'
+ */
+ private function targetTransferStatus(string $targetUrl, string $token, string $serverUuid, string $exportId): string
+ {
+ $statusUrl = $targetUrl.'/api/v1/servers/'.rawurlencode($serverUuid).'/transfer';
+
+ try {
+ $response = $this->targetRequest($statusUrl, $token, self::STATUS_REQUEST_TIMEOUT_SECONDS)->get($statusUrl);
+ } catch (Throwable) {
+ return 'unknown';
+ }
+
+ if (in_array($response->status(), [401, 403], true)) {
+ return 'rejected';
+ }
+ if ($response->status() === 404) {
+ return 'absent';
+ }
+ if (! $response->successful()) {
+ return 'unknown';
+ }
+ if (data_get($response->json(), 'transfer.export_id') !== $exportId) {
+ return 'absent';
+ }
+
+ $status = data_get($response->json(), 'transfer.status');
+
+ return in_array($status, ['claimed', 'imported'], true) ? $status : 'unknown';
+ }
+
+ private function targetRequest(string $url, string $token, int $timeout): PendingRequest
+ {
+ return Http::timeout($timeout)
+ ->withOptions($this->isLocalDevelopmentTarget($url)
+ ? ['allow_redirects' => false]
+ : SafeWebhookUrl::httpClientOptions($url))
+ ->acceptJson()
+ ->withToken($token)
+ ->asJson();
+ }
}
diff --git a/app/Services/ServerTransfer/TransferFinishChecklist.php b/app/Services/ServerTransfer/TransferFinishChecklist.php
new file mode 100644
index 0000000000..ee8d93dd79
--- /dev/null
+++ b/app/Services/ServerTransfer/TransferFinishChecklist.php
@@ -0,0 +1,581 @@
+ */
+ public const GROUPS = [
+ 'server' => 'Server',
+ 'git' => 'Git and webhooks',
+ 'dns' => 'Domains and DNS',
+ ];
+
+ private const GROUP_OF_TYPE = [
+ 'server_connection' => 'server', 'sentinel' => 'server', 's3_storage' => 'server',
+ 'github_app_webhook' => 'git', 'github_app_urls' => 'git', 'gitlab_webhook' => 'git', 'manual_webhook' => 'git',
+ 'git_source' => 'git', 'github_runners' => 'git',
+ 'dns_record' => 'dns', 'domain' => 'dns',
+ ];
+
+ /** Types that the user can only mark as done. */
+ private const MANUAL_TYPES = ['github_app_urls'];
+
+ /** Types that Coolify can fix with one click. */
+ private const FIX_LABELS = [
+ 'github_app_webhook' => 'Set webhook URL',
+ 'sentinel' => 'Restart Sentinel',
+ 'dns_record' => 'Take ownership',
+ ];
+
+ /**
+ * Build the list for a server that this instance manages now. It replaces an earlier list.
+ *
+ * @return list>
+ */
+ public function start(Server $server): array
+ {
+ $items = $this->buildItems($server);
+ $this->save($server, [
+ 'started_at' => now()->toIso8601String(),
+ 'finished_at' => null,
+ 'finished_by' => null,
+ 'skipped' => [],
+ 'items' => $items,
+ ]);
+
+ return $items;
+ }
+
+ /**
+ * @return array|null
+ */
+ public function of(Server $server): ?array
+ {
+ $finish = data_get($server->server_metadata, 'transfer.finish');
+
+ return is_array($finish) ? $finish : null;
+ }
+
+ public function isOpen(Server $server): bool
+ {
+ $finish = $this->of($server);
+
+ return $finish !== null && $finish['finished_at'] === null && ! $server->isTransferredAway();
+ }
+
+ /**
+ * @return list>
+ */
+ public function openItems(Server $server): array
+ {
+ return array_values(array_filter((array) data_get($this->of($server), 'items', []), fn (array $item) => in_array($item['status'], self::OPEN_STATUSES, true)));
+ }
+
+ public function groupOf(array $item): string
+ {
+ return self::GROUP_OF_TYPE[$item['type']] ?? 'server';
+ }
+
+ /**
+ * Run the checks of the open steps in a queued job when the last run is older than RECHECK_AFTER_SECONDS.
+ */
+ public function checkWhenStale(Server $server): void
+ {
+ $finish = $this->of($server);
+ if (! $this->isOpen($server) || $this->openItems($server) === []) {
+ return;
+ }
+ $lastCheckedAt = data_get($finish, 'last_checked_at');
+ if (is_string($lastCheckedAt) && Carbon::parse($lastCheckedAt)->isAfter(now()->subSeconds(self::RECHECK_AFTER_SECONDS))) {
+ return;
+ }
+
+ CheckTransferFinishStepsJob::dispatch($server);
+ }
+
+ public function canCheck(array $item): bool
+ {
+ return ! in_array($item['type'], self::MANUAL_TYPES, true);
+ }
+
+ /**
+ * Where the user does the step. GitHub App links are built on each render, so lists saved before a rename of
+ * the App, or before the link existed, still point to the right page.
+ *
+ * @return array{0: string, 1: string}|null
+ */
+ public function linkOf(array $item): ?array
+ {
+ if (in_array($item['type'], ['github_app_webhook', 'github_app_urls'], true)) {
+ $githubApp = GithubApp::where('uuid', $item['subject'])->first();
+
+ return $githubApp ? [$githubApp->settingsUrl(), 'Open on GitHub'] : null;
+ }
+
+ return filled($item['link'] ?? null) ? [$item['link'], $item['link_label'] ?? 'Open'] : null;
+ }
+
+ public function fixLabel(array $item): ?string
+ {
+ return self::FIX_LABELS[$item['type']] ?? null;
+ }
+
+ public function check(Server $server, string $key): array
+ {
+ return $this->update($server, $key, fn (array $item) => $this->runCheck($server, $item));
+ }
+
+ public function checkAll(Server $server): void
+ {
+ foreach ($this->openItems($server) as $item) {
+ if ($this->canCheck($item)) {
+ $this->check($server, $item['key']);
+ }
+ }
+
+ $finish = (array) $this->of($server->fresh());
+ $finish['last_checked_at'] = now()->toIso8601String();
+ $this->save($server, $finish);
+ }
+
+ public function fix(Server $server, string $key): array
+ {
+ return $this->update($server, $key, function (array $item) use ($server) {
+ if ($this->fixLabel($item) === null) {
+ throw new RuntimeException('Coolify cannot fix this step. Do it by hand, then mark it as done.');
+ }
+ $this->runFix($server, $item);
+
+ // Sentinel restarts in the background, so a check now would always fail.
+ return $item['type'] === 'sentinel'
+ ? ['pending', 'Sentinel restarts. The step turns OK when Sentinel sends data.', null]
+ : $this->runCheck($server, $item);
+ });
+ }
+
+ public function markDone(Server $server, string $key): array
+ {
+ return $this->update($server, $key, fn (array $item) => ['done', 'Marked as done by '.(auth()->user()?->name ?? 'a user').'.', null]);
+ }
+
+ /**
+ * The user finished the transfer. Open steps are not checked again; they are kept in the list as skipped.
+ */
+ public function finish(Server $server, User $user): void
+ {
+ $finish = $this->of($server) ?? throw new RuntimeException('This server has no transfer to finish.');
+ $finish['finished_at'] = now()->toIso8601String();
+ $finish['finished_by'] = $user->id;
+ $finish['skipped'] = array_column($this->openItems($server), 'key');
+ $this->save($server, $finish);
+ }
+
+ /**
+ * Webhook controllers call this when a verified webhook arrives, so a check can see that the new URL works.
+ */
+ public static function recordWebhook(string $kind, ?string $uuid): void
+ {
+ if (filled($uuid)) {
+ Cache::put("transfer-finish:webhook:{$kind}:{$uuid}", now()->toIso8601String(), now()->addDays(60));
+ }
+ }
+
+ /**
+ * @return list>
+ */
+ private function buildItems(Server $server): array
+ {
+ $applications = $server->applications()->unique('id')->values();
+ $serviceApplications = $server->services()->with('applications')->get()->flatMap->applications;
+ $runnerConfig = $server->githubRunnerConfig()->with('githubApp')->first();
+ $items = [
+ $this->item('server_connection', 'server', 'Connection to the server'),
+ $this->item('sentinel', 'server', 'Sentinel sends its data to this instance'),
+ ];
+
+ $githubApps = $applications->map(fn (Application $application) => $application->source)
+ ->push($runnerConfig?->githubApp)
+ ->filter(fn ($source) => $source instanceof GithubApp && ! $source->is_system_wide && ! $source->is_public)
+ ->unique('id');
+ foreach ($githubApps as $githubApp) {
+ $items[] = $this->item('github_app_webhook', $githubApp->uuid, "Webhook URL of GitHub App {$githubApp->name}",
+ 'GitHub must send webhooks to this instance.', [['label' => 'Webhook URL', 'value' => $this->githubWebhookUrl()]]);
+ $items[] = $this->item('github_app_urls', $githubApp->uuid, "Setup and Callback URL of GitHub App {$githubApp->name}",
+ 'Set both in the GitHub App settings. GitHub has no API for them.', [
+ ['label' => 'Setup URL', 'value' => $this->instanceUrl().'/webhooks/source/github/install'],
+ ['label' => 'Callback URL', 'value' => $this->instanceUrl().'/login/github/app'],
+ ]);
+ }
+
+ $gitlabApps = $applications->map(fn (Application $application) => $application->source)
+ ->filter(fn ($source) => $source instanceof GitlabApp && ! $source->is_system_wide)
+ ->unique('id');
+ foreach ($gitlabApps as $gitlabApp) {
+ $items[] = $this->item('gitlab_webhook', $gitlabApp->uuid, "GitLab webhooks of GitLab App {$gitlabApp->name}",
+ 'Set the webhook URL in each GitLab project that uses it, then push once.',
+ [['label' => 'Webhook URL', 'value' => $this->instanceUrl().'/webhooks/source/gitlab/events']]);
+ }
+
+ // These applications get a git source step instead: their source is not on this instance.
+ $gitSourceApplications = (array) data_get($server->server_metadata, 'transfer.git_source_applications', []);
+ foreach ($applications as $application) {
+ // A Docker image application keeps a default git_repository, but it never deploys from git.
+ if (! in_array($application->uuid, $gitSourceApplications, true) && $application->build_pack !== 'dockerimage' && filled($application->git_repository) && (! $application->source || data_get($application->source, 'is_public') === true)) {
+ $items[] = $this->item('manual_webhook', $application->uuid, "Manual webhook of {$application->name}",
+ 'If the repository has a webhook to Coolify, change its URL, then push once.',
+ array_map(fn (string $provider, string $label) => ['label' => $label, 'value' => "{$this->instanceUrl()}/webhooks/source/{$provider}/events/manual"],
+ ['github', 'gitlab', 'bitbucket', 'gitea'], ['GitHub', 'GitLab', 'Bitbucket', 'Gitea']));
+ }
+ }
+
+ foreach ($this->dnsRecords($applications, $serviceApplications) as $record) {
+ $items[] = $this->item('dns_record', $record->uuid, "DNS record {$record->name}");
+ }
+
+ $hosts = $applications->flatMap(fn (Application $application) => $application->fqdns)
+ ->concat($serviceApplications->flatMap(fn (ServiceApplication $application) => explode(',', (string) $application->fqdn)))
+ ->map(fn ($url) => strtolower((string) parse_url(trim((string) $url), PHP_URL_HOST)))
+ ->filter()
+ ->unique();
+ foreach ($hosts as $host) {
+ $items[] = $this->item('domain', $host, "Domain {$host} points to the server");
+ }
+
+ foreach ($this->s3Storages($server) as $storage) {
+ $items[] = $this->item('s3_storage', $storage->uuid, "S3 storage {$storage->name} works from this instance");
+ }
+
+ if ($runnerConfig) {
+ $items[] = $this->item('github_runners', $runnerConfig->uuid, 'GitHub runners on this server',
+ 'Disabled after the transfer. Enable them when the GitHub App sends its webhooks here.', [],
+ route('server.github-runners', ['server_uuid' => $server->uuid], false), 'Open runners');
+ }
+
+ // Links are relative: the list is built during the import request, whose host can be an internal one.
+ // The importer lists applications that used a system-wide git source, or a source that this instance lacks.
+ foreach ($applications->whereIn('uuid', $gitSourceApplications) as $application) {
+ $environment = $application->environment;
+ $items[] = $this->item('git_source', $application->uuid, "Git source of {$application->name}",
+ 'System-wide git sources are not transferred. Select a source of this instance.', [],
+ $environment?->project ? route('project.application.source', [
+ 'project_uuid' => $environment->project->uuid,
+ 'environment_uuid' => $environment->uuid,
+ 'application_uuid' => $application->uuid,
+ ], false) : null, 'Open source');
+ }
+
+ return $items;
+ }
+
+ /**
+ * @return array{0: string, 1: string, 2: string|null} status, short message, technical reason
+ */
+ private function runCheck(Server $server, array $item): array
+ {
+ $startedAt = Carbon::parse((string) data_get($this->of($server), 'started_at', now()->toIso8601String()));
+
+ return match ($item['type']) {
+ 'server_connection' => ($result = $server->validateConnection())['uptime']
+ ? ['ok', 'This instance connects to the server over SSH.', null]
+ : ['action', 'This instance cannot connect to the server over SSH.', $result['error']],
+ 'sentinel' => $server->sentinel_updated_at && Carbon::parse($server->sentinel_updated_at)->isAfter($startedAt)
+ ? ['ok', 'Sentinel sent data at '.Carbon::parse($server->sentinel_updated_at)->format('H:i').'.', null]
+ : ['action', 'No data from Sentinel since the transfer.', null],
+ 'github_app_webhook' => $this->checkGithubAppWebhook($item['subject'], $startedAt),
+ 'gitlab_webhook' => $this->checkWebhookReceipt('gitlab_app', $item['subject'], $startedAt),
+ 'manual_webhook' => $this->checkWebhookReceipt('application', $item['subject'], $startedAt),
+ 'dns_record' => $this->checkDnsRecord($item['subject']),
+ 'domain' => $this->checkDomain($server, $item['subject']),
+ 's3_storage' => $this->checkS3Storage($item['subject']),
+ 'git_source' => $this->checkGitSource($item['subject']),
+ 'github_runners' => $server->githubRunnerConfig()->value('is_enabled')
+ ? ['ok', 'GitHub runners are enabled.', null]
+ : ['action', 'Disabled after the transfer. Enable them when the GitHub App sends its webhooks here.', null],
+ default => throw new RuntimeException('Coolify cannot check this step. Do it by hand, then mark it as done.'),
+ };
+ }
+
+ private function runFix(Server $server, array $item): void
+ {
+ match ($item['type']) {
+ 'github_app_webhook' => $this->githubAppRequest(GithubApp::where('uuid', $item['subject'])->firstOrFail(), 'patch', '/app/hook/config', ['url' => $this->githubWebhookUrl()]),
+ 'sentinel' => $server->restartSentinel(),
+ 'dns_record' => app(CloudflareDnsProvider::class)->reclaimRecord(ManagedDnsRecord::where('uuid', $item['subject'])->firstOrFail())
+ ? ManagedDnsRecord::where('uuid', $item['subject'])->update(['owned' => true])
+ : throw new RuntimeException('Coolify could not take ownership: the record changed at Cloudflare, or Cloudflare cannot be reached.'),
+ };
+ }
+
+ /**
+ * @return array{0: string, 1: string, 2: string|null}
+ */
+ private function checkGithubAppWebhook(string $uuid, Carbon $startedAt): array
+ {
+ $githubApp = GithubApp::where('uuid', $uuid)->first();
+ if (! $githubApp) {
+ return ['unchecked', 'The GitHub App does not exist on this instance anymore.', null];
+ }
+
+ try {
+ $url = (string) data_get($this->githubAppRequest($githubApp, 'get', '/app/hook/config'), 'url');
+ } catch (Throwable $e) {
+ // Without the API, a webhook that arrives here still proves the URL.
+ [$status, $message] = $this->checkWebhookReceipt('github_app', $uuid, $startedAt);
+
+ return [$status, $status === 'ok' ? $message : 'GitHub cannot be asked with the App key. '.$message, $e->getMessage()];
+ }
+
+ return rtrim($url, '/') === $this->githubWebhookUrl()
+ ? ['ok', 'GitHub sends webhooks to this instance.', null]
+ : ['action', 'GitHub sends webhooks to another URL.', 'Current URL: '.($url ?: 'none')];
+ }
+
+ /**
+ * @return array{0: string, 1: string, 2: string|null}
+ */
+ private function checkWebhookReceipt(string $kind, string $uuid, Carbon $startedAt): array
+ {
+ $receivedAt = Cache::get("transfer-finish:webhook:{$kind}:{$uuid}");
+ if (is_string($receivedAt) && Carbon::parse($receivedAt)->isAfter($startedAt)) {
+ return ['ok', 'A webhook arrived at '.Carbon::parse($receivedAt)->format('H:i').'.', null];
+ }
+
+ return ['action', 'No webhook arrived since the transfer.', null];
+ }
+
+ /**
+ * @return array{0: string, 1: string, 2: string|null}
+ */
+ private function checkDnsRecord(string $uuid): array
+ {
+ $record = ManagedDnsRecord::where('uuid', $uuid)->first();
+ if (! $record) {
+ return ['unchecked', 'This instance does not track this record anymore.', null];
+ }
+ if ($record->owned && app(CloudflareDnsProvider::class)->reclaimRecord($record)) {
+ return ['ok', 'This instance manages the record at Cloudflare.', null];
+ }
+
+ return $record->owned
+ ? ['action', 'Cloudflare does not confirm the record.', 'The record changed at Cloudflare, or Cloudflare cannot be reached with the token.']
+ : ['action', 'This instance does not own the record yet.', 'Until it owns the record, this instance does not change or delete it.'];
+ }
+
+ /**
+ * @return array{0: string, 1: string, 2: string|null}
+ */
+ private function checkDomain(Server $server, string $host): array
+ {
+ $serverIps = filter_var($server->ip, FILTER_VALIDATE_IP) ? [$server->ip] : (gethostbynamel($server->ip) ?: []);
+ $records = @dns_get_record($host, DNS_A | DNS_AAAA) ?: [];
+ $ips = array_values(array_filter(array_map(fn (array $record) => $record['ip'] ?? $record['ipv6'] ?? null, $records)));
+ if ($ips === []) {
+ return ['action', 'The domain does not resolve.', null];
+ }
+ if (array_intersect($ips, $serverIps) !== []) {
+ return ['ok', 'The domain points to the server.', null];
+ }
+
+ return ['action', 'The domain points to another address.', 'Resolves to '.implode(', ', $ips).'; the server is '.implode(', ', $serverIps ?: [$server->ip]).'. If a proxy or load balancer is in front, mark it as done.'];
+ }
+
+ /**
+ * @return array{0: string, 1: string, 2: string|null}
+ */
+ private function checkS3Storage(string $uuid): array
+ {
+ $storage = S3Storage::where('uuid', $uuid)->first();
+ if (! $storage) {
+ return ['unchecked', 'The S3 storage does not exist on this instance anymore.', null];
+ }
+ try {
+ $storage->testConnection(shouldSave: true);
+ } catch (Throwable $e) {
+ return ['action', 'This instance cannot use the S3 storage.', $e->getMessage()];
+ }
+
+ return $storage->fresh()->is_usable ? ['ok', 'This instance can use the S3 storage.', null] : ['action', 'This instance cannot use the S3 storage.', null];
+ }
+
+ private function githubAppRequest(GithubApp $githubApp, string $method, string $path, array $data = []): array
+ {
+ $response = Http::GitSource($githubApp->api_url)
+ ->withHeaders(['Authorization' => 'Bearer '.generateGithubJwt($githubApp), 'Accept' => 'application/vnd.github+json'])
+ ->{$method}(rtrim($githubApp->api_url, '/').$path, $data);
+ if (! $response->successful()) {
+ throw new RuntimeException('GitHub answered with HTTP '.$response->status().': '.data_get($response->json(), 'message', 'no details'));
+ }
+
+ return (array) $response->json();
+ }
+
+ /**
+ * @param Collection $applications
+ * @param Collection $serviceApplications
+ * @return Collection
+ */
+ private function dnsRecords(Collection $applications, Collection $serviceApplications): Collection
+ {
+ $ids = ManagedDnsRecordReference::query()
+ ->where(function ($query) use ($applications, $serviceApplications) {
+ $query->where(fn ($q) => $q->where('resource_type', (new Application)->getMorphClass())->whereIn('resource_id', $applications->pluck('id')))
+ ->orWhere(fn ($q) => $q->where('resource_type', (new ServiceApplication)->getMorphClass())->whereIn('resource_id', $serviceApplications->pluck('id')));
+ })
+ ->pluck('managed_dns_record_id');
+
+ return ManagedDnsRecord::query()->whereIn('id', $ids)->orderBy('name')->get();
+ }
+
+ /**
+ * @return Collection
+ */
+ private function s3Storages(Server $server): Collection
+ {
+ $ids = collect($server->databases())
+ ->flatMap(fn ($database) => method_exists($database, 'scheduledBackups') ? $database->scheduledBackups()->get() : [])
+ ->flatMap(fn ($backup) => [$backup->s3_storage_id, ...$backup->s3Storages()->pluck('s3_storages.id')->all()])
+ ->filter()
+ ->unique();
+
+ return S3Storage::query()->whereIn('id', $ids)->where('team_id', $server->team_id)->get();
+ }
+
+ /**
+ * @param callable(array): array{0: string, 1: string} $change
+ * @return array
+ */
+ private function update(Server $server, string $key, callable $change): array
+ {
+ $finish = $this->of($server) ?? throw new RuntimeException('This server has no transfer to finish.');
+ $index = array_search($key, array_column($finish['items'], 'key'), true);
+ if ($index === false) {
+ throw new RuntimeException('This step is not in the list.');
+ }
+
+ try {
+ [$status, $message, $error] = $change($finish['items'][$index]) + [2 => null];
+ } catch (Throwable $e) {
+ [$status, $message, $error] = ['action', 'Coolify could not do this step.', $e->getMessage()];
+ }
+
+ return DB::transaction(function () use ($server, $key, $status, $message, $error) {
+ $finish = (array) data_get(Server::query()->lockForUpdate()->findOrFail($server->id)->server_metadata, 'transfer.finish');
+ $index = array_search($key, array_column($finish['items'], 'key'), true);
+ $finish['items'][$index] = [...$finish['items'][$index], 'status' => $status, 'message' => $message, 'error' => $error, 'checked_at' => now()->toIso8601String()];
+ $this->save($server, $finish);
+
+ return $finish['items'][$index];
+ });
+ }
+
+ /**
+ * Writes only transfer.finish, on the current row: callers can hold an older copy of the server, for example
+ * from before the claim, and must not write its other metadata back.
+ */
+ private function save(Server $server, array $finish): void
+ {
+ DB::transaction(function () use ($server, $finish) {
+ $locked = Server::query()->lockForUpdate()->findOrFail($server->id);
+ $metadata = $locked->server_metadata ?? [];
+ $metadata['transfer']['finish'] = $finish;
+ $locked->server_metadata = $metadata;
+ $locked->save();
+ $server->server_metadata = $metadata;
+ $server->syncOriginalAttribute('server_metadata');
+ });
+ TransferFinishChanged::dispatch($server);
+ }
+
+ /**
+ * @return array{0: string, 1: string}
+ */
+ private function checkGitSource(string $applicationUuid): array
+ {
+ $application = Application::where('uuid', $applicationUuid)->first();
+ $source = $application?->source;
+ if (! $source instanceof GithubApp && ! $source instanceof GitlabApp) {
+ return ['action', 'No git source on this instance, so deployments from git fail.', null];
+ }
+ if ($source instanceof GithubApp && ! $source->is_public) {
+ try {
+ generateGithubInstallationToken($source);
+ } catch (Throwable $e) {
+ return ['action', "GitHub App {$source->name} cannot read the repository.", $e->getMessage()];
+ }
+ }
+
+ // Push webhooks of a system-wide App go to the instance where the App is registered.
+ return ['ok', "Uses {$source->name}.", $source->is_system_wide ? 'For push-to-deploy, use a manual webhook if the system-wide App is registered on another instance.' : null];
+ }
+
+ /**
+ * @return array
+ */
+ /**
+ * @param list $details
+ */
+ private function item(string $type, string $subject, string $label, ?string $hint = null, array $details = [], ?string $link = null, ?string $linkLabel = null): array
+ {
+ return [
+ 'key' => "{$type}:{$subject}",
+ 'type' => $type,
+ 'subject' => $subject,
+ 'label' => $label,
+ 'status' => 'pending',
+ 'hint' => $hint,
+ 'message' => null,
+ 'details' => $details,
+ 'error' => null,
+ 'link' => $link,
+ 'link_label' => $linkLabel,
+ 'checked_at' => null,
+ ];
+ }
+
+ private function instanceUrl(): string
+ {
+ return rtrim((string) (instanceSettings()->fqdn ?: config('app.url')), '/');
+ }
+
+ private function githubWebhookUrl(): string
+ {
+ return $this->instanceUrl().'/webhooks/source/github/events';
+ }
+}
diff --git a/docker-compose.dev-side-by-side.yml b/docker-compose.dev-side-by-side.yml
new file mode 100644
index 0000000000..5c8404495a
--- /dev/null
+++ b/docker-compose.dev-side-by-side.yml
@@ -0,0 +1,9 @@
+# Added by scripts/dev for side-by-side instances (COOLIFY_DEV_SIDE_BY_SIDE=1). Two instances of one
+# checkout share storage/, and both have a server with the uuid "localhost", so they would share the
+# SSH key and multiplexing files. This gives the instance its own SSH folder.
+services:
+ coolify:
+ volumes:
+ - ssh_data:/var/www/html/storage/app/ssh
+volumes:
+ ssh_data:
diff --git a/docs/server-transfer-full-coverage.md b/docs/server-transfer-full-coverage.md
new file mode 100644
index 0000000000..447798cb90
--- /dev/null
+++ b/docs/server-transfer-full-coverage.md
@@ -0,0 +1,139 @@
+# Full-coverage server for transfer tests
+
+Set up one source server with everything below, transfer it, then compare each item on the target.
+Use values that are NOT the default, so that a lost value is visible after the transfer.
+
+Use the kit in `scripts/transfer-test/` (see its README): it starts the source (A) and the target (B) and seeds A with a basic set of deployed resources.
+
+Legend: ✅ `scripts/transfer-test/seed.sh` or `seed-extras.php` already creates it · ➕ add it · ⚠️ needs special infrastructure
+
+The models come from `ServerTransferCoverage::TRANSFERRED` (`app/Services/ServerTransfer/ServerTransferCoverage.php`).
+When you add a model or a setting to the transfer, add it to this file too.
+
+## 1. Server (Server → General / Proxy)
+
+| ✓ | What | Value to set |
+|---|---|---|
+| ✅ | Non-root SSH user | a dev VM with the `ubuntu-non-root` profile |
+| ➕ | Name, description | any text |
+| ➕ | Port | other than 22, if the VM allows it |
+| ➕ | Proxy: redirect | redirect enabled + target URL |
+| ✅ | Proxy: custom error page | custom HTML (`proxy_error_page`) |
+| ➕ | Server role / build server | mark a second server as a build server |
+| ⚠️ | Cloud provider link | Hetzner/Vultr/DigitalOcean/Hostinger server ID + cloud provider token (needs a real cloud server) |
+| ⚠️ | Swarm | swarm manager / worker + swarm cluster + swarm destination (needs a swarm server) |
+
+## 2. Server settings (Server → Advanced, Log drains, Sentinel/Metrics, Traffic, Cloudflare tunnel)
+
+| ✓ | What | Value to set |
+|---|---|---|
+| ➕ | Wildcard domain | `http://{ip}.sslip.io` style, not empty |
+| ➕ | Concurrent builds, dynamic timeout, deployment queue limit, connection timeout | 3, 1800, 5, 20 |
+| ➕ | Server timezone | `Europe/Budapest` |
+| ➕ | Docker cleanup | force cleanup on, frequency `0 4 * * *`, threshold 70, delete unused volumes on, delete unused networks on |
+| ➕ | Disk usage notification | threshold 70, check frequency `*/30 * * * *`, interval 12 h |
+| ➕ | Terminal | terminal access off |
+| ➕ | Image retention | "disable application image retention" on |
+| ➕ | Labels / Traefik | generate exact labels on, Traefik certificate resolver `custom` |
+| ➕ | Backup compression | CPU percentage 50 |
+| ➕ | Metrics / Sentinel | metrics on, refresh rate 10 s, history 14 days, push interval 30 s |
+| ➕ | Traffic analytics | on, top N 20, sample threshold, retention 1h=3 / 1d=60 days, IP mode `anonymized` |
+| ➕ | GeoIP | on, MaxMind license key, refresh 14 days |
+| ➕ | Log drains | New Relic (key + base URI), Highlight (project ID), Axiom (dataset + key), custom (config + parser), one of them enabled |
+| ✅ | Cloudflare tunnel | tunnel on, tunnel ID, account ID, Cloudflare integration token selected |
+| ➕ | Jump server flag | on (on a server that is only a jump host) |
+
+## 3. Server-level records
+
+| ✓ | What | Notes |
+|---|---|---|
+| ✅ | Default destination | created automatically |
+| ➕ | Second standalone destination | extra Docker network, one resource on it |
+| ➕ | Server shared variables | 2 variables, one multiline, one literal, one with a comment |
+| ➕ | SSL certificates | CA certificate (created automatically) + a database with SSL on (creates a server certificate) |
+| ✅ | GitHub runner config | labels, max runners 2, docker mode `dind`, image, CPU/memory limits, 3 timeouts, PR jobs off, dedicated on, uses the team GitHub App (see 4). Expected: on the target it is **disabled** and the checklist says to enable it |
+
+## 4. Team credentials the server uses
+
+| ✓ | What | Notes |
+|---|---|---|
+| ✅ | Server private key | |
+| ➕ | Deploy key | a second private key used by an application (git over SSH) |
+| ✅ | GitHub App (team, not system-wide) | with runner fields (`organization_self_hosted_runners`, `actions`, `webhook_events`, `runner_group_id`). Expected: checklist step for Webhook/Setup/Callback URL |
+| ➕ | GitLab App (team) | used by one application. Expected: checklist step for the webhook URL |
+| ➕ | S3 storage | the dev MinIO service (the VM must reach it), used by a database backup and a volume backup |
+| ✅ | Cloudflare integration token | for DNS zones and the tunnel |
+| ✅ | Secret manager token | Infisical, Doppler, or Vault |
+| ⚠️ | Cloud provider token | only with a real cloud server |
+
+## 5. Projects and environments
+
+| ✓ | What | Notes |
+|---|---|---|
+| ✅ | 2 projects, 2 environments | for example production and staging |
+| ✅/➕ | Project / environment shared variables | the seed tries to create them; check that they exist |
+| ✅ | Team shared variable | `TEAM_KEY`, used as `{{team.TEAM_KEY}}` in an application variable. Expected: created on the target |
+| ✅ | Project description + icon (JPEG) | Expected: the icon is on the target |
+| ✅ | Canvas positions | drag the nodes on the environment canvas |
+| ✅ | Resource links | a link between 2 resources on this server, and a link to a resource on another server. Expected: the first moves, the second does not, and the export warns |
+| ✅ | Environment with resources only on another server | Expected: it is not exported, and the prechecks show a `NOTE:` line |
+
+## 6. Applications (one per build pack and source)
+
+| ✓ | What | Notes |
+|---|---|---|
+| ✅ | Docker image app (whoami) | |
+| ✅ | Dockerfile app | |
+| ➕ | Nixpacks app, public GitHub repo | Expected: checklist step for manual webhooks |
+| ➕ | Static app (`is_static`, `is_spa`) | |
+| ➕ | Docker Compose app | raw compose on, one variable with an empty value (`preserve_compose_null`) |
+| ➕ | App with GitHub App source, app with GitLab App source, app with deploy key | |
+| ➕ | Domains | 2 domains, noindex domains, domain port overrides, preview URL template |
+| ➕ | Health check | custom path, port, method, return code, interval, retries, start period |
+| ➕ | Limits | memory, swap, swappiness, reservation, CPUs, cpuset, CPU shares |
+| ➕ | Advanced | custom labels, custom docker run options, pre/post deployment command + container, manual webhook secrets |
+| ✅ | Maintenance | maintenance mode on + custom maintenance page |
+| ➕ | All application settings that are not the default | git submodules, LFS, shallow clone, auto deploy off, force HTTPS, debug, preview deployments on, PR deployments public, log drain, GPU (driver, count, device IDs, options), include timestamps, swarm-only worker nodes, build server, consistent container name, gzip off, strip prefix off, connect to Docker network, custom internal name, container label escape off, env sorting, read-only labels, preserve repository, disable build cache, use build secrets, stop grace period 30, inject build args off, include source commit, Docker images to keep 5, custom container name prefix, redact env values in logs, Traefik certificate resolver |
+| ➕ | Environment variables | normal, multiline, literal, shown once, build time only, runtime only, required, with comment, preview-only, and references to `{{team.*}}`, `{{project.*}}`, `{{environment.*}}`, `{{server.*}}` |
+| ✅/➕ | Persistent volume | the seed has one; add a host-path bind mount and preview suffix on |
+| ➕ | File mount + directory mount | content, chown, chmod |
+| ➕ | Volume backup | on the persistent volume: S3 on, stop during backup, all retention values, timeout, missing-backup days |
+| ✅/➕ | Scheduled tasks | the seed has one; add a disabled one with container + timeout |
+| ✅ | Tag | |
+| ➕ | Preview deployment | PR preview with domain port overrides + max restart count (needs the GitHub App, or create it in the database) |
+| ✅ | Secret manager link | the app loads variables from the secret manager token |
+| ✅ | Managed DNS record | owned record in the Cloudflare zone for the app domain. Expected: the target takes ownership (provider comment changes) |
+
+## 7. Databases (one of each of the 9 types)
+
+| ✓ | Type | Extra settings |
+|---|---|---|
+| ✅ | PostgreSQL | + init script, `postgres_conf`, host auth method, SSL on + mode |
+| ➕ | MySQL | + `mysql_conf`, SSL |
+| ✅ | MariaDB | + `mariadb_conf`, SSL |
+| ➕ | MongoDB | + `mongo_conf`, SSL |
+| ✅ | Redis | + `redis_conf`, SSL |
+| ➕ | KeyDB, Dragonfly, ClickHouse | defaults are enough, plus the common settings below |
+| ➕ | SQLite | database files list, volume link |
+
+Common settings on at least one database: custom image, public port + public on + public port timeout, limits, custom docker run options, health check values, log drain, include timestamps, redact env values, environment variables, persistent volume + volume backup, tags, secret manager link (if the type supports it).
+
+Database backups (✅ one exists): add S3 on, dump all, databases list, local and S3 retention (amount, days, max storage), timeout, disable local backup, missing-backup days.
+
+## 8. Services
+
+| ✓ | What | Notes |
+|---|---|---|
+| ✅ | One-click service (uptime-kuma) | |
+| ➕ | Custom compose service with an app and a database part | |
+| ➕ | Service app settings | domain, gzip off, strip prefix off, force HTTPS, exclude from status, redirect, port overrides, log drain, custom image, Traefik resolver |
+| ➕ | Service database settings | public port + public on, custom type |
+| ➕ | Service level | connect to network, label escape off, redact env values, maintenance mode + page, environment variables, scheduled task, tags, volume backup on a service volume |
+
+## 9. After the transfer, check
+
+- Transfer log: every check `OK`, the `NOTE:` line for the shared project.
+- "After the transfer" checklist: GitHub App URLs, GitLab webhook, manual webhooks, GitHub runners. All URLs use the public URL of the target.
+- Target: every item above has the same value. Runner config disabled. DNS records owned by the target.
+- Source: the server is an archive, the runner config is disabled, and no DNS records are tracked.
+- Delete the archive on the source: the containers, DNS records, and GitHub runners are not changed.
diff --git a/openapi.json b/openapi.json
index 6b0bd8e12a..075213afee 100644
--- a/openapi.json
+++ b/openapi.json
@@ -17611,6 +17611,304 @@
]
}
},
+ "\/servers\/{uuid}\/migrate": {
+ "post": {
+ "tags": [
+ "Servers"
+ ],
+ "summary": "Migrate server to another Coolify instance",
+ "description": "Start a transfer in the background: export this server, import and claim it on the target instance (with the given token), then disable automations here. Follow the progress with GET \/servers\/{uuid}\/transfer. The target URL must use https. Requires read:sensitive and write.",
+ "operationId": "migrate-server-between-instances",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application\/json": {
+ "schema": {
+ "required": [
+ "target_url",
+ "target_token"
+ ],
+ "properties": {
+ "target_url": {
+ "type": "string",
+ "example": "https:\/\/coolify-b.example.com"
+ },
+ "target_token": {
+ "description": "API token on the target instance (root or write)",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "responses": {
+ "202": {
+ "description": "Transfer started"
+ },
+ "403": {
+ "description": "Missing sensitive permission"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ },
+ "422": {
+ "description": "Validation or remote import failed"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "\/servers\/{uuid}\/transfer": {
+ "get": {
+ "tags": [
+ "Servers"
+ ],
+ "summary": "Get server transfer status",
+ "description": "Show the transfer state of a server on this instance: the import\/claim state (transfer) and the state of a running or finished transfer to another instance (migration).",
+ "operationId": "get-server-transfer-status",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Transfer status"
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "\/servers\/import\/check": {
+ "get": {
+ "tags": [
+ "Servers"
+ ],
+ "summary": "Check server import",
+ "description": "Check that this instance can import a server, before the source instance starts the transfer. It checks the token, the bundle version, the server limit, servers with the same UUID or IP, and that this instance can reach the SSH port of the server.",
+ "operationId": "check-server-import",
+ "parameters": [
+ {
+ "name": "server_uuid",
+ "in": "query",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "ip",
+ "in": "query",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "port",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer",
+ "default": 22
+ }
+ },
+ {
+ "name": "schema_version",
+ "in": "query",
+ "required": false,
+ "schema": {
+ "type": "integer"
+ }
+ },
+ {
+ "name": "system_github_apps[]",
+ "in": "query",
+ "description": "UUIDs of system-wide GitHub Apps that the server uses. The response lists the ones this instance does not have.",
+ "required": false,
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ },
+ {
+ "name": "system_gitlab_apps[]",
+ "in": "query",
+ "description": "UUIDs of system-wide GitLab Apps that the server uses. The response lists the ones this instance does not have.",
+ "required": false,
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The server can be imported. Lists every check with its result."
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "422": {
+ "description": "One or more checks failed. Lists every check with its result."
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "\/servers\/import": {
+ "post": {
+ "tags": [
+ "Servers"
+ ],
+ "summary": "Import server transfer bundle",
+ "description": "Import a server transfer bundle into this Coolify instance (adopt mode by default). This instance then manages the server, and Sentinel reports to it.",
+ "operationId": "import-server-transfer-bundle",
+ "requestBody": {
+ "required": true,
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "bundle": {
+ "description": "Transfer bundle",
+ "type": "object"
+ },
+ "dry_run": {
+ "type": "boolean",
+ "default": false
+ },
+ "preserve_uuids": {
+ "type": "boolean",
+ "default": true
+ },
+ "adopt_mode": {
+ "description": "Import without forcing redeploy; keep statuses for adoption",
+ "type": "boolean",
+ "default": true
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Dry-run result"
+ },
+ "201": {
+ "description": "Imported"
+ },
+ "401": {
+ "$ref": "#\/components\/responses\/401"
+ },
+ "422": {
+ "description": "Validation failed"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
+ "\/servers\/{uuid}\/transfer\/complete": {
+ "post": {
+ "tags": [
+ "Servers"
+ ],
+ "summary": "Mark server transferred",
+ "description": "Source-instance recovery step: disable automations here when the target already manages the server but the transfer could not finish on this instance.",
+ "operationId": "complete-server-transfer",
+ "parameters": [
+ {
+ "name": "uuid",
+ "in": "path",
+ "required": true,
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "requestBody": {
+ "content": {
+ "application\/json": {
+ "schema": {
+ "properties": {
+ "export_id": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "target_instance_url": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ "type": "object"
+ }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Marked transferred"
+ },
+ "404": {
+ "$ref": "#\/components\/responses\/404"
+ }
+ },
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ]
+ }
+ },
"\/servers": {
"get": {
"tags": [
diff --git a/openapi.yaml b/openapi.yaml
index d5ec8bbd2d..c91d927233 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -11131,6 +11131,204 @@ paths:
security:
-
bearerAuth: []
+ '/servers/{uuid}/migrate':
+ post:
+ tags:
+ - Servers
+ summary: 'Migrate server to another Coolify instance'
+ description: 'Start a transfer in the background: export this server, import and claim it on the target instance (with the given token), then disable automations here. Follow the progress with GET /servers/{uuid}/transfer. The target URL must use https. Requires read:sensitive and write.'
+ operationId: migrate-server-between-instances
+ parameters:
+ -
+ name: uuid
+ in: path
+ required: true
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ required:
+ - target_url
+ - target_token
+ properties:
+ target_url:
+ type: string
+ example: 'https://coolify-b.example.com'
+ target_token:
+ description: 'API token on the target instance (root or write)'
+ type: string
+ type: object
+ responses:
+ '202':
+ description: 'Transfer started'
+ '403':
+ description: 'Missing sensitive permission'
+ '404':
+ $ref: '#/components/responses/404'
+ '422':
+ description: 'Validation or remote import failed'
+ security:
+ -
+ bearerAuth: []
+ '/servers/{uuid}/transfer':
+ get:
+ tags:
+ - Servers
+ summary: 'Get server transfer status'
+ description: 'Show the transfer state of a server on this instance: the import/claim state (transfer) and the state of a running or finished transfer to another instance (migration).'
+ operationId: get-server-transfer-status
+ parameters:
+ -
+ name: uuid
+ in: path
+ required: true
+ schema:
+ type: string
+ responses:
+ '200':
+ description: 'Transfer status'
+ '401':
+ $ref: '#/components/responses/401'
+ '404':
+ $ref: '#/components/responses/404'
+ security:
+ -
+ bearerAuth: []
+ /servers/import/check:
+ get:
+ tags:
+ - Servers
+ summary: 'Check server import'
+ description: 'Check that this instance can import a server, before the source instance starts the transfer. It checks the token, the bundle version, the server limit, servers with the same UUID or IP, and that this instance can reach the SSH port of the server.'
+ operationId: check-server-import
+ parameters:
+ -
+ name: server_uuid
+ in: query
+ required: true
+ schema:
+ type: string
+ -
+ name: ip
+ in: query
+ required: true
+ schema:
+ type: string
+ -
+ name: port
+ in: query
+ required: false
+ schema:
+ type: integer
+ default: 22
+ -
+ name: schema_version
+ in: query
+ required: false
+ schema:
+ type: integer
+ -
+ name: 'system_github_apps[]'
+ in: query
+ description: 'UUIDs of system-wide GitHub Apps that the server uses. The response lists the ones this instance does not have.'
+ required: false
+ schema:
+ type: array
+ items:
+ type: string
+ -
+ name: 'system_gitlab_apps[]'
+ in: query
+ description: 'UUIDs of system-wide GitLab Apps that the server uses. The response lists the ones this instance does not have.'
+ required: false
+ schema:
+ type: array
+ items:
+ type: string
+ responses:
+ '200':
+ description: 'The server can be imported. Lists every check with its result.'
+ '401':
+ $ref: '#/components/responses/401'
+ '422':
+ description: 'One or more checks failed. Lists every check with its result.'
+ security:
+ -
+ bearerAuth: []
+ /servers/import:
+ post:
+ tags:
+ - Servers
+ summary: 'Import server transfer bundle'
+ description: 'Import a server transfer bundle into this Coolify instance (adopt mode by default). This instance then manages the server, and Sentinel reports to it.'
+ operationId: import-server-transfer-bundle
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ properties:
+ bundle:
+ description: 'Transfer bundle'
+ type: object
+ dry_run:
+ type: boolean
+ default: false
+ preserve_uuids:
+ type: boolean
+ default: true
+ adopt_mode:
+ description: 'Import without forcing redeploy; keep statuses for adoption'
+ type: boolean
+ default: true
+ type: object
+ responses:
+ '200':
+ description: 'Dry-run result'
+ '201':
+ description: Imported
+ '401':
+ $ref: '#/components/responses/401'
+ '422':
+ description: 'Validation failed'
+ security:
+ -
+ bearerAuth: []
+ '/servers/{uuid}/transfer/complete':
+ post:
+ tags:
+ - Servers
+ summary: 'Mark server transferred'
+ description: 'Source-instance recovery step: disable automations here when the target already manages the server but the transfer could not finish on this instance.'
+ operationId: complete-server-transfer
+ parameters:
+ -
+ name: uuid
+ in: path
+ required: true
+ schema:
+ type: string
+ requestBody:
+ content:
+ application/json:
+ schema:
+ properties:
+ export_id:
+ type: [string, 'null']
+ target_instance_url:
+ type: [string, 'null']
+ type: object
+ responses:
+ '200':
+ description: 'Marked transferred'
+ '404':
+ $ref: '#/components/responses/404'
+ security:
+ -
+ bearerAuth: []
/servers:
get:
tags:
diff --git a/resources/css/app.css b/resources/css/app.css
index 0efb327b20..c46f1ddf06 100644
--- a/resources/css/app.css
+++ b/resources/css/app.css
@@ -5775,7 +5775,7 @@ html.dark .canvas-zoom-max {
}
/* Resource menu in the collapsed main sidebar: icons only. */
-.sidebar-collapsed .nav-context-menu :is(.menu-item-label, .nav-section-toggle, .nav-children, .menu-item > :not(.menu-item-icon)) {
+.sidebar-collapsed .nav-context-menu :is(.menu-item-label, .nav-children, .menu-item > :not(.menu-item-icon)) {
display: none !important;
}
diff --git a/resources/css/utilities.css b/resources/css/utilities.css
index 83fc9cbc34..a35cb76471 100644
--- a/resources/css/utilities.css
+++ b/resources/css/utilities.css
@@ -249,11 +249,6 @@
@apply px-2.5 pt-1 pb-1 text-[11px] font-medium text-nav-muted select-none;
}
-/* Collapsible group header (accordion) for the resource settings sidebar. */
-@utility nav-section-toggle {
- @apply w-full items-center justify-between gap-2 px-2.5 pt-1 pb-1 text-[11px] font-medium text-nav-muted select-none rounded-md transition-colors cursor-pointer hover:text-nav-active;
-}
-
/* Indented child rows in a collapsible nav group */
@utility menu-subitem {
/* Label owns text ellipsis; keep this row overflow-visible so the focus ring is not clipped. */
diff --git a/resources/js/app.js b/resources/js/app.js
index c9adb0f307..7e54193431 100644
--- a/resources/js/app.js
+++ b/resources/js/app.js
@@ -1,6 +1,5 @@
import { initializeCopyButtonComponent } from './copy-button.js';
import { initializeRuntimeLogsComponent } from './runtime-logs.js';
-import { initializeSettingsSidebarAccordionComponent } from './settings-sidebar-accordion.js';
import { initializeTerminalComponent } from './terminal.js';
import './traffic-globe.js';
import './traffic-table.js';
@@ -50,7 +49,6 @@ document.addEventListener('livewire:navigated', () => {
document.addEventListener('alpine:init', initializeTerminalComponent);
document.addEventListener('alpine:init', initializeCopyButtonComponent);
document.addEventListener('alpine:init', initializeRuntimeLogsComponent);
-document.addEventListener('alpine:init', initializeSettingsSidebarAccordionComponent);
/**
* Smooth-scroll a settings section into view, then flash its border for 500ms
diff --git a/resources/js/settings-sidebar-accordion.js b/resources/js/settings-sidebar-accordion.js
deleted file mode 100644
index 11dec0c320..0000000000
--- a/resources/js/settings-sidebar-accordion.js
+++ /dev/null
@@ -1,42 +0,0 @@
-// Alpine data provider for the collapsible resource settings sidebar
-// (x-data="settingsSidebarAccordion({ activeGroup, storageKey })").
-//
-// Every group is open by default; each group can be collapsed/expanded and the
-// choice is remembered per resource type. The active group is always forced open
-// on load so the current page stays reachable.
-export function initializeSettingsSidebarAccordionComponent() {
- window.Alpine.data('settingsSidebarAccordion', (config = {}) => ({
- activeGroup: config.activeGroup || '',
- storageKey: config.storageKey || 'coolify.settings-sidebar',
- groups: {},
- init() {
- let stored = {};
- try {
- stored = JSON.parse(localStorage.getItem(this.storageKey)) || {};
- } catch (e) {
- stored = {};
- }
- this.groups = stored && typeof stored === 'object' ? stored : {};
- },
- isOpen(group) {
- // The current page must stay visible, even when this group was
- // previously stored as collapsed on another page.
- if (group === this.activeGroup) {
- return true;
- }
-
- if (Object.prototype.hasOwnProperty.call(this.groups, group)) {
- return this.groups[group];
- }
- return true;
- },
- toggle(group) {
- this.groups = { ...this.groups, [group]: !this.isOpen(group) };
- try {
- localStorage.setItem(this.storageKey, JSON.stringify(this.groups));
- } catch (e) {
- // ignore storage errors (private mode, quota, etc.)
- }
- },
- }));
-}
diff --git a/resources/views/components/application/configuration-sidebar.blade.php b/resources/views/components/application/configuration-sidebar.blade.php
index 8b61ea5b72..29f56af40d 100644
--- a/resources/views/components/application/configuration-sidebar.blade.php
+++ b/resources/views/components/application/configuration-sidebar.blade.php
@@ -159,7 +159,7 @@
'Git Source' => 'sources',
'Servers' => 'servers',
'Scheduled Tasks' => 'calendar',
- 'Webhooks' => 'notifications',
+ 'Webhooks' => 'webhook',
'Preview Deployments' => 'eye',
'Healthcheck' => 'feedback',
'Rollback' => 'time-back',
@@ -178,7 +178,6 @@
'Deploy' => ['Git Source', 'Servers', 'Preview Deployments'],
'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups'],
'Operations' => ['Resource Operations', 'Resource Limits', 'Rollback', 'Tags'],
- // Always last, after a divider and without a group header.
'Danger Zone' => ['Danger Zone'],
];
$groupedMenuItems = collect($menuGroups)
@@ -188,9 +187,6 @@
->values())
->filter(fn ($items) => $items->isNotEmpty());
- // Group that holds the current page — always kept open, even if collapsed before.
- $activeGroup = (string) $groupedMenuItems->search(fn ($items) => $items->contains(fn ($item) => $item['active'] ?? false));
-
// In-page sections (cards) shown as sub-items under the active page
$isComposeApp = $application->build_pack === 'dockercompose';
$pageSections = [
@@ -274,7 +270,6 @@
]) data-settings-search-items="{{ json_encode($settingsSearchItems) }}">
@endif
@if ($placement === 'main-nav')
diff --git a/resources/views/components/backup-sidebar.blade.php b/resources/views/components/backup-sidebar.blade.php
index a17274bab3..900a0be0b6 100644
--- a/resources/views/components/backup-sidebar.blade.php
+++ b/resources/views/components/backup-sidebar.blade.php
@@ -55,7 +55,6 @@
data-settings-search-items="{{ json_encode(settingsSearchItems(['Backup' => collect($items)->map(fn (array $item): array => [...$item, 'route' => $routes[$item['key']]])], $parameters)) }}">
- @if (isDev() && $server->isManagementDisabled())
- Management is disabled on this Coolify instance. You can transfer this server to another instance.
- @elseif (isDev() && $server->isTransferredAway())
+ @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.
diff --git a/resources/views/livewire/server/transfer-finish.blade.php b/resources/views/livewire/server/transfer-finish.blade.php
new file mode 100644
index 0000000000..aaeb37869a
--- /dev/null
+++ b/resources/views/livewire/server/transfer-finish.blade.php
@@ -0,0 +1,171 @@
+
+ @if ($finish && $openCount > 0)
+ {{-- Not on the root element: Livewire starts a poll that a later render adds only on a new element. --}}
+
+ @endif
+ @if ($finish)
+ @php
+ $doneCount = $totalCount - $openCount;
+ $lastCheckedAt = data_get($finish, 'last_checked_at');
+ $badges = [
+ 'action' => ['Needs action', 'warning'],
+ 'unchecked' => ['Cannot check', 'neutral'],
+ 'pending' => ['Not checked yet', 'neutral'],
+ ];
+ @endphp
+
+
+
+
Finish the transfer
+
+ {{ $doneCount }} of {{ $totalCount }} done.
+ @if ($openCount > 0)
+ Coolify checks the open steps every 30 seconds.
+ @endif
+
+ Undo the transfer. The instance that manages the server now transfers it back here,
+ with all changes made there. This archive is replaced only when the import here
+ succeeds. If the other instance is lost, use Enable management on the server page.
+
+
+
+
+
+
+
+
+
+
+
+ Transfer back
+ Running checks…
+
+
+
+
+ @if ($transferBackAt = data_get($server->server_metadata, 'transfer.transfer_back.requested_at'))
+
+ {{ data_get($server->server_metadata, 'transfer.transfer_back.from') }} started the transfer back at
+ {{ \Illuminate\Support\Carbon::parse($transferBackAt)->format('H:i') }}. Reload this page in a minute.
+ When the transfer finishes, this instance manages the server again. If it does not, read
+ the transfer log of the server on the other instance.
+
+ @endif
+ @else
Transfer to another instance
@@ -45,8 +90,8 @@
+ placeholder="https://coolify.example.com"
+ helper="Base URL of the other Coolify instance (no trailing path). It must use https." />
@@ -55,6 +100,7 @@
@@ -62,11 +108,83 @@
Transfer server
- Transferring…
+ Running checks…
+
+ The server is being transferred to {{ data_get($migration, 'target_url') }}. You can
+ leave this page; the transfer continues in the background.
+
+
+ @elseif (data_get($migration, 'status') === 'failed')
+
+ @if (count((array) data_get($migration, 'log', [])) > 0)
+ See the transfer log below for details.
+ @else
+ {{ data_get($migration, 'error') }}
+ @endif
+
+ @elseif (data_get($migration, 'status') === 'unknown')
+
+ The transfer stopped before this instance could confirm the result. Check whether
+ {{ data_get($migration, 'target_url') }} manages this server. If it does, call
+ POST /api/v1/servers/{{ $server->uuid }}/transfer/complete on this instance.
+ If it does not, transfer again.
+
+ @elseif (data_get($migration, 'status') === 'succeeded')
+
+ {{ data_get($migration, 'target_url') }} now manages this server. Automations are
+ disabled on this instance.
+
+ @php $checklist = $this->postTransferChecklist; @endphp
+ @if (count($checklist) > 0 || data_get($migration, 'target_server_url'))
+
+
Finish the transfer on the target
+
+ The target shows these steps on the server page, checks them where it can, and keeps
+ their state.
+ @if ($targetServerUrl = data_get($migration, 'target_server_url'))
+ Open the server on the target.
+ @endif
+