feat(server-transfer): run instance migration as a background job (#12180)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras BacsaiandClaude Opus 5.5 authored and GitHub committed 2026-10-18 22:03:45 +02:00
1 parent 4553454f89
commit 7c14c0098e
94 files changed
+8705 -1524

No files matched your search

+1
View File
@@ -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 <name> || true` before `docker run --name <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.
+16
View File
@@ -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.
@@ -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',
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Events;
use App\Models\Server;
use App\Traits\BroadcastsToTeam;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Foundation\Events\Dispatchable;
/**
* A step of the "Finish the transfer" list changed, so open server pages show it without a reload.
*/
class TransferFinishChanged implements ShouldBroadcast, ShouldDispatchAfterCommit
{
use BroadcastsToTeam, Dispatchable, InteractsWithSockets;
public string $serverUuid;
public function __construct(Server $server)
{
$this->teamId = $server->team_id;
$this->serverUuid = $server->uuid;
}
}
@@ -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);
@@ -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([
+2
View File
@@ -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([
+3
View File
@@ -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');
+3
View File
@@ -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([
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Jobs;
use App\Models\Server;
use App\Services\ServerTransfer\TransferFinishChecklist;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
/**
* Checks the open steps of the "Finish the transfer" list in the background, so the page stays fast while
* checks call SSH, GitHub, Cloudflare, DNS, and S3. One run per server at a time.
*/
class CheckTransferFinishStepsJob implements ShouldBeUnique, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 1;
public int $timeout = 120;
public int $uniqueFor = 120;
public function __construct(public Server $server) {}
public function uniqueId(): string
{
return (string) $this->server->id;
}
public function handle(TransferFinishChecklist $checklist): void
{
$checklist->checkAll($this->server);
}
}
+8
View File
@@ -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);
}
+1 -1
View File
@@ -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();
+69
View File
@@ -0,0 +1,69 @@
<?php
namespace App\Jobs;
use App\Models\Server;
use App\Services\ServerTransfer\ServerTransferMigrator;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Throwable;
/**
* Moves a server to another Coolify instance outside the web request, because a large import can take
* longer than the nginx request timeout. The job payload is encrypted because it holds the target token.
*/
class MigrateServerToInstanceJob implements ShouldBeEncrypted, ShouldBeUnique, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 1;
public int $timeout = ServerTransferMigrator::MIGRATION_TIMEOUT_SECONDS;
public function __construct(
public Server $server,
public string $targetUrl,
public string $targetToken,
) {
$this->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.',
);
}
}
+7 -2
View File
@@ -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;
+30 -1
View File
@@ -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 {
+22 -6
View File
@@ -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)',
+18 -15
View File
@@ -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()
+66 -57
View File
@@ -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<string> */
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<string, mixed>|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<string>
*/
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);
}
}
+122
View File
@@ -0,0 +1,122 @@
<?php
namespace App\Livewire\Server;
use App\Jobs\CheckTransferFinishStepsJob;
use App\Models\Server;
use App\Services\ServerTransfer\TransferFinishChecklist;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\On;
use Livewire\Component;
/**
* The "Finish the transfer" list on the server page. A separate component, so its refresh does not re-render the
* server form.
*/
class TransferFinish extends Component
{
use AuthorizesRequests;
public Server $server;
public function getListeners(): array
{
return [
"echo-private:team.{$this->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(),
]);
}
}
-150
View File
@@ -1,150 +0,0 @@
<?php
namespace App\Livewire\Server;
use App\Models\Server;
use App\Services\ServerTransfer\ServerTransferBundle;
use App\Services\ServerTransfer\ServerTransferImporter;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
use Livewire\WithFileUploads;
use Throwable;
class TransferImport extends Component
{
use AuthorizesRequests;
use WithFileUploads;
public string $bundleJson = '';
public string $passphrase = '';
public bool $preserveUuids = true;
public bool $adoptMode = true;
/** @var TemporaryUploadedFile|null */
public $bundleFile = null;
/** @var array<string, mixed>|null */
public ?array $lastResult = null;
/** @var list<string> */
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<string, mixed>
*/
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);
}
}
+1 -8
View File
@@ -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()
+15
View File
@@ -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}";
}
}
+14 -11
View File
@@ -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);
+2 -2
View File
@@ -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);
@@ -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();
@@ -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<int, Model> $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<int, Model> $resources
* @return list<int> 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.
+9 -1
View File
@@ -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.');
}
@@ -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<string, mixed> $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<string, mixed> $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<string, mixed> $encrypted
* @return array<string, mixed>
*/
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<string, mixed> $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<string, mixed> $bundle
*/
public static function sealWithAppKey(array $bundle): string
{
return Crypt::encryptString(json_encode($bundle, JSON_THROW_ON_ERROR));
}
/**
* @return array<string, mixed>
*/
public static function unsealWithAppKey(string $sealed): array
{
/** @var array<string, mixed> $bundle */
$bundle = json_decode(Crypt::decryptString($sealed), true, 512, JSON_THROW_ON_ERROR);
return $bundle;
}
}
@@ -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<int, Model>
*/
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<array<string, mixed>> 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<string, mixed> $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<string, mixed> $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,
];
}
}
@@ -0,0 +1,347 @@
<?php
namespace App\Services\ServerTransfer;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\ApplicationPreview;
use App\Models\ApplicationSetting;
use App\Models\AuditEvent;
use App\Models\CloudInitScript;
use App\Models\CloudProviderToken;
use App\Models\DatabaseBackupS3Replica;
use App\Models\DiscordNotificationSettings;
use App\Models\DnsProviderZone;
use App\Models\DockerCleanupExecution;
use App\Models\EmailNotificationSettings;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\GithubApp;
use App\Models\GithubRunnerConfig;
use App\Models\GithubRunnerExecution;
use App\Models\GitlabApp;
use App\Models\InstanceSettings;
use App\Models\IntegrationToken;
use App\Models\LocalFileVolume;
use App\Models\LocalPersistentVolume;
use App\Models\ManagedDnsRecord;
use App\Models\ManagedDnsRecordReference;
use App\Models\NotificationThrottle;
use App\Models\OauthIdentity;
use App\Models\OauthSetting;
use App\Models\PersonalAccessToken;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\ProjectSetting;
use App\Models\PushoverNotificationSettings;
use App\Models\ResourceLink;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ScheduledDatabaseBackupExecution;
use App\Models\ScheduledJobDelivery;
use App\Models\ScheduledJobState;
use App\Models\ScheduledTask;
use App\Models\ScheduledTaskExecution;
use App\Models\ScheduledVolumeBackup;
use App\Models\ScheduledVolumeBackupExecution;
use App\Models\SecretManagerLink;
use App\Models\Server;
use App\Models\ServerSetting;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Models\SharedEnvironmentVariable;
use App\Models\SlackNotificationSettings;
use App\Models\SslCertificate;
use App\Models\StandaloneClickhouse;
use App\Models\StandaloneDocker;
use App\Models\StandaloneDragonfly;
use App\Models\StandaloneKeydb;
use App\Models\StandaloneMariadb;
use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Models\StandaloneSqlite;
use App\Models\Subscription;
use App\Models\SwarmDocker;
use App\Models\Tag;
use App\Models\Team;
use App\Models\TeamInvitation;
use App\Models\TelegramNotificationSettings;
use App\Models\User;
use App\Models\UserChangelogRead;
use App\Models\VolumeBackupS3Replica;
use App\Models\WebhookNotificationSettings;
/**
* The decision for every model and every column: does a server transfer move it to the other instance?
*
* ServerTransferCoverageTest exports a server that has a record of every transferred model and fails when
* a model is not listed here, or when a column of a transferred table is neither in the bundle nor in
* NOT_EXPORTED_COLUMNS. So a new model or column gets a transfer decision when it is added.
*/
final class ServerTransferCoverage
{
/**
* Transferred models and where their records are in the bundle. `*` matches every list item. When a
* record has an `attributes` object, its keys also count. `model` limits a path to one database class.
*
* @var array<class-string, list<string>>
*/
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<class-string, array<string, string>>
*/
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<string>
*/
public const ALWAYS_NOT_EXPORTED = ['id', 'created_at', 'updated_at', 'deleted_at'];
/**
* Models that a transfer does not move, with the reason.
*
* @var array<class-string, string>
*/
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.',
];
}
@@ -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<int, CloudProviderToken>
* }
*/
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<T> $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<GithubApp|GitlabApp> $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<int|string> $ids
* @return Collection<int, S3Storage>
*/
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<int, Application> $applications
* @param array<string, mixed> $dependencies
* @return list<string>
*/
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<int, Model> $resources
* @param Collection<int, Application> $applications
* @param Collection<int, Service> $services
* @return array{
* records: Collection<int, ManagedDnsRecord>,
* resource_keys: array<string, array{kind: string, uuid: string}|null>,
* warnings: list<string>
* }
*/
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<int, Model> $resources
* @param Collection<int, ManagedDnsRecord> $dnsRecords
* @return Collection<int, IntegrationToken>
*/
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<int, GithubApp>, gitlab_apps: Collection<int, GitlabApp>} $dependencies
* @param Collection<int, Application> $applications
* @return list<string>
*/
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<array<string, mixed>> $serverVariables
* @param list<array<string, mixed>> $projects
* @return list<array<string, mixed>>
*/
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<int, Model> $databases
* @param Collection<int, Service> $services
* @param array<string, string> $destinationUuidByKey
* @param list<string> $warnings
* @return array<string, mixed>
*/
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<string> $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<int, Application> $applications
* @param Collection<int, Model> $databases
* @param Collection<int, Service> $services
* @return list<array{uuid: string, label: string|null, source: array{kind: string, uuid: string}, target: array{kind: string, uuid: string}}>
*/
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<string, mixed>
*/
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<string, string> $destinationUuidByKey
* @return array<string, mixed>
@@ -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<string, mixed>
*/
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<string, mixed>
*/
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<string, array{kind: string, uuid: string}|null> $resourceKeys
* @return array<string, mixed>
*/
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<string, mixed>|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<array<string, mixed>>
*/
@@ -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),
File diff suppressed because it is too large. Load diff
@@ -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<string>, gitlab: list<string>}
*/
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<string, mixed> $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<string, mixed> $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<array<string, string>> $log
* @param array<string, string> $entry
* @return list<array<string, string>>
*/
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<string, mixed> $migration
*/
private function writeMigration(Server $server, array $migration): void
{
$metadata = $server->server_metadata ?? [];
$metadata['migration'] = $migration;
$server->server_metadata = $metadata;
$server->save();
}
/**
* @return list<array{label: string, ok: bool|null, message: string|null, at: string}>
*/
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<string>
*/
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<string, array{name: string, applications: list<string>}>, gitlab: array<string, array{name: string, applications: list<string>}>}
*/
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<string>
*/
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<string, mixed>,
* complete: array<string, mixed>,
* warnings: list<string>,
* checklist: list<string>,
* 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<string, mixed> $bundle
* @return list<string>
*/
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<string, mixed> $bundle
* @return array<string, mixed>
*/
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<array{label: string, ok: bool|null, message: string|null, at: string}>
*/
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<string, mixed> $bundle
* @return array<string, mixed>|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();
}
}
@@ -0,0 +1,581 @@
<?php
namespace App\Services\ServerTransfer;
use App\Events\TransferFinishChanged;
use App\Jobs\CheckTransferFinishStepsJob;
use App\Models\Application;
use App\Models\GithubApp;
use App\Models\GitlabApp;
use App\Models\ManagedDnsRecord;
use App\Models\ManagedDnsRecordReference;
use App\Models\S3Storage;
use App\Models\Server;
use App\Models\ServiceApplication;
use App\Models\User;
use App\Services\Dns\CloudflareDnsProvider;
use Illuminate\Support\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use RuntimeException;
use Throwable;
/**
* The steps that finish a transfer on the instance that manages the server now: webhooks, DNS, Sentinel, backups,
* and runners. Coolify checks each step where it can, and fixes some. The list is saved in
* server_metadata.transfer.finish, so its state survives a reload and is the same for every user of the team.
*
* An item is {key, type, subject, label, status, hint, message, details, error, link, link_label, checked_at}. The hint
* says what to do; message is the short result of the last check; details are values to copy (URLs); error is the
* technical reason of the last check.
* Status: pending (not checked yet), ok, action (the user must do something), done (the user marked it), or
* unchecked (Coolify cannot check it).
*/
class TransferFinishChecklist
{
public const OPEN_STATUSES = ['pending', 'action', 'unchecked'];
/** Open steps are checked again in the background when the last check is older than this. */
public const RECHECK_AFTER_SECONDS = 30;
/** @var array<string, string> */
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<array<string, mixed>>
*/
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<string, mixed>|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<array<string, mixed>>
*/
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<array<string, mixed>>
*/
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<int, Application> $applications
* @param Collection<int, ServiceApplication> $serviceApplications
* @return Collection<int, ManagedDnsRecord>
*/
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<int, S3Storage>
*/
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<string, mixed>): array{0: string, 1: string} $change
* @return array<string, mixed>
*/
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<string, mixed>
*/
/**
* @param list<array{label: string, value: string}> $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';
}
}
+9
View File
@@ -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:
+139
View File
@@ -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.
+298
View File
@@ -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": [
+198
View File
@@ -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:
+1 -1
View File
@@ -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;
}
-5
View File
@@ -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. */
-2
View File
@@ -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
@@ -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.)
}
},
}));
}
@@ -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
<nav aria-label="Configuration sections"
x-data="settingsSidebarAccordion({ activeGroup: @js($activeGroup), storageKey: 'coolify.settings-sidebar.application' })"
@class([
'grid gap-0.5',
'grid-cols-2 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]' => $placement === 'page',
@@ -285,18 +280,6 @@
<div class="my-2 hidden border-t border-neutral-200 xl:block dark:border-white/[0.06]"
aria-hidden="true"></div>
@endunless
@unless ($groupLabel === 'Danger Zone')
<button type="button" class="nav-section-toggle hidden xl:flex"
@click="toggle(@js($groupLabel))" :aria-expanded="isOpen(@js($groupLabel))">
<span>{{ $groupLabel }}</span>
<svg class="size-3 shrink-0 opacity-60 transition-transform"
:class="!isOpen(@js($groupLabel)) && '-rotate-90'" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="m6 9 6 6 6-6" />
</svg>
</button>
@endunless
<div @class(['contents', 'xl:block' => $groupLabel === 'Danger Zone']) @unless ($groupLabel === 'Danger Zone') :class="isOpen(@js($groupLabel)) ? 'xl:block' : 'xl:hidden'" @endunless>
@foreach ($groupItems as $menuItem)
@php $sections = $pageSections[$menuItem['route']] ?? []; @endphp
<div wire:key="{{ $placement }}-application-settings-group-{{ str($menuItem['label'])->slug() }}">
@@ -336,7 +319,6 @@
@endif
</div>
@endforeach
</div>
@endforeach
</nav>
@if ($placement === 'main-nav')
@@ -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)) }}">
<nav aria-label="Backup settings"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
<div class="nav-section hidden xl:block">Backup</div>
<a class="menu-item" data-page-only {{ wireNavigate() }} href="{{ route($routes['back'], $backParameters) }}">
<x-reicon name="logout" class="menu-item-icon rotate-180" />
<span class="menu-item-label">{{ $backLabel }}</span>
@@ -17,7 +17,7 @@
['label' => 'Runtime Logs', 'route' => 'project.database.logs', 'icon' => 'unordered-list', 'navigate' => false],
['label' => 'Terminal', 'route' => 'project.database.command', 'icon' => 'browser-terminal', 'navigate' => false, 'visible' => auth()->user()?->can('canAccessTerminal')],
['label' => 'Files', 'route' => 'project.database.files', 'icon' => 'folder', 'visible' => auth()->user()?->can('canAccessTerminal')],
['label' => 'Webhooks', 'route' => 'project.database.webhooks', 'icon' => 'notifications'],
['label' => 'Webhooks', 'route' => 'project.database.webhooks', 'icon' => 'webhook'],
['label' => 'Healthcheck', 'route' => 'project.database.healthcheck', 'icon' => 'feedback'],
['label' => 'Resource Limits', 'route' => 'project.database.resource-limits', 'icon' => 'cpu'],
['label' => 'Resource Operations', 'route' => 'project.database.resource-operations', 'icon' => 'server-update'],
@@ -38,7 +38,6 @@
'Deploy' => ['Servers'],
'Automation' => ['Webhooks', 'Backups', 'Import Backup'],
'Operations' => ['Resource Operations', 'Resource Limits', 'Tags'],
// Always last, after a divider and without a group header.
'Danger Zone' => ['Danger Zone'],
];
@@ -49,9 +48,6 @@
->values())
->filter(fn ($items) => $items->isNotEmpty());
// Group that holds the current page — always kept open, even if collapsed before.
$activeGroup = (string) $groupedItems->search(fn ($items) => $items->contains(fn ($item) => $item['active'] ?? false));
$pageSections = $database->type() === 'standalone-postgresql'
? [
['id' => 'database-details-section', 'label' => 'Database details'],
@@ -82,7 +78,6 @@
data-settings-search-items="{{ json_encode(settingsSearchItems($groupedItems, $databaseRouteParameters, ['project.database.configuration' => $pageSections])) }}">
@endif
<nav aria-label="Database settings"
x-data="settingsSidebarAccordion({ activeGroup: @js($activeGroup), storageKey: 'coolify.settings-sidebar.database' })"
@class([
'grid gap-0.5',
'grid-cols-2 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]' => $placement === 'page',
@@ -92,18 +87,6 @@
@unless ($loop->first)
<div class="my-2 hidden border-t border-neutral-200 xl:block dark:border-white/[0.06]" aria-hidden="true"></div>
@endunless
@unless ($groupLabel === 'Danger Zone')
<button type="button" class="nav-section-toggle hidden xl:flex" @click="toggle(@js($groupLabel))"
:aria-expanded="isOpen(@js($groupLabel))">
<span>{{ $groupLabel }}</span>
<svg class="size-3 shrink-0 opacity-60 transition-transform"
:class="!isOpen(@js($groupLabel)) && '-rotate-90'" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="m6 9 6 6 6-6" />
</svg>
</button>
@endunless
<div @class(['contents', 'xl:block' => $groupLabel === 'Danger Zone']) @unless ($groupLabel === 'Danger Zone') :class="isOpen(@js($groupLabel)) ? 'xl:block' : 'xl:hidden'" @endunless>
@foreach ($groupItems as $menuItem)
<a @class(['menu-item', 'menu-item-active' => $menuItem['active']])
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
@@ -133,7 +116,6 @@
</div>
@endif
@endforeach
</div>
@endforeach
</nav>
@if ($placement === 'main-nav')
@@ -22,6 +22,7 @@
'storages' => '<path fill-rule="evenodd" clip-rule="evenodd" d="M12 1.25C11.3953 1.25 10.8384 1.40029 10.2288 1.65242C9.64008 1.89588 8.95633 2.25471 8.1049 2.70153L6.03739 3.78651C4.99242 4.33487 4.15616 4.77371 3.51047 5.20491C2.84154 5.65164 2.32632 6.12201 1.95112 6.75918C1.57718 7.39421 1.40896 8.08184 1.32829 8.90072C1.24999 9.69558 1.24999 10.6731 1.25 11.9026V12.0974C1.24999 13.3268 1.24999 14.3044 1.32829 15.0993C1.40896 15.9182 1.57718 16.6058 1.95112 17.2408C2.32632 17.878 2.84154 18.3484 3.51047 18.7951C4.15616 19.2263 4.99241 19.6651 6.03737 20.2135L8.10481 21.2984C8.95628 21.7453 9.64006 22.1041 10.2288 22.3476C10.8384 22.5997 11.3953 22.75 12 22.75C12.6047 22.75 13.1616 22.5997 13.7712 22.3476C14.3599 22.1041 15.0437 21.7453 15.8951 21.2985L17.9626 20.2135C19.0076 19.6651 19.8438 19.2263 20.4895 18.7951C21.1585 18.3484 21.6737 17.878 22.0489 17.2408C22.4228 16.6058 22.591 15.9182 22.6717 15.0993C22.75 14.3044 22.75 13.3269 22.75 12.0975V11.9025C22.75 10.6731 22.75 9.69557 22.6717 8.90072C22.591 8.08184 22.4228 7.39421 22.0489 6.75918C21.6737 6.12201 21.1585 5.65164 20.4895 5.20491C19.8438 4.77371 19.0076 4.33487 17.9626 3.7865L15.8951 2.70154C15.0437 2.25472 14.3599 1.89589 13.7712 1.65242C13.1616 1.40029 12.6047 1.25 12 1.25ZM8.7708 4.04608C9.66052 3.57917 10.284 3.2528 10.802 3.03856C11.3062 2.83004 11.6605 2.75 12 2.75C12.3395 2.75 12.6938 2.83004 13.198 3.03856C13.716 3.2528 14.3395 3.57917 15.2292 4.04608L17.2292 5.09563C18.3189 5.66748 19.0845 6.07032 19.6565 6.45232C19.9387 6.64078 20.1604 6.81578 20.3395 6.99174L17.0088 8.65708L8.50895 4.18349L8.7708 4.04608ZM6.94466 5.00439L6.7708 5.09563C5.68111 5.66747 4.91553 6.07032 4.34352 6.45232C4.06131 6.64078 3.83956 6.81578 3.66054 6.99174L12 11.1615L15.3572 9.48289L7.15069 5.16369C7.07096 5.12173 7.00191 5.06743 6.94466 5.00439ZM2.93768 8.30737C2.88718 8.52125 2.84901 8.76413 2.82106 9.04778C2.75084 9.7606 2.75 10.6644 2.75 11.9415V12.0585C2.75 13.3356 2.75084 14.2394 2.82106 14.9522C2.88974 15.6494 3.02022 16.1002 3.24367 16.4797C3.46587 16.857 3.78727 17.1762 4.34352 17.5477C4.91553 17.9297 5.68111 18.3325 6.7708 18.9044L8.7708 19.9539C9.66052 20.4208 10.284 20.7472 10.802 20.9614C10.9656 21.0291 11.1134 21.0832 11.25 21.1255V12.4635L2.93768 8.30737ZM12.75 21.1255C12.8866 21.0832 13.0344 21.0291 13.198 20.9614C13.716 20.7472 14.3395 20.4208 15.2292 19.9539L17.2292 18.9044C18.3189 18.3325 19.0845 17.9297 19.6565 17.5477C20.2127 17.1762 20.5341 16.857 20.7563 16.4797C20.9798 16.1002 21.1103 15.6494 21.1789 14.9522C21.2492 14.2394 21.25 13.3356 21.25 12.0585V11.9415C21.25 10.6644 21.2492 9.7606 21.1789 9.04778C21.151 8.76412 21.1128 8.52125 21.0623 8.30736L17.75 9.96352V13C17.75 13.4142 17.4142 13.75 17 13.75C16.5858 13.75 16.25 13.4142 16.25 13V10.7135L12.75 12.4635V21.1255Z" fill="currentColor"/>',
'variables' => '<path d="M16.96 6.17004C18.96 7.56004 20.34 9.77004 20.62 12.32" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M3.48999 12.37C3.74999 9.82997 5.10999 7.61997 7.08999 6.21997" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M8.19 20.9399C9.35 21.5299 10.67 21.8599 12.06 21.8599C13.4 21.8599 14.66 21.5599 15.79 21.0099" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M12.06 7.70001C13.5954 7.70001 14.84 6.45537 14.84 4.92001C14.84 3.38466 13.5954 2.14001 12.06 2.14001C10.5247 2.14001 9.28003 3.38466 9.28003 4.92001C9.28003 6.45537 10.5247 7.70001 12.06 7.70001Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M4.82999 19.92C6.36534 19.92 7.60999 18.6753 7.60999 17.14C7.60999 15.6046 6.36534 14.36 4.82999 14.36C3.29464 14.36 2.04999 15.6046 2.04999 17.14C2.04999 18.6753 3.29464 19.92 4.82999 19.92Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M19.17 19.92C20.7054 19.92 21.95 18.6753 21.95 17.14C21.95 15.6046 20.7054 14.36 19.17 14.36C17.6347 14.36 16.39 15.6046 16.39 17.14C16.39 18.6753 17.6347 19.92 19.17 19.92Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>',
'notifications' => '<path fill-rule="evenodd" clip-rule="evenodd" d="M12 1.25C7.71983 1.25 4.25004 4.71979 4.25004 9V9.7041C4.25004 10.401 4.04375 11.0824 3.65717 11.6622L2.50856 13.3851C1.17547 15.3848 2.19318 18.1028 4.51177 18.7351C5.26738 18.9412 6.02937 19.1155 6.79578 19.2581L6.79768 19.2632C7.56667 21.3151 9.62198 22.75 12 22.75C14.378 22.75 16.4333 21.3151 17.2023 19.2632L17.2042 19.2581C17.9706 19.1155 18.7327 18.9412 19.4883 18.7351C21.8069 18.1028 22.8246 15.3848 21.4915 13.3851L20.3429 11.6622C19.9563 11.0824 19.75 10.401 19.75 9.7041V9C19.75 4.71979 16.2802 1.25 12 1.25ZM15.3764 19.537C13.1335 19.805 10.8664 19.8049 8.62349 19.5369C9.33444 20.5585 10.571 21.25 12 21.25C13.4289 21.25 14.6655 20.5585 15.3764 19.537ZM5.75004 9C5.75004 5.54822 8.54826 2.75 12 2.75C15.4518 2.75 18.25 5.54822 18.25 9V9.7041C18.25 10.6972 18.544 11.668 19.0948 12.4943L20.2434 14.2172C21.0086 15.3649 20.4245 16.925 19.0936 17.288C14.4494 18.5546 9.5507 18.5546 4.90644 17.288C3.57561 16.925 2.99147 15.3649 3.75664 14.2172L4.90524 12.4943C5.45609 11.668 5.75004 10.6972 5.75004 9.7041V9Z" fill="currentColor"/>',
'webhook' => '<g stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 16.98h-5.99c-1.1 0-1.95.94-2.48 1.9A4 4 0 0 1 2 17c.01-.7.2-1.4.57-2"/><path d="m6 17 3.13-5.78c.53-.97.1-2.18-.5-3.1a4 4 0 1 1 6.89-4.06"/><path d="m12 6 3.13 5.73C15.66 12.7 16.9 13 18 13a4 4 0 0 1 0 8"/></g>',
'keys' => '<path fill-rule="evenodd" clip-rule="evenodd" d="M15.6809 5.34814C14.0521 5.34814 12.7265 6.66395 12.7265 8.29353C12.7265 9.92311 14.0521 11.2389 15.6809 11.2389C17.3097 11.2389 18.6353 9.92311 18.6353 8.29353C18.6353 6.66395 17.3097 5.34814 15.6809 5.34814ZM14.2265 8.29353C14.2265 7.49816 14.8748 6.84814 15.6809 6.84814C16.487 6.84814 17.1353 7.49816 17.1353 8.29353C17.1353 9.0889 16.487 9.73891 15.6809 9.73891C14.8748 9.73891 14.2265 9.0889 14.2265 8.29353Z" fill="currentColor"/><path fill-rule="evenodd" clip-rule="evenodd" d="M9.52998 20.8783C9.86298 20.414 9.97017 19.9429 9.96222 19.5233C10.3544 19.6387 10.7424 19.6533 11.1141 19.5828C11.8825 19.437 12.4511 18.9512 12.7527 18.5507L12.758 18.5437L12.7631 18.5366C13.2883 17.8043 13.2872 17.0543 13.1586 16.5164C13.0956 16.2528 13.0021 16.0361 12.9245 15.8846C12.8853 15.8081 12.849 15.746 12.8207 15.7005C12.8132 15.6885 12.8063 15.6775 12.7999 15.6677C12.7112 15.5021 12.6111 15.3719 12.5269 15.2737L12.5359 15.2647L13.0001 14.8024C13.3817 14.9849 13.7957 15.0999 14.1583 15.1749C14.744 15.2962 15.3171 15.3369 15.6807 15.3369C19.582 15.3369 22.75 12.1863 22.75 8.29344C22.75 4.40056 19.582 1.25 15.6807 1.25C11.7794 1.25 8.61144 4.40056 8.61144 8.29344C8.61144 9.2105 8.82018 9.99588 9.02588 10.549C9.07825 10.6898 9.13081 10.8166 9.18035 10.9279L1.92511 18.1535C1.66869 18.4089 1.36789 18.853 1.27697 19.4092C1.17837 20.0124 1.34031 20.6829 1.92511 21.2654L2.80687 22.1435C2.82046 22.1571 2.83457 22.1701 2.84916 22.1825C3.10385 22.3999 3.53164 22.6513 4.04572 22.7273C4.59712 22.8088 5.23527 22.6818 5.77579 22.1435L6.34232 21.5793C6.87523 21.8849 7.43853 21.9545 7.95941 21.8548C8.63497 21.7254 9.19686 21.321 9.51964 20.8924L9.5249 20.8854L9.52998 20.8783ZM10.1114 8.29344C10.1114 5.23477 12.602 2.75 15.6807 2.75C18.7594 2.75 21.25 5.23477 21.25 8.29344C21.25 11.3521 18.7594 13.8369 15.6807 13.8369C15.4075 13.8369 14.9372 13.8044 14.4623 13.7061C13.9654 13.6032 13.5752 13.4504 13.3674 13.2779C13.0699 13.031 12.6332 13.0508 12.3592 13.3237L11.4774 14.2019C11.2757 14.4028 11.0818 14.6305 10.9794 14.8933C10.8499 15.2261 10.8912 15.5463 11.0394 15.8121C11.1273 15.9697 11.2689 16.1202 11.3278 16.183L11.3476 16.2042C11.4173 16.2811 11.4555 16.3314 11.4834 16.387L11.5098 16.4397L11.54 16.4817L11.5468 16.4924C11.5558 16.507 11.5712 16.533 11.5895 16.5685C11.6267 16.6412 11.6709 16.7445 11.6997 16.8652C11.7544 17.0937 11.7538 17.3656 11.5494 17.6551C11.4087 17.8384 11.1424 18.0506 10.8345 18.1091C10.5769 18.1579 10.1571 18.1261 9.59673 17.5681C9.30409 17.2766 8.83089 17.2766 8.53825 17.5681L8.24433 17.8608C7.96748 18.1365 7.94891 18.5782 8.20054 18.8761C8.20194 18.8778 8.2058 18.8826 8.2116 18.8903C8.22363 18.9062 8.24339 18.9336 8.2668 18.9704C8.31483 19.0461 8.37128 19.1508 8.41138 19.2706C8.48694 19.4963 8.49882 19.7374 8.31639 19.9966C8.19643 20.1519 7.95303 20.3287 7.67726 20.3815C7.4429 20.4264 7.14284 20.3931 6.8045 20.0562C6.51186 19.7647 6.03866 19.7647 5.74602 20.0562L4.7173 21.0807C4.55241 21.2449 4.4068 21.2643 4.26505 21.2434C4.09729 21.2186 3.93333 21.1293 3.84077 21.0562L2.9836 20.2025C2.74543 19.9653 2.73591 19.7821 2.75733 19.6511C2.78643 19.4731 2.89711 19.3025 2.9836 19.2163L10.6279 11.6033C10.8747 11.3575 10.9185 10.9735 10.7333 10.6784L10.7311 10.6748C10.7284 10.6703 10.7232 10.6615 10.7158 10.6487C10.7012 10.6231 10.6781 10.5814 10.6494 10.5251C10.5918 10.4123 10.5122 10.2423 10.4318 10.0262C10.2701 9.59135 10.1114 8.98632 10.1114 8.29344ZM8.20054 18.8761C8.20192 18.8777 8.2033 18.8793 8.20469 18.881L8.20354 18.8796L8.20054 18.8761Z" fill="currentColor"/>',
'tags' => '<path fill-rule="evenodd" clip-rule="evenodd" d="M11.2383 2.79888C10.6243 2.88003 9.86602 3.0542 8.7874 3.30311L7.55922 3.58654C6.6482 3.79677 6.02082 3.94252 5.54162 4.10698C5.07899 4.26576 4.81727 4.42228 4.61978 4.61978C4.42228 4.81727 4.26576 5.07899 4.10698 5.54162C3.94252 6.02082 3.79677 6.6482 3.58654 7.55922L3.30311 8.7874C3.0542 9.86602 2.88003 10.6243 2.79888 11.2383C2.71982 11.8365 2.73805 12.2413 2.84358 12.6092C2.94911 12.9772 3.14817 13.3301 3.53226 13.7954C3.92651 14.2731 4.47607 14.8238 5.25882 15.6066L7.08845 17.4362C8.44794 18.7957 9.41533 19.7608 10.247 20.3954C11.0614 21.0167 11.6569 21.25 12.2623 21.25C12.8678 21.25 13.4633 21.0167 14.2776 20.3954C15.1093 19.7608 16.0767 18.7957 17.4362 17.4362C18.7957 16.0767 19.7608 15.1093 20.3954 14.2776C21.0167 13.4633 21.25 12.8678 21.25 12.2623C21.25 11.6569 21.0167 11.0614 20.3954 10.247C19.7608 9.41533 18.7957 8.44794 17.4362 7.08845L15.6066 5.25882C14.8238 4.47607 14.2731 3.92651 13.7954 3.53226C13.3301 3.14817 12.9772 2.94911 12.6092 2.84358C12.2413 2.73805 11.8365 2.71982 11.2383 2.79888ZM11.0418 1.31181C11.7591 1.21701 12.3881 1.21969 13.0227 1.4017C13.6574 1.58372 14.1922 1.91482 14.7502 2.37538C15.2897 2.82061 15.8905 3.4214 16.641 4.17197L18.5368 6.06774C19.8474 7.37835 20.8851 8.41598 21.5879 9.33714C22.311 10.2849 22.75 11.197 22.75 12.2623C22.75 13.3276 22.311 14.2397 21.5879 15.1875C20.8851 16.1087 19.8474 17.1463 18.5368 18.4569L18.4569 18.5368C17.1463 19.8474 16.1087 20.8851 15.1875 21.5879C14.2397 22.311 13.3276 22.75 12.2623 22.75C11.197 22.75 10.2849 22.311 9.33714 21.5879C8.41598 20.8851 7.37833 19.8474 6.06771 18.5368L4.17196 16.641C3.4214 15.8905 2.82061 15.2897 2.37538 14.7502C1.91482 14.1922 1.58372 13.6574 1.4017 13.0227C1.21969 12.3881 1.21701 11.7591 1.31181 11.0418C1.40345 10.3484 1.59451 9.52048 1.83319 8.48622L2.13385 7.18334C2.33302 6.32023 2.49543 5.61639 2.68821 5.05469C2.88955 4.46806 3.14313 3.9751 3.55912 3.55912C3.9751 3.14313 4.46806 2.88955 5.05469 2.68821C5.61639 2.49543 6.32023 2.33302 7.18335 2.13385L8.48622 1.83319C9.52047 1.59451 10.3484 1.40345 11.0418 1.31181ZM9.49094 7.99514C9.00278 7.50699 8.21133 7.50699 7.72317 7.99514C7.23502 8.4833 7.23502 9.27476 7.72317 9.76291C8.21133 10.2511 9.00278 10.2511 9.49094 9.76291C9.97909 9.27476 9.97909 8.4833 9.49094 7.99514ZM6.66251 6.93448C7.73645 5.86054 9.47766 5.86054 10.5516 6.93448C11.6255 8.00843 11.6255 9.74963 10.5516 10.8236C9.47766 11.8975 7.73645 11.8975 6.66251 10.8236C5.58857 9.74963 5.58857 8.00843 6.66251 6.93448ZM19.0511 10.9902C19.344 11.2831 19.344 11.7579 19.0511 12.0508L12.0721 19.0301C11.7792 19.323 11.3043 19.323 11.0114 19.0301C10.7185 18.7372 10.7185 18.2623 11.0114 17.9694L17.9904 10.9902C18.2833 10.6973 18.7582 10.6973 19.0511 10.9902Z" fill="currentColor"/>',
'time-back' => '<path d="M12 2.75C9.91677 2.75 7.99633 3.43778 6.45042 4.59896C5.82246 5.07064 5.11166 5.77778 4.48433 6.50232C4.26205 6.75904 4.0557 7.01164 3.87142 7.25H7.5C7.91421 7.25 8.25 7.58579 8.25 8C8.25 8.41421 7.91421 8.75 7.5 8.75H2.47183C2.46206 8.75019 2.45227 8.75019 2.44247 8.75H2C1.58579 8.75 1.25 8.41421 1.25 8V2.5C1.25 2.08579 1.58579 1.75 2 1.75C2.41421 1.75 2.75 2.08579 2.75 2.5L2.75 6.24861C2.93794 6.00859 3.14013 5.76324 3.35033 5.52046C4.01529 4.75246 4.80449 3.95925 5.54955 3.39961C7.3463 2.05002 9.58081 1.25 12 1.25C17.9371 1.25 22.75 6.06294 22.75 12C22.75 17.9371 17.9371 22.75 12 22.75C7.18541 22.75 3.11189 19.5854 1.74227 15.2247C1.61816 14.8296 1.83789 14.4086 2.23307 14.2845C2.62825 14.1603 3.04923 14.3801 3.17335 14.7753C4.35226 18.5288 7.85932 21.25 12 21.25C17.1086 21.25 21.25 17.1086 21.25 12C21.25 6.89137 17.1086 2.75 12 2.75Z" fill="currentColor"/><path d="M12.75 7C12.75 6.58579 12.4142 6.25 12 6.25C11.5858 6.25 11.25 6.58579 11.25 7V12C11.25 12.2586 11.3832 12.4989 11.6025 12.636L14.8025 14.636C15.1538 14.8555 15.6165 14.7488 15.836 14.3975C16.0555 14.0462 15.9488 13.5835 15.5975 13.364L12.75 11.5843V7Z" fill="currentColor"/>',
@@ -196,15 +196,16 @@
'route' => 'server.transfer',
'active' => $activeMenu === 'transfer',
'icon' => 'arrow-right',
'beta' => true,
'group' => 'Operations',
'visible' => isDev() && ! $server->isLocalhost() && auth()->user()?->can('view', $server),
'visible' => ! $server->isLocalhost() && auth()->user()?->can('update', $server),
],
[
'label' => 'Danger',
'label' => 'Danger Zone',
'route' => 'server.delete',
'active' => $activeMenu === 'danger',
'icon' => 'danger',
'group' => 'Danger zone',
'group' => 'Danger Zone',
// Coolify host (id 0) cannot be deleted. Other servers may still use
// host.docker.internal (e.g. Lima VMs) and must keep the Danger menu.
'visible' => ! $server->is_coolify_host,
@@ -215,13 +216,6 @@
->filter(fn (array $item): bool => $item['visible'] ?? true)
->values();
$groupedServerMenuItems = $serverMenuItems->groupBy('group');
// Group that holds the current page (item or nested child) — always kept
// open, even if collapsed before.
$activeGroup = (string) $groupedServerMenuItems->search(fn ($items) => $items->contains(
fn ($item) => ($item['active'] ?? false)
|| collect($item['children'] ?? [])->contains(fn ($child) => $child['active'] ?? false)
));
@endphp
@php
@@ -286,7 +280,6 @@
">
@endif
<nav aria-label="Server configuration sections"
x-data="settingsSidebarAccordion({ activeGroup: @js($activeGroup), storageKey: 'coolify.settings-sidebar.server' })"
@class([
'grid gap-0.5',
'grid-cols-2 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]' => $placement === 'page',
@@ -297,16 +290,6 @@
<div class="my-2 hidden border-t border-neutral-200 xl:block dark:border-white/[0.06]"
aria-hidden="true"></div>
@endunless
<button type="button" class="nav-section-toggle hidden xl:flex" @click="toggle(@js($groupLabel))"
:aria-expanded="isOpen(@js($groupLabel))">
<span>{{ $groupLabel }}</span>
<svg class="size-3 shrink-0 opacity-60 transition-transform"
:class="!isOpen(@js($groupLabel)) && '-rotate-90'" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="m6 9 6 6 6-6" />
</svg>
</button>
<div class="contents" :class="isOpen(@js($groupLabel)) ? 'xl:block' : 'xl:hidden'">
@foreach ($groupItems as $menuItem)
@if ($menuItem['disabled'] ?? false)
<span wire:key="{{ $placement }}-server-settings-link-{{ str($menuItem['label'])->slug() }}"
@@ -354,7 +337,6 @@
</div>
@endif
@endforeach
</div>
@endforeach
</nav>
@if ($placement === 'main-nav')
@@ -6,7 +6,6 @@
<x-main-nav-menu :depth="1" back-label="Service" back-title="Back to the service menu">
<nav aria-label="Compose resource settings"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
<div class="nav-section hidden xl:block">Compose resource</div>
<a class="menu-item" data-page-only {{ wireNavigate() }}
href="{{ route('project.service.configuration', [...$parameters, 'stack_service_uuid' => null]) }}">
<x-reicon name="logout" class="menu-item-icon rotate-180" />
@@ -19,7 +19,7 @@
['label' => 'Files', 'route' => 'project.service.files', 'icon' => 'folder', 'visible' => auth()->user()?->can('canAccessTerminal')],
['label' => 'Analytics', 'route' => 'project.service.analytics', 'icon' => 'analytics'],
['label' => 'Scheduled Tasks', 'route' => 'project.service.scheduled-tasks.show', 'icon' => 'calendar'],
['label' => 'Webhooks', 'route' => 'project.service.webhooks', 'icon' => 'notifications'],
['label' => 'Webhooks', 'route' => 'project.service.webhooks', 'icon' => 'webhook'],
['label' => 'Resource Operations', 'route' => 'project.service.resource-operations', 'icon' => 'server-update'],
['label' => 'Tags', 'route' => 'project.service.tags', 'icon' => 'tags'],
['label' => 'Danger Zone', 'route' => 'project.service.danger', 'icon' => 'danger'],
@@ -40,7 +40,6 @@
'Observe & troubleshoot' => ['Runtime Logs', 'Terminal', 'Files', 'Analytics'],
'Automation' => ['Scheduled Tasks', 'Webhooks', 'Backups', 'Import Backup'],
'Operations' => ['Resource Operations', 'Tags'],
// Always last, after a divider and without a group header.
'Danger Zone' => ['Danger Zone'],
];
@@ -50,60 +49,43 @@
->filter()
->values())
->filter(fn ($items) => $items->isNotEmpty());
// Group that holds the current page — always kept open, even if collapsed before.
$activeGroup = (string) $groupedItems->search(fn ($items) => $items->contains(fn ($item) => $item['active'] ?? false));
@endphp
<x-main-nav-menu back-label="Dashboard" :nav-only="$navOnly"
data-settings-search-items="{{ json_encode(settingsSearchItems($groupedItems, $serviceRouteParameters)) }}">
<nav aria-label="Service settings"
x-data="settingsSidebarAccordion({ activeGroup: @js($activeGroup), storageKey: 'coolify.settings-sidebar.service' })"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
@foreach ($groupedItems as $groupLabel => $groupItems)
@unless ($loop->first)
<div class="my-2 hidden border-t border-neutral-200 xl:block dark:border-white/[0.06]" aria-hidden="true"></div>
@endunless
@unless ($groupLabel === 'Danger Zone')
<button type="button" class="nav-section-toggle hidden xl:flex" @click="toggle(@js($groupLabel))"
:aria-expanded="isOpen(@js($groupLabel))">
<span>{{ $groupLabel }}</span>
<svg class="size-3 shrink-0 opacity-60 transition-transform"
:class="!isOpen(@js($groupLabel)) && '-rotate-90'" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="m6 9 6 6 6-6" />
</svg>
</button>
@endunless
<div @class(['contents', 'xl:block' => $groupLabel === 'Danger Zone']) @unless ($groupLabel === 'Danger Zone') :class="isOpen(@js($groupLabel)) ? 'xl:block' : 'xl:hidden'" @endunless>
@foreach ($groupItems as $menuItem)
<a @class(['menu-item', 'menu-item-active' => $menuItem['active']])
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $serviceRouteParameters) }}">
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
@if ($menuItem['label'] === 'Files')
<x-beta-badge class="ml-auto shrink-0" />
@endif
@if ($menuItem['hasWarning'] ?? false)
<span class="ml-auto size-2 shrink-0 rounded-full bg-error" title="Required environment variables missing"></span>
@endif
</a>
@if ($menuItem['active'] && $menuItem['route'] === 'project.service.storages' && $storageSections->isNotEmpty())
<div class="nav-children hidden flex-col gap-0.5 py-1 xl:flex"
x-data="{ activeSection: '' }">
@foreach ($storageSections as $section)
<button type="button" class="menu-subitem"
:class="activeSection === '{{ $section['id'] }}' && 'menu-subitem-active'"
@click="activeSection = '{{ $section['id'] }}'; window.scrollToSettingsSection?.('{{ $section['id'] }}')">
<span class="menu-item-label truncate text-left"
title="{{ $section['label'] }}">{{ $section['label'] }}</span>
</button>
@endforeach
</div>
@foreach ($groupItems as $menuItem)
<a @class(['menu-item', 'menu-item-active' => $menuItem['active']])
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $serviceRouteParameters) }}">
<x-reicon :name="$menuItem['icon']" class="menu-item-icon" />
<span class="menu-item-label">{{ $menuItem['label'] }}</span>
@if ($menuItem['label'] === 'Files')
<x-beta-badge class="ml-auto shrink-0" />
@endif
@endforeach
</div>
@if ($menuItem['hasWarning'] ?? false)
<span class="ml-auto size-2 shrink-0 rounded-full bg-error" title="Required environment variables missing"></span>
@endif
</a>
@if ($menuItem['active'] && $menuItem['route'] === 'project.service.storages' && $storageSections->isNotEmpty())
<div class="nav-children hidden flex-col gap-0.5 py-1 xl:flex"
x-data="{ activeSection: '' }">
@foreach ($storageSections as $section)
<button type="button" class="menu-subitem"
:class="activeSection === '{{ $section['id'] }}' && 'menu-subitem-active'"
@click="activeSection = '{{ $section['id'] }}'; window.scrollToSettingsSection?.('{{ $section['id'] }}')">
<span class="menu-item-label truncate text-left"
title="{{ $section['label'] }}">{{ $section['label'] }}</span>
</button>
@endforeach
</div>
@endif
@endforeach
@endforeach
</nav>
</x-main-nav-menu>
@@ -14,7 +14,6 @@
<x-main-nav-menu :depth="1" back-label="Service" back-title="Back to the service menu">
<nav aria-label="Compose resource settings"
class="grid grid-cols-2 gap-0.5 border-y border-neutral-200 py-3 sm:grid-cols-3 xl:grid-cols-1 xl:border-y-0 xl:py-0 dark:border-white/[0.06]">
<div class="nav-section hidden xl:block">Compose resource</div>
<a class="menu-item" data-page-only {{ wireNavigate() }}
href="{{ route('project.service.configuration', [...$parameters, 'stack_service_uuid' => null]) }}">
<x-reicon name="logout" class="menu-item-icon rotate-180" />
@@ -21,16 +21,6 @@
<div data-ec-views class="flex items-center gap-2">
<x-canvas-view-toggle on-canvas
:list-url="route('server.index')" :canvas-url="route('server.canvas.index')" />
@if (isDev())
@can('create', App\Models\Server::class)
<a href="{{ route('server.transfer.import') }}" {{ wireNavigate() }}
class="button whitespace-nowrap">
<x-reicon name="upload" class="size-3.5" />
Import server
<x-status-badge label="Dev" />
</a>
@endcan
@endif
@can('createAnyResource')
<a href="{{ route('server.create') }}" {{ wireNavigate() }}
class="button button-highlighted whitespace-nowrap">
@@ -17,11 +17,15 @@
<x-danger-zone title="This action cannot be undone">
<p>
The server will be removed from Coolify.
@if ($server->definedResources()->count() > 0)
@if ($server->isTransferredAway())
This server was transferred to another Coolify instance. Only the records on this instance
are deleted, also the records of its resources. The server, its containers, and its data
stay as they are.
@elseif ($server->definedResources()->count() > 0)
It currently contains managed resources. Enable force deletion in the confirmation only
if those resources should also be removed.
@endif
@if ($server->hostinger_virtual_machine_id)
@if ($server->hostinger_virtual_machine_id && ! $server->isTransferredAway())
Hostinger deletes the VPS only at the end of its paid period, and only if you select this in the confirmation dialog.
<a class="underline" href="https://hpanel.hostinger.com/" target="_blank"
rel="noopener noreferrer">Manage the VPS and billing in hPanel.</a>
@@ -30,9 +34,16 @@
<p>Type the server name in the confirmation dialog to continue.</p>
<x-slot:action>
@php
$deleteActions = ['This server will be permanently deleted from Coolify.'];
$deleteActions = $server->isTransferredAway()
? ['This server and the records of its resources will be permanently deleted from this Coolify instance. Nothing on the server changes.']
: ['This server will be permanently deleted from Coolify.'];
$deleteWarningMessage = null;
if ($server->hostinger_virtual_machine_id) {
if ($server->isTransferredAway()) {
$deleteWarningMessage = '<strong>This removes the option to undo the transfer.</strong><br>'
.'This archive is the copy that "Transfer back" and "Take back" use. '
.'Delete it only when the transfer works and you do not need to undo it.';
}
if ($server->hostinger_virtual_machine_id && ! $server->isTransferredAway()) {
$deleteWarningMessage = '<strong>Hostinger deletes a VPS only at the end of its paid period.</strong><br>'
.'If you select to delete the VPS from Hostinger, Coolify turns off its auto-renewal. '
.'The VPS keeps running until the paid period ends. Then Hostinger deletes it with all its data. '
@@ -6,16 +6,6 @@
<div class="mb-5 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<h1 class="min-w-0 text-[24px]! leading-7! font-semibold! tracking-tight!">Servers</h1>
<div class="flex flex-wrap items-center gap-2">
@if (isDev())
@can('create', App\Models\Server::class)
<a href="{{ route('server.transfer.import') }}" {{ wireNavigate() }}
class="button w-fit shrink-0 whitespace-nowrap">
<x-reicon name="upload" class="size-3.5" />
Import server
<x-status-badge label="Dev" />
</a>
@endcan
@endif
@can('createAnyResource')
<a href="{{ route('server.create') }}" {{ wireNavigate() }}
class="button w-fit shrink-0 whitespace-nowrap button-highlighted">
@@ -28,7 +18,7 @@
@php
$serverRows = $servers->map(function ($server) {
$isTransferredAway = isDev() && $server->isTransferredAway();
$isTransferredAway = $server->isTransferredAway();
$isReady = $server->settings->is_reachable
&& $server->settings->is_usable
&& ! $server->settings->force_disabled
@@ -38,7 +28,6 @@
$sentinelNeedsAttention = $isReady && $server->isSentinelEnabled() && $server->sentinelStatus() === 'out_of_sync';
$status = match (true) {
$isTransferredAway && $server->isManagementDisabled() => 'Transferable',
$isTransferredAway => 'Transferred away',
$server->settings->force_disabled => 'Disabled',
$proxyNeedsAttention || $sentinelNeedsAttention => 'Attention required',
@@ -254,70 +254,55 @@
</div>
</div>
@php
$canManageProxy = auth()->user()?->can('manageProxy', $server) ?? false;
@endphp
@if ($server->proxySet() && ($canManageProxy || $traefikDashboardAvailable))
{{-- Teleport only with content: a filled slot renders as a floating panel. --}}
@teleport('#resource-action-hud-slot')
<div class="hidden xl:flex xl:w-auto xl:items-center">
<div
class="resource-heading-navbar application-heading-actions flex w-auto min-w-0 items-center justify-end gap-1 overflow-visible">
<x-resource-heading-tabs class="hidden" aria-hidden="true">
@foreach ($serverMenuItems as $menuItem)
<a wire:key="server-primary-nav-{{ str($menuItem['label'])->slug() }}"
@class([
'app-tab shrink-0 gap-1',
'app-tab-active' => $menuItem['active'],
])
@if ($menuItem['active']) aria-current="page" @endif
@if ($menuItem['navigate'] ?? true) {{ wireNavigate() }} @endif
href="{{ route($menuItem['route'], $serverRouteParameters) }}">
{{ $menuItem['label'] }}
@if ($menuItem['warning'] ?? false)
<x-reicon name="alert-triangle"
class="size-3.5 text-orange-500 dark:text-warning" />
@endif
@if ($traefikDashboardAvailable)
<div class="resource-heading-menus shrink-0">
<a class="app-tab shrink-0 gap-1" target="_blank" title="Open Traefik dashboard"
href="http://{{ formatHostForUrl((string) $serverIp) }}:8080">
<x-reicon name="external-link" class="size-3.5 shrink-0 opacity-70" />
Traefik Dashboard
</a>
@endforeach
</x-resource-heading-tabs>
@if ($server->proxySet())
@can('manageProxy', $server)
@if ($traefikDashboardAvailable)
<div class="resource-heading-menus shrink-0">
<a class="app-tab shrink-0 gap-1" target="_blank" title="Open Traefik dashboard"
href="http://{{ formatHostForUrl((string) $serverIp) }}:8080">
<x-reicon name="external-link" class="size-3.5 shrink-0 opacity-70" />
Traefik Dashboard
</a>
</div>
@endif
<x-split-action id="server-desktop-actions" class="resource-heading-actions shrink-0">
@if ($proxyCanBeStopped)
<x-slot:main wire:loading.attr="disabled" wire:target="checkProxy,startProxy"
@click="document.getElementById('server-mobile-restart-proxy-trigger')?.click()">
<x-reicon name="restart" class="size-3.5" />
Restart Proxy
</x-slot:main>
<button type="button" class="listbox-option justify-start! gap-2.5!"
@click="open = false; document.getElementById('server-mobile-stop-proxy-trigger')?.click()"
role="menuitem">
<x-reicon name="stop-circle" class="size-3.5 text-error" />
Stop Proxy
</button>
@else
<x-slot:main wire:loading.attr="disabled" wire:target="checkProxy,startProxy" @click="$wire.dispatch('checkProxyEvent')">
<x-reicon name="play-circle" class="size-3.5" />
Start Proxy
</x-slot:main>
@endif
<button type="button" class="listbox-option justify-start! gap-2.5!" wire:click="checkProxyStatus"
wire:loading.attr="disabled" @click="open = false" role="menuitem">
<x-reicon name="refresh" class="size-3.5 opacity-70" />
Refresh Proxy Status
</div>
@endif
@if ($canManageProxy)
<x-split-action id="server-desktop-actions" class="resource-heading-actions shrink-0">
@if ($proxyCanBeStopped)
<x-slot:main wire:loading.attr="disabled" wire:target="checkProxy,startProxy"
@click="document.getElementById('server-mobile-restart-proxy-trigger')?.click()">
<x-reicon name="restart" class="size-3.5" />
Restart Proxy
</x-slot:main>
<button type="button" class="listbox-option justify-start! gap-2.5!"
@click="open = false; document.getElementById('server-mobile-stop-proxy-trigger')?.click()"
role="menuitem">
<x-reicon name="stop-circle" class="size-3.5 text-error" />
Stop Proxy
</button>
</x-split-action>
@endcan
@else
<x-slot:main wire:loading.attr="disabled" wire:target="checkProxy,startProxy" @click="$wire.dispatch('checkProxyEvent')">
<x-reicon name="play-circle" class="size-3.5" />
Start Proxy
</x-slot:main>
@endif
<button type="button" class="listbox-option justify-start! gap-2.5!" wire:click="checkProxyStatus"
wire:loading.attr="disabled" @click="open = false" role="menuitem">
<x-reicon name="refresh" class="size-3.5 opacity-70" />
Refresh Proxy Status
</button>
</x-split-action>
@endif
</div>
</div>
@endteleport
@endif
</div>
@script
+40 -51
View File
@@ -76,10 +76,8 @@
<x-application.settings-section id="server-connection-section" title="Connection"
helper="Configure how Coolify identifies, reaches, and validates this server.">
<x-slot:badges>
@if (isDev() && $server->isTransferredAway())
@unless ($server->isManagementDisabled())
<x-status-badge label="Transferred away" type="warning" />
@endunless
@if ($server->isTransferredAway())
<x-status-badge label="Transferred away" type="warning" />
@else
<x-status-badge :label="$server->isFunctional() ? 'Ready' : 'Validation required'"
:type="$server->isFunctional() ? 'success' : 'warning'" />
@@ -157,56 +155,49 @@
</x-forms.button>
</x-process-dialog>
@endif
@if (isDev())
<div wire:key="server-management-{{ $server->isTransferredAway() ? 'enable' : 'disable' }}">
@if ($server->isTransferredAway())
<x-modal-confirmation title="Enable management on this instance?"
submitAction="toggleManagement" :confirmWithText="false"
:confirmWithPassword="false" step2ButtonText="Enable management"
warningMessage="Before you continue, disable this server on every other Coolify instance. Two active instances can cause conflicting deployments, proxy changes, backups, and restarts."
:actions="['This instance will run deployments, webhooks, scheduled tasks, backups, proxy updates, and monitoring for this server.']">
<x-slot:trigger>
<x-forms.button type="button" canGate="update" :canResource="$server">
<x-reicon name="play-circle" class="size-3.5" />
Enable management
</x-forms.button>
</x-slot:trigger>
</x-modal-confirmation>
@else
<x-modal-confirmation title="Disable management on this instance?"
submitAction="toggleManagement" :confirmWithText="false"
:confirmWithPassword="false" step2ButtonText="Disable management"
:actions="[
'Deployments, webhooks, scheduled tasks, backups, proxy updates, and monitoring will stop on this instance for this server.',
'Existing workloads continue to run on the server.',
]">
<x-slot:trigger>
<x-forms.button type="button" canGate="update" :canResource="$server">
<x-reicon name="stop-circle" class="size-3.5" />
Disable management
</x-forms.button>
</x-slot:trigger>
</x-modal-confirmation>
@endif
</div>
@if ($server->isTransferredAway() && ! $server->isLocalhost())
@php
$transferTarget = data_get($server->server_metadata, 'transfer.target_instance_url') ?: 'another Coolify instance';
$transferredAt = data_get($server->server_metadata, 'transfer.transferred_at');
$transferredAt = $transferredAt ? \Illuminate\Support\Carbon::parse($transferredAt)->toDayDateTimeString().' UTC' : 'the transfer';
@endphp
<x-modal-confirmation title="Take back a transferred server?"
submitAction="enableManagement" :confirmWithPassword="false"
temporaryDisableTwoStepConfirmation
step2ButtonText="I understand, enable management"
:confirmationText="$server->name"
confirmationLabel="Enter the server name to take the server back."
shortConfirmationLabel="Server name"
:warningMessage="'<strong>Changes made on '.e($transferTarget).' after '.e($transferredAt).' are lost.</strong> This instance uses its copy of the settings and resources from the time of the transfer. Use this only when '.e($transferTarget).' is lost or cannot transfer the server back. Otherwise use Transfer back, which keeps those changes.'"
:actions="[
'This instance starts to run deployments, webhooks, scheduled tasks, backups, proxy updates, and monitoring for this server again.',
'The server records that this instance owns it. The next time the other instance connects to the server, it stops managing it automatically.',
'Sentinel on the server is reconfigured to send its data to this instance.',
'This instance takes back its DNS records and shows the steps that finish the take back: webhooks, DNS, Sentinel, backups, and runners.',
]">
<x-slot:trigger>
<x-forms.button type="button" isError canGate="update" :canResource="$server">
<x-reicon name="alert-triangle" class="size-3.5" />
Enable management
</x-forms.button>
</x-slot:trigger>
</x-modal-confirmation>
@endif
</x-slot:actions>
@if (isDev() && $server->isManagementDisabled())
<x-callout type="warning" title="Transferable to another instance" class="mb-4">
Management of this server is disabled on this Coolify instance. You can now
<a href="{{ route('server.transfer', ['server_uuid' => $server->uuid]) }}"
{{ wireNavigate() }} class="underline">transfer it to another instance</a>, or enable
management again to manage it here.
</x-callout>
@elseif (isDev() && $server->isTransferredAway())
<x-callout type="warning" title="Transferred to another instance" class="mb-4">
This server was migrated away from this Coolify instance. It cannot be revalidated or
managed here. Use the target instance, or delete this server when you no longer need the
archive.
@if ($server->isTransferredAway())
<x-callout type="danger" title="Transferred to another instance" class="mb-4">
This server was transferred to
<strong>{{ data_get($server->server_metadata, 'transfer.target_instance_url') ?: 'another Coolify instance' }}</strong>,
which manages it now. Use that instance. To undo the transfer, use
<a href="{{ route('server.transfer', ['server_uuid' => $server->uuid]) }}" {{ wireNavigate() }}
class="underline">Transfer back</a>. Keep this archive until you are sure: it is the copy
that an undo uses.
</x-callout>
@endif
<livewire:server.transfer-finish :server="$server" :key="'transfer-finish-'.$server->uuid" />
@if ($this->limaStartCommand)
<x-callout type="info" title="Start this Lima VM locally" class="mb-4">
<code
@@ -342,9 +333,7 @@
{{ $server->name }}
</p>
<p class="mt-1 text-xs leading-5 text-neutral-500 dark:text-fg-dim">
@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.
@@ -0,0 +1,171 @@
<div>
@if ($finish && $openCount > 0)
{{-- Not on the root element: Livewire starts a poll that a later render adds only on a new element. --}}
<div wire:key="transfer-finish-poll" wire:init="refreshSteps" wire:poll.15s="refreshSteps" hidden></div>
@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
<div class="mb-4 rounded-lg p-4 ring-1 ring-neutral-200 dark:ring-white/[0.08]" data-testid="transfer-finish">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0">
<h3 class="text-sm font-semibold text-neutral-950 dark:text-fg">Finish the transfer</h3>
<p class="mt-1 text-xs text-neutral-500 dark:text-fg-dim">
{{ $doneCount }} of {{ $totalCount }} done.
@if ($openCount > 0)
Coolify checks the open steps every 30 seconds.
@endif
</p>
</div>
<div class="flex items-center gap-2">
@if ($openCount > 0)
<span class="text-xs text-neutral-500 dark:text-fg-dim">
{{ $lastCheckedAt ? 'Checked '.\Illuminate\Support\Carbon::parse($lastCheckedAt)->format('H:i') : 'Checking…' }}
</span>
@can('update', $server)
<button type="button" class="icon-button" title="Check now" wire:click="checkAllNow"
wire:loading.attr="disabled" wire:target="checkAllNow">
<x-reicon name="refresh" class="size-4" />
</button>
@endcan
@else
<x-forms.button isHighlighted wire:click="finishTransfer" canGate="update" :canResource="$server">
Finish the transfer
</x-forms.button>
@endif
</div>
</div>
<div class="mt-3 h-1 overflow-hidden rounded-full bg-neutral-200 dark:bg-white/[0.08]">
<div class="h-full rounded-full bg-success transition-all"
style="width: {{ $totalCount > 0 ? round($doneCount / $totalCount * 100) : 0 }}%"></div>
</div>
@foreach ($openGroups as $group)
<div class="mt-4" wire:key="transfer-group-{{ $loop->index }}-{{ $group['label'] }}">
<p class="text-xs font-medium text-neutral-500 dark:text-fg-dim">{{ $group['label'] }}</p>
<ul class="mt-1 flex flex-col divide-y divide-neutral-200 dark:divide-white/[0.06]">
@foreach ($group['items'] as $step)
@php
$fixLabel = $checklist->fixLabel($step);
$manualOnly = ! $checklist->canCheck($step);
[$link, $linkLabel] = $checklist->linkOf($step) ?? [null, null];
$linkAttributes = $link && str_starts_with($link, 'http') ? 'target="_blank" rel="noopener noreferrer"' : wireNavigate();
$hasMenu = ! $manualOnly || $link;
$hasDetails = filled($step['hint'] ?? null) || count($step['details'] ?? []) > 0 || filled($step['error'] ?? null);
[$badgeLabel, $badgeType] = $badges[$step['status']] ?? ['Not checked yet', 'neutral'];
@endphp
<li wire:key="transfer-step-{{ $step['key'] }}" class="py-2" x-data="{ details: false, menu: false }">
<div class="flex items-center gap-3">
<span class="size-2 shrink-0 rounded-full {{ $badgeType === 'warning' ? 'bg-warning' : 'bg-neutral-400 dark:bg-neutral-500' }}"
title="{{ $badgeLabel }}" role="img" aria-label="{{ $badgeLabel }}"></span>
<div class="min-w-0 flex-1">
<p class="truncate text-sm text-neutral-950 dark:text-fg">{{ $step['label'] }}</p>
<p class="truncate text-xs text-neutral-500 dark:text-fg-dim">
{{ $step['message'] ?? $step['hint'] }}
@if ($hasDetails)
<button type="button" class="ml-1 underline" @click="details = !details"
x-text="details ? 'Hide details' : 'Details'"></button>
@endif
</p>
</div>
@can('update', $server)
<div class="flex shrink-0 items-center gap-1">
@if ($hasMenu)
<div class="relative" @click.outside="menu = false" @keydown.escape.window="menu = false">
<button type="button" class="icon-button" title="More actions" aria-haspopup="menu"
:aria-expanded="menu" @click="menu = !menu">
<x-reicon name="dots-horizontal" class="size-4" />
</button>
<div x-cloak x-show="menu" x-transition.origin.top.right role="menu"
class="listbox-panel top-full! right-0! left-auto! z-[90]! mt-1! w-40! min-w-40!">
@unless ($manualOnly)
<button type="button" class="listbox-option justify-start!" role="menuitem"
wire:click="checkStep('{{ $step['key'] }}')" @click="menu = false">Check now</button>
@endunless
@if (! $manualOnly || $link)
<button type="button" class="listbox-option justify-start!" role="menuitem"
wire:click="markStepDone('{{ $step['key'] }}')" @click="menu = false">Mark as done</button>
@endif
@if ($fixLabel && $link)
<a href="{{ $link }}" {!! $linkAttributes !!} class="listbox-option justify-start!"
role="menuitem">{{ $linkLabel }}</a>
@endif
</div>
</div>
@endif
@if ($fixLabel)
<x-forms.button wire:click="fixStep('{{ $step['key'] }}')">{{ $fixLabel }}</x-forms.button>
@elseif ($link)
<a href="{{ $link }}" {!! $linkAttributes !!} class="button">{{ $linkLabel }}</a>
@elseif ($manualOnly)
<x-forms.button wire:click="markStepDone('{{ $step['key'] }}')">Mark as done</x-forms.button>
@endif
</div>
@endcan
</div>
@if ($hasDetails)
<div x-cloak x-show="details" class="mt-2 ml-1 flex flex-col gap-1.5 text-xs text-neutral-600 dark:text-fg-dim">
@if (filled($step['hint'] ?? null) && filled($step['message'] ?? null))
<p>{{ $step['hint'] }}</p>
@endif
@foreach ($step['details'] ?? [] as $detail)
<div class="flex min-w-0 items-center gap-2" wire:key="transfer-detail-{{ $step['key'] }}-{{ $detail['label'] }}">
<span class="shrink-0">{{ $detail['label'] }}</span>
<code class="min-w-0 truncate rounded bg-neutral-100 px-1.5 py-0.5 dark:bg-white/[0.06]">{{ $detail['value'] }}</code>
<x-copy-button :value="$detail['value']" />
</div>
@endforeach
@if (filled($step['error'] ?? null))
<p class="break-words">{{ $step['error'] }}</p>
@endif
</div>
@endif
</li>
@endforeach
</ul>
</div>
@endforeach
@if ($doneCount > 0)
<div class="mt-4" x-data="{ showDone: false }">
<button type="button" class="flex items-center gap-1 text-xs text-neutral-500 dark:text-fg-dim"
@click="showDone = !showDone">
<x-reicon name="chevron-down" class="size-3 transition-transform" ::class="showDone ? '' : '-rotate-90'" />
{{ $doneCount }} {{ $doneCount === 1 ? 'step is' : 'steps are' }} OK or done
</button>
<ul x-cloak x-show="showDone" class="mt-1 flex flex-col gap-1">
@foreach ($doneItems as $step)
<li wire:key="transfer-done-{{ $step['key'] }}" class="flex items-center gap-2 text-xs text-neutral-500 dark:text-fg-dim">
<x-reicon name="check-circle" class="size-3.5 text-success" />
<span class="text-neutral-700 dark:text-fg">{{ $step['label'] }}</span>
<span class="truncate">{{ $step['message'] }}</span>
</li>
@endforeach
</ul>
</div>
@endif
@if ($openCount > 0)
@can('update', $server)
<div class="mt-4 flex justify-end">
<x-modal-confirmation title="Finish with open steps?" submitAction="finishTransfer"
:confirmWithText="false" :confirmWithPassword="false" step2ButtonText="Finish the transfer"
:warningMessage="'<strong>'.$openCount.' steps are not OK or done.</strong> Coolify does not check them again. Webhooks, DNS records, or runners can still point to the other instance.'"
:actions="collect($openGroups)->flatMap(fn ($group) => $group['items'])->map(fn ($step) => 'Skipped: '.$step['label'])->all()">
<x-slot:trigger>
<button type="button" class="text-xs text-neutral-500 underline dark:text-fg-dim">Finish with open steps…</button>
</x-slot:trigger>
</x-modal-confirmation>
</div>
@endcan
@endif
</div>
@endif
</div>
@@ -1,128 +0,0 @@
<div class="w-full">
<x-slot:title>
Import server | Coolify
</x-slot>
<div class="mb-5 flex min-h-9 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h1 class="min-w-0 text-[24px]! leading-7! font-semibold! tracking-tight!">Import server</h1>
<x-status-badge label="Dev" />
</div>
<p class="mt-1 text-[13px] text-neutral-500 dark:text-fg-dim">
Import a server that you downloaded from another Coolify instance. This instance then manages it.
</p>
</div>
<a href="{{ route('server.index') }}" class="button w-fit shrink-0" {{ wireNavigate() }}>
Back to servers
</a>
</div>
<div class="application-settings-form flex flex-col gap-6">
<x-application.settings-section id="server-import-file-section" title="Transfer file"
helper="On the source instance, open the server, go to Transfer, and click Download JSON in Manual transfer. The server data on the machine does not change.">
<x-slot:actions>
<x-forms.button wire:click="dryRun" wire:loading.attr="disabled" wire:target="dryRun,importBundle">
<x-reicon name="search" class="size-3.5" />
<span wire:loading.remove wire:target="dryRun">Dry run</span>
<span wire:loading wire:target="dryRun">Checking…</span>
</x-forms.button>
<x-modal-confirmation title="Import this server?" submitAction="importBundle"
:confirmWithText="false" :confirmWithPassword="false" step2ButtonText="Import server"
:actions="[
'The server and its resources will be added to the current team.',
'This instance will manage the server. Disable management of it on the source instance.',
]">
<x-slot:trigger>
<x-forms.button isHighlighted wire:loading.attr="disabled" wire:target="dryRun,importBundle">
<x-reicon name="upload" class="size-3.5" />
<span wire:loading.remove wire:target="importBundle">Import server</span>
<span wire:loading wire:target="importBundle">Importing…</span>
</x-forms.button>
</x-slot:trigger>
</x-modal-confirmation>
</x-slot:actions>
<div class="flex flex-col gap-4">
<x-forms.input type="file" id="bundleFile" label="Upload file"
accept=".json,application/json" />
<x-forms.textarea id="bundleJson" label="Or paste the file content" rows="10"
placeholder='{"schema_version":1,...}' />
<div class="md:max-w-xl">
<x-forms.input id="passphrase" type="password" label="Passphrase"
placeholder="Only for encrypted files" autocomplete="off" />
</div>
</div>
</x-application.settings-section>
<x-application.settings-section id="server-import-options-section" title="Options">
<div class="flex flex-col gap-1 md:max-w-xl">
<x-forms.checkbox id="preserveUuids" label="Keep UUIDs from the source instance"
helper="Resources keep the same UUIDs, so their names, containers, and webhook URLs stay the same." />
<x-forms.checkbox id="adoptMode" label="Adopt running resources"
helper="Keep the current status of each resource and do not redeploy stopped resources." />
</div>
</x-application.settings-section>
@if (count($lastWarnings) > 0)
<x-callout type="warning" title="Warnings">
<ul class="mt-1 list-disc space-y-1 pl-5">
@foreach ($lastWarnings as $warning)
<li>{{ $warning }}</li>
@endforeach
</ul>
</x-callout>
@endif
@if ($lastResult)
@php
$createdCounts = collect(data_get($lastResult, 'created', []))
->filter(fn ($count) => (int) $count > 0);
@endphp
<x-application.settings-section id="server-import-result-section"
:title="data_get($lastResult, 'dry_run') ? 'Dry run result' : 'Import result'">
<x-slot:actions>
@if (data_get($lastResult, 'dry_run'))
<x-status-badge label="Nothing written" type="neutral" />
@elseif (data_get($lastResult, 'claimed'))
<x-status-badge label="Managed here" type="success" />
@else
<x-status-badge label="Management not enabled" type="warning" />
@endif
@if ($importedServerUuid)
<a href="{{ route('server.show', ['server_uuid' => $importedServerUuid]) }}" class="button"
{{ wireNavigate() }}>
Open server
<x-reicon name="arrow-right" class="size-3.5" />
</a>
@endif
</x-slot:actions>
<div class="flex flex-col gap-4">
@if (filled(data_get($lastResult, 'server_uuid')))
<p class="text-xs text-neutral-500 dark:text-fg-dim">
Server UUID
<code class="ml-1 font-mono text-neutral-700 dark:text-fg">{{ data_get($lastResult, 'server_uuid') }}</code>
</p>
@endif
@if ($createdCounts->isNotEmpty())
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4">
@foreach ($createdCounts as $type => $count)
<div
class="rounded-lg border border-neutral-200 px-3 py-2 dark:border-white/[0.08]">
<div class="text-[15px] font-semibold text-neutral-950 dark:text-fg">
{{ $count }}</div>
<div class="text-[11px] text-neutral-500 dark:text-fg-faint">
{{ str($type)->replace('_', ' ')->ucfirst() }}</div>
</div>
@endforeach
</div>
@else
<p class="text-xs text-neutral-500 dark:text-fg-dim">No resources in this file.</p>
@endif
</div>
</x-application.settings-section>
@endif
</div>
</div>
@@ -11,29 +11,74 @@
<div class="application-settings-form flex w-full flex-col gap-6">
@if ($this->isLocalhost)
<x-application.settings-section id="server-transfer-section" title="Transfer server (Dev)"
<x-application.settings-section id="server-transfer-section" title="Transfer server"
helper="Move this server’s control-plane config to another Coolify instance (same physical host).">
<x-callout type="warning" title="Localhost cannot be transferred">
The Coolify host (localhost) cannot be transferred between instances.
</x-callout>
</x-application.settings-section>
@else
<x-application.settings-section id="server-transfer-section" title="Transfer server (Dev)"
<x-application.settings-section id="server-transfer-section" title="Transfer server"
helper="Move this server’s control-plane config to another Coolify instance (same physical host).">
<x-slot:actions>
@if ($server->isManagementDisabled())
<x-status-badge label="Transferable" type="warning" />
@php
$exportTooltip = $exportId ? "Export ID: {$exportId}" : null;
@endphp
@if (data_get($this->migration, 'status') === 'running')
<x-status-badge label="Transferring" type="warning" :data-tooltip="$exportTooltip" />
@elseif ($server->isTransferredAway())
<x-status-badge label="Transferred away" type="warning" />
<x-status-badge label="Transferred away" type="warning" :data-tooltip="$exportTooltip" />
@else
<x-status-badge label="Managed here" type="success" />
@endif
@if ($exportId)
<span class="text-[11px] text-neutral-500 dark:text-fg-faint">export {{ $exportId }}</span>
<x-status-badge label="Managed here" type="success" :data-tooltip="$exportTooltip" />
@endif
</x-slot:actions>
<div class="flex flex-col gap-4">
@if ($server->isTransferredAway())
<div>
<h3 class="text-sm font-semibold text-neutral-950 dark:text-fg">Transfer back</h3>
<p class="mt-1 text-xs leading-5 text-neutral-500 dark:text-fg-dim">
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.
</p>
</div>
<div class="flex flex-col gap-3 md:max-w-xl">
<x-forms.input id="managerUrl" label="Instance that manages the server now" required
placeholder="https://coolify.example.com"
helper="The Coolify instance that this server was transferred to." />
<x-forms.input id="managerToken" type="password" label="API token of that instance" required
placeholder="Paste token from that instance" autocomplete="off"
helper="A token with root, or with read:sensitive and write, from an admin or owner of that instance." />
<x-forms.input id="thisInstanceUrl" label="URL of this instance" required
helper="The URL at which the other instance reaches this instance. This instance gives it a token that expires in 3 hours." />
</div>
<div>
<x-modal-confirmation title="Transfer this server back?" submitAction="transferBack"
:confirmWithText="false" :confirmWithPassword="false" step2ButtonText="Transfer back"
:actions="[
'The other instance checks the server, this instance, and the tokens. If a check fails, nothing changes.',
'The other instance transfers the server, its resources, and its credentials back here.',
'This archive is replaced by the transferred server, and the other instance keeps an archive.',
]">
<x-slot:trigger>
<x-forms.button canGate="update" :canResource="$server" wire:loading.attr="disabled"
wire:target="transferBack">
<span wire:loading.remove wire:target="transferBack">Transfer back</span>
<span wire:loading wire:target="transferBack">Running checks…</span>
</x-forms.button>
</x-slot:trigger>
</x-modal-confirmation>
</div>
@if ($transferBackAt = data_get($server->server_metadata, 'transfer.transfer_back.requested_at'))
<x-callout type="info" title="Transfer back started">
{{ 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.
</x-callout>
@endif
@else
<div>
<h3 class="text-sm font-semibold text-neutral-950 dark:text-fg">Transfer to another instance
</h3>
@@ -45,8 +90,8 @@
</div>
<div class="flex flex-col gap-3 md:max-w-xl">
<x-forms.input id="targetUrl" label="Target instance URL" required
placeholder="http://localhost:8001"
helper="Base URL of the other Coolify instance (no trailing path)." />
placeholder="https://coolify.example.com"
helper="Base URL of the other Coolify instance (no trailing path). It must use https." />
<x-forms.input id="targetToken" type="password" label="Target API token" required
placeholder="Paste token from target instance" autocomplete="off"
helper="Create a token on the target with root (or write + create servers). It is only used for this request." />
@@ -55,6 +100,7 @@
<x-modal-confirmation title="Transfer this server?" submitAction="migrateServer"
:confirmWithText="false" :confirmWithPassword="false" step2ButtonText="Transfer server"
:actions="[
'Coolify first checks this server, the target instance, and the token. If a check fails, nothing is changed.',
'The server will be transferred to the target instance.',
'Automations for this server will be disabled on this instance.',
]">
@@ -62,11 +108,83 @@
<x-forms.button canGate="update" :canResource="$server" wire:loading.attr="disabled"
wire:target="migrateServer">
<span wire:loading.remove wire:target="migrateServer">Transfer server</span>
<span wire:loading wire:target="migrateServer">Transferring…</span>
<span wire:loading wire:target="migrateServer">Running checks…</span>
</x-forms.button>
</x-slot:trigger>
</x-modal-confirmation>
</div>
@endif
@php $migration = $this->migration; @endphp
@if (data_get($migration, 'status') === 'running')
<div wire:poll.5s="refreshMigration">
<x-callout type="info" title="Transfer in progress">
The server is being transferred to {{ data_get($migration, 'target_url') }}. You can
leave this page; the transfer continues in the background.
</x-callout>
</div>
@elseif (data_get($migration, 'status') === 'failed')
<x-callout type="danger"
:title="data_get($migration, 'phase') === 'checks' ? 'A check failed. The transfer did not start.' : 'Transfer failed'">
@if (count((array) data_get($migration, 'log', [])) > 0)
See the transfer log below for details.
@else
{{ data_get($migration, 'error') }}
@endif
</x-callout>
@elseif (data_get($migration, 'status') === 'unknown')
<x-callout type="warning" title="Transfer result 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
<code>POST /api/v1/servers/{{ $server->uuid }}/transfer/complete</code> on this instance.
If it does not, transfer again.
</x-callout>
@elseif (data_get($migration, 'status') === 'succeeded')
<x-callout type="success" title="Transfer finished">
{{ data_get($migration, 'target_url') }} now manages this server. Automations are
disabled on this instance.
</x-callout>
@php $checklist = $this->postTransferChecklist; @endphp
@if (count($checklist) > 0 || data_get($migration, 'target_server_url'))
<div data-testid="post-transfer-checklist">
<h3 class="text-sm font-semibold text-neutral-950 dark:text-fg">Finish the transfer on the target</h3>
<p class="mt-1 text-xs leading-5 text-neutral-500 dark:text-fg-dim">
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'))
<a href="{{ $targetServerUrl }}" target="_blank" rel="noopener noreferrer"
class="underline">Open the server on the target</a>.
@endif
</p>
@if (count($checklist) > 0)
<ul class="mt-2 list-disc space-y-1 pl-5 text-xs leading-5 text-neutral-600 dark:text-fg-dim">
@foreach ($checklist as $step)
<li wire:key="post-transfer-step-{{ hash('xxh128', $step) }}">{{ $step }}</li>
@endforeach
</ul>
@endif
</div>
@endif
@endif
@if (count((array) data_get($migration, 'log', [])) > 0)
<div>
<h3 class="text-sm font-semibold text-neutral-950 dark:text-fg">Transfer log</h3>
<ol class="mt-2 flex flex-col gap-1 rounded-lg border border-neutral-200 bg-neutral-50 p-3 font-mono text-xs dark:border-coolgray-300 dark:bg-coolgray-100"
data-testid="transfer-log">
@foreach ((array) data_get($migration, 'log', []) as $entry)
<li wire:key="transfer-log-{{ $loop->index }}-{{ data_get($entry, 'at') }}" @class([
'flex gap-3',
'text-success' => data_get($entry, 'level') === 'success',
'text-error' => data_get($entry, 'level') === 'error',
'text-warning' => data_get($entry, 'level') === 'warning',
'text-neutral-600 dark:text-fg-dim' => data_get($entry, 'level') === 'info',
])>
<span class="shrink-0 text-neutral-400">{{ \Illuminate\Support\Carbon::parse(data_get($entry, 'at'))->format('H:i:s') }}</span>
<span class="break-words">{{ data_get($entry, 'message') }}</span>
</li>
@endforeach
</ol>
</div>
@endif
@if (count($lastWarnings) > 0)
<x-callout type="warning" title="Warnings">
<ul class="mt-2 list-disc space-y-1 pl-5 text-sm">
@@ -76,41 +194,9 @@
</ul>
</x-callout>
@endif
@if ($lastResultJson)
<div>
<div class="mb-1 text-sm font-semibold">Result</div>
<pre
class="max-h-64 overflow-auto rounded-lg bg-neutral-100 p-3 text-xs dark:bg-coolgray-100">{{ $lastResultJson }}</pre>
</div>
@endif
</div>
</x-application.settings-section>
<x-application.settings-section id="server-transfer-manual-section" title="Manual transfer"
helper="Download the server as a file, for example when the instances cannot reach each other.">
<div class="flex flex-col gap-4">
<p class="text-xs leading-5 text-neutral-500 dark:text-fg-dim">
Import the file on the target via Servers → Import server, then click Disable management
on this server.
</p>
<div class="flex flex-col gap-3 md:max-w-xl">
<x-forms.input id="passphrase" type="password" label="Passphrase"
placeholder="Optional"
helper="If you set a passphrase, the file is encrypted with it. You need the same passphrase to import the file."
autocomplete="new-password" />
</div>
<div class="flex flex-wrap gap-2">
<x-forms.button canGate="view" :canResource="$server" wire:click="exportBundle"
wire:loading.attr="disabled">
<span wire:loading.remove wire:target="exportBundle">Download JSON</span>
<span wire:loading wire:target="exportBundle">Exporting…</span>
</x-forms.button>
<a href="{{ route('server.transfer.import') }}" {{ wireNavigate() }} class="button">
Import page (this instance)
</a>
</div>
</div>
</x-application.settings-section>
@endif
</div>
</div>
+2 -2
View File
@@ -203,9 +203,9 @@ Route::group([
Route::post('/servers/{uuid}/proxy/restart', [ServerProxyController::class, 'restart'])->middleware(['api.ability:write']);
Route::post('/servers', [ServersController::class, 'create_server'])->middleware(['api.ability:write']);
Route::get('/servers/import/check', [ServerTransferController::class, 'check'])->middleware(['api.ability:write']);
Route::post('/servers/import', [ServerTransferController::class, 'import'])->middleware(['api.ability:write']);
Route::get('/servers/{uuid}/export', [ServerTransferController::class, 'export'])->middleware(['api.ability:read']);
Route::post('/servers/{uuid}/export/mailbox', [ServerTransferController::class, 'writeMailbox'])->middleware(['api.ability:write']);
Route::get('/servers/{uuid}/transfer', [ServerTransferController::class, 'status'])->middleware(['api.ability:read,write']);
Route::post('/servers/{uuid}/transfer/complete', [ServerTransferController::class, 'complete'])->middleware(['api.ability:write']);
Route::post('/servers/{uuid}/migrate', [ServerTransferController::class, 'migrate'])->middleware(['api.ability:write']);
Route::patch('/servers/{uuid}', [ServersController::class, 'update_server'])->middleware(['api.ability:write']);
-2
View File
@@ -108,7 +108,6 @@ use App\Livewire\Server\Sentinel\Show as SentinelShow;
use App\Livewire\Server\Show as ServerShow;
use App\Livewire\Server\Swarm as ServerSwarm;
use App\Livewire\Server\Transfer as ServerTransfer;
use App\Livewire\Server\TransferImport as ServerTransferImport;
use App\Livewire\Settings\Advanced as SettingsAdvanced;
use App\Livewire\Settings\Index as SettingsIndex;
use App\Livewire\Settings\Updates as SettingsUpdates;
@@ -482,7 +481,6 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/servers', ServerIndex::class)->name('server.index');
Route::get('/servers/canvas', ServerCanvas::class)->name('server.canvas.index');
Route::get('/registries', ServerDockerRegistries::class)->name('registries.index');
Route::get('/servers/import', ServerTransferImport::class)->name('server.transfer.import')->middleware('can:create,'.Server::class);
Route::get('/servers/new', ServerCreatePage::class)->name('server.create')->middleware('can:create,'.Server::class);
Route::get('/servers/new/{type}/{token_uuid}', ServerCreatePage::class)->name('server.create.token')->middleware('can:create,'.Server::class)->whereIn('type', ['hetzner', 'vultr', 'digital-ocean', 'hostinger']);
Route::get('/servers/new/{type}', ServerCreatePage::class)->name('server.create.type')->middleware('can:create,'.Server::class)->whereIn('type', ['hetzner', 'vultr', 'digital-ocean', 'hostinger', 'manual']);
+23 -3
View File
@@ -24,12 +24,17 @@
#
# Env: COOLIFY_DEV_SERVER_BACKEND=auto|lima|testing-host, COOLIFY_DEV_KVM_PROFILE=<profile>
#
# Side by side: COOLIFY_DEV_INSTANCE=<name> COOLIFY_DEV_SIDE_BY_SIDE=1 starts a second instance of
# this checkout without stopping the other ones (for example a transfer target, see
# scripts/transfer-test). It gets its own SSH key folder; the instance remembers the mode.
#
set -euo pipefail
cd "$(dirname "$0")/.."
ROOT="$(pwd -P)"
COMPOSE_FILE="docker-compose.dev-multi.yml"
SIDE_BY_SIDE_COMPOSE_FILE="docker-compose.dev-side-by-side.yml"
GIT_COMMON_DIR="$(git rev-parse --path-format=absolute --git-common-dir)"
# Shared by all worktrees, so slots, APP_KEYs, and data survive worktree removal.
ENV_DIR="$(dirname "$GIT_COMMON_DIR")/.dev-instances"
@@ -38,7 +43,7 @@ QEMU_STORAGE="${DEVELOPMENT_QEMU_STORAGE_PATH:-/var/lib/libvirt/images/coolify-d
LEGACY_CONTAINERS=(coolify coolify-db coolify-redis coolify-vite coolify-mail coolify-minio coolify-minio-init coolify-testing-host)
usage() {
sed -n '2,26p' "$0" | sed 's/^# \?//'
sed -n '2,30p' "$0" | sed 's/^# \?//'
}
normalize_name() {
@@ -66,6 +71,7 @@ current_instance() {
project_name() { echo "coolify-dev-$1"; }
env_file() { echo "$ENV_DIR/$1.env"; }
env_value() { grep -E "^$2=" "$(env_file "$1")" | cut -d= -f2-; }
is_side_by_side() { [[ -f "$(env_file "$1")" ]] && grep -qxF 'DEV_SIDE_BY_SIDE=1' "$(env_file "$1")"; }
# Lowest free slot (1..254), stable per instance name. It selects the port block
# and the libvirt network (10.221.<slot>.0/24) of the instance.
@@ -135,6 +141,11 @@ ensure_env() {
fi
printf '%s\n' "$existing_key" >"$ENV_DIR/$name.appkey"
local side_by_side="${COOLIFY_DEV_SIDE_BY_SIDE:-}"
if [[ -z "$side_by_side" ]] && is_side_by_side "$name"; then
side_by_side=1
fi
cat >"$env_file" <<EOF
# Generated by scripts/dev for instance ${name} — do not commit
COMPOSE_PROJECT_NAME=$(project_name "$name")
@@ -158,6 +169,9 @@ PUSHER_APP_ID=coolify-${name}
PUSHER_APP_KEY=coolify-${name}
PUSHER_APP_SECRET=coolify-${name}
EOF
if [[ "$side_by_side" == 1 ]]; then
echo 'DEV_SIDE_BY_SIDE=1' >>"$env_file"
fi
}
# compose <name> <args...>: .env of the checkout first, generated instance env wins.
@@ -168,7 +182,11 @@ compose() {
if [[ -f .env ]]; then
env_files+=(--env-file .env)
fi
docker compose -p "$(project_name "$name")" -f "$COMPOSE_FILE" \
local -a compose_files=(-f "$COMPOSE_FILE")
if is_side_by_side "$name"; then
compose_files+=(-f "$SIDE_BY_SIDE_COMPOSE_FILE")
fi
docker compose -p "$(project_name "$name")" "${compose_files[@]}" \
"${env_files[@]}" --env-file "$(env_file "$name")" "${profiles[@]}" "$@"
}
@@ -221,12 +239,14 @@ wait_for_coolify() {
# Only one instance of a checkout runs at a time: they share its ports (main checkout)
# and its public/hot Vite file. The legacy fixed-name stack also holds the main ports.
# Side-by-side instances have their own ports and run next to the others on purpose.
stop_other_checkout_instances() {
local file other
is_side_by_side "$instance" && return 0
for file in "$ENV_DIR"/*.env; do
[[ -f "$file" ]] || continue
other="$(basename "$file" .env)"
if [[ "$other" != "$instance" ]] && grep -qxF "DEV_CHECKOUT=$ROOT" "$file" &&
if [[ "$other" != "$instance" ]] && ! is_side_by_side "$other" && grep -qxF "DEV_CHECKOUT=$ROOT" "$file" &&
[[ -n "$(docker ps -q --filter "label=com.docker.compose.project=$(project_name "$other")")" ]]; then
echo "Stopping instance ${other} (same checkout)"
docker compose -p "$(project_name "$other")" stop
+70
View File
@@ -0,0 +1,70 @@
# Server transfer test kit
Live end-to-end tests for the server transfer (moving a server and its resources to another Coolify instance).
The unit tests in `tests/Unit/ServerTransfer/` fake SSH and HTTP. These scripts use two real dev instances and
real VMs, so they also find problems with shells, sudo, networks, queues, and running containers.
- **Source (A):** the dev instance of this checkout (`./scripts/dev start`). Its localhost is the `ubuntu-root` VM.
The tests transfer its `ubuntu-non-root` and `debian-root` VMs.
- **Target (B):** a second instance of the same checkout that runs side by side (`transfer-target`). Its localhost is
the testing-host container.
Requirements: Linux with KVM (the dev VMs), root or passwordless sudo (VMs and iptables), `jq`, and PHP on the host.
macOS with Lima is not supported yet.
## Steps
```bash
./scripts/dev start # A, if it does not run yet
scripts/transfer-test/setup.sh # start B and connect A, B, and A's VMs (safe to run again)
scripts/transfer-test/reset.sh # WIPES BOTH DEV DATABASES, then seeds A with deployed resources (~4 min)
scripts/transfer-test/e2e.sh # transfer both VMs A -> B through the API (41 checks)
scripts/transfer-test/reset.sh
scripts/transfer-test/reverse-e2e.sh # undo paths: transfer back, transfer over an archive, take back, archive delete (18 checks)
scripts/transfer-test/reset.sh
scripts/transfer-test/extras-e2e.sh # fake tokens, DNS, secret links, runner config, links, icon ... (31 checks)
scripts/transfer-test/reset.sh
```
For a manual test, run `scripts/transfer-test/manual-setup.sh`: it resets, adds the fake extra records to A, and
prints the URLs, logins, and tokens.
Each test ends with `=== N failure(s)` and exits with N. Run `reset.sh` between two tests: a test leaves servers
transferred. After `reset.sh`, you can also test by hand: log in to A and B with `test@example.com` / `password`, open a
VM server on A → Transfer, and use the target URL `http://localhost:<B port>` with the token in
`<main checkout>/.dev-instances/transfer-test/<source>/target.token`.
## Files
| File | What it does |
|---|---|
| `lib.sh` | Shared values (from `.dev-instances/*.env` and `config/development-qemu.php`) and helpers |
| `setup.sh` | Starts B side by side, adds the iptables rule for B → A's VMs, and adds socat forwards when the ports listen on 127.0.0.1 |
| `reset.sh` | Removes test workloads from the VMs, wipes both databases, seeds A's VMs, makes the helper image available, creates API tokens, runs `seed.sh` |
| `seed.sh` | Creates and deploys resources on A through the API |
| `e2e.sh`, `reverse-e2e.sh`, `extras-e2e.sh` | The tests |
| `seed-extras.php`, `verify-extras-*.php` | Fake records and checks of `extras-e2e.sh` |
| `manual-setup.sh` | Reset plus the fake extra records, then prints what to open and enter for a manual test |
| `seed-inventory.php` | Seeds a large database-only demo server (no deploys) on the current instance |
Tokens and the VM SSH key are written to `<main checkout>/.dev-instances/transfer-test/<source>/` (not in git).
For a full manual test of every transferred setting, see `docs/server-transfer-full-coverage.md`.
## Options
- `TRANSFER_TEST_SOURCE=<instance>`: another source instance than the one of this checkout.
- `TRANSFER_TEST_TARGET=<instance>`: another target name than `transfer-target`.
## Notes
- A and B share `storage/` of the checkout. The side-by-side mode of `scripts/dev` gives B its own SSH folder, because
both instances have a server with the uuid `localhost`.
- A and B have the same `app.id`, so the DNS owner comment does not change in a transfer here.
`extras-e2e.sh` expects that. With different ids, the target must change the comment at the DNS provider.
- `reset.sh` tags the newest local helper image with the configured helper version when that version is not
published yet.
- Remove the kit: run `./scripts/dev destroy transfer-target` and
`docker rm -f $(docker ps -aq --filter name=coolify-transfer-test-forward)`. Then remove the iptables rule that
`setup.sh` added: `sudo iptables -D LIBVIRT_FWI -s <B subnet> -d 10.221.<A slot>.0/24 -o cdevbr<A slot> -j ACCEPT`
(`setup.sh` prints the subnet; `sudo iptables -S LIBVIRT_FWI` lists the rules).
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# Transfer both VM servers A -> B through the API and check source, target, server, and a redeploy from B.
# Run reset.sh before and after.
source "$(dirname "$0")/lib.sh"
TA="$(source_token)"
TB="$(target_token)"
for profile in "${TRANSFER_PROFILES[@]}"; do
S="$(vm_field "$profile" uuid)"
ip="$(vm_field "$profile" ip)"
echo "=== $S"
before="$(stable_workloads "$profile")"
apps="$(tinker "$A" "echo App\\Models\\Server::where('uuid','$S')->first()->applications()->pluck('uuid')->implode(' ');" | tail -1)"
echo '-- start the transfer on A'
r="$(api POST "$A_PORT" "/servers/$S/migrate" "$TA" "$(jq -nc --arg u "http://localhost:$B_PORT" --arg t "$TB" '{target_url:$u,target_token:$t}')")"
if [[ "$(jq -r .migration.status <<<"$r" 2>/dev/null)" != running ]]; then
bad "transfer request accepted: $r"
continue
fi
ok 'transfer request accepted'
for _ in $(seq 1 40); do sleep 3; st="$(api GET "$A_PORT" "/servers/$S/transfer" "$TA" | jq -r .migration.status)"; [[ "$st" != running ]] && break; done
check "transfer finished ($st)" test "$st" = succeeded
echo '-- source A'
a="$(api GET "$A_PORT" "/servers/$S/transfer" "$TA")"
export_id="$(jq -r .transfer.export_id <<<"$a")"
check 'A transfer.status = transferred' test "$(jq -r .transfer.status <<<"$a")" = transferred
check 'A force_disabled + Sentinel off' test "$(tinker "$A" "\$s=App\\Models\\Server::where('uuid','$S')->first()->settings; echo (int)\$s->force_disabled.(int)\$s->is_sentinel_enabled;" | tail -1)" = 10
check 'A shows the take-back modal' test "$(tinker "$A" "$login_php echo (int) str_contains(Livewire\\Livewire::test(App\\Livewire\\Server\\Show::class, ['server_uuid'=>'$S'])->html(), 'Take back a transferred server?');" | tail -1)" = 1
r="$(api POST "$A_PORT" "/servers/$S/migrate" "$TA" "$(jq -nc --arg u "http://localhost:$B_PORT" --arg t "$TB" '{target_url:$u,target_token:$t}')")"
check 'second transfer refused by the prechecks' grep -q 'already transferred' <<<"$r"
echo '-- target B'
b="$(api GET "$B_PORT" "/servers/$S/transfer" "$TB")"
check 'B transfer.status = claimed' test "$(jq -r .transfer.status <<<"$b")" = claimed
check 'same export id on A and B' test "$(jq -r .transfer.export_id <<<"$b")" = "$export_id"
for _ in $(seq 1 20); do
sleep 3
meta="$(tinker "$B" "\$s=App\\Models\\Server::where('uuid','$S')->first(); echo json_encode(['os'=>data_get(\$s->server_metadata,'os'),'reach'=>\$s->settings->is_reachable,'usable'=>\$s->settings->is_usable,'logs'=>\$s->validation_logs,'transfer'=>data_get(\$s->server_metadata,'transfer.status')]);" | tail -1)"
[[ "$(jq -r .os <<<"$meta")" != null ]] && break
done
check "automatic validation filled the metadata ($(jq -r .os <<<"$meta"))" test "$(jq -r .os <<<"$meta")" != null
check 'B reachable + usable, no validation error' test "$(jq -c '[.reach,.usable,.logs]' <<<"$meta")" = '[true,true,null]'
check 'transfer state kept after validation' test "$(jq -r .transfer <<<"$meta")" = claimed
check 'B started the finish list' test "$(tinker "$B" "echo count((array) data_get(App\\Models\\Server::where('uuid','$S')->first()->server_metadata,'transfer.finish.items')) > 0 ? 1 : 0;" | tail -1)" = 1
check 'A links to the server on B' test "$(jq -r .migration.target_server_url <<<"$a")" = "$B_URL/server/$S"
check 'B has all applications' test "$(tinker "$B" "echo App\\Models\\Server::where('uuid','$S')->first()->applications()->pluck('uuid')->implode(' ');" | tail -1)" = "$apps"
check 'B validates the server over SSH' test "$(tinker "$B" "\$r=App\\Models\\Server::where('uuid','$S')->first()->validateConnection(); echo (int)(bool)\$r['uptime'];" | tail -1)" = 1
echo '-- server'
sleep 5
after="$(workloads "$profile")"
check 'proxy + workloads not recreated' test "$before" = "$after"
sentinel_env="$(ssh_vm "$profile" "docker inspect coolify-sentinel --format '{{range .Config.Env}}{{println .}}{{end}}'")"
check 'Sentinel sends to B' grep -qx "PUSH_ENDPOINT=$B_URL" <<<"$sentinel_env"
for app in $apps; do check "app $app answers over HTTP" bash -c "curl -sf -m5 -H 'Host: $app.$ip.sslip.io' http://$ip/ >/dev/null"; done
echo '-- redeploy one app from B'
app="${apps%% *}"
api POST "$B_PORT" "/deploy?uuid=$app&force=false" "$TB" >/dev/null
for _ in $(seq 1 40); do sleep 5; ds="$(tinker "$B" "echo App\\Models\\ApplicationDeploymentQueue::where('application_id', App\\Models\\Application::where('uuid','$app')->value('id'))->latest('id')->value('status');" | tail -1)"; [[ "$ds" == finished || "$ds" == failed ]] && break; done
check "redeploy from B ($ds)" test "$ds" = finished
check 'app answers after the redeploy' bash -c "for i in {1..10}; do curl -sf -m5 -H 'Host: $app.$ip.sslip.io' http://$ip/ >/dev/null && exit 0; sleep 3; done; exit 1"
done
echo "=== $FAILS failure(s)"
exit "$FAILS"
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Add fake records around debian-root on A (tokens, DNS, secret manager link, tunnel token, GitHub App + runner config,
# resource links, canvas positions, team variable, maintenance mode, proxy error page, project icon, and an app of the
# same project on the other VM), transfer debian-root to B through the Transfer page, and check them on B and A.
# Run reset.sh before and after.
source "$(dirname "$0")/lib.sh"
S="$(vm_field debian-root uuid)"
echo '=== seed fake records on A'
php_file "$A" "$TT_DIR/seed-extras.php"
echo "=== transfer $S A -> B"
echo "status: $(transfer_from_page "$S")"
# The target compares the DNS owner comment, which contains the instance id, with the one of the source.
tinker "$A" 'echo config("app.id");' | tail -1 | docker exec -i "$B" sh -c 'cat > /tmp/source-app-id'
echo '=== target B'
b="$(php_file "$B" "$TT_DIR/verify-extras-target.php")"
echo "$b"
echo '=== source A'
a="$(php_file "$A" "$TT_DIR/verify-extras-source.php")"
echo "$a"
FAILS="$(printf '%s\n%s\n' "$a" "$b" | grep -c '^FAIL')"
echo "=== $FAILS failure(s)"
exit "$FAILS"
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env bash
# Shared values and helpers of the server transfer test kit. The other scripts source this file.
#
# Source (A): the dev instance of this checkout, as `./scripts/dev` names it. Override: TRANSFER_TEST_SOURCE.
# Target (B): a side-by-side instance of the same checkout. Default "transfer-target". Override: TRANSFER_TEST_TARGET.
# Tokens and the VM SSH key are stored in <main checkout>/.dev-instances/transfer-test/<source>/ (not in git).
set -uo pipefail
TT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
ROOT="$(cd "$TT_DIR/../.." && pwd -P)"
cd "$ROOT"
ENV_DIR="$(dirname "$(git rev-parse --path-format=absolute --git-common-dir)")/.dev-instances"
SOURCE="${TRANSFER_TEST_SOURCE:-$(./scripts/dev container | sed 's/^coolify-dev-//')}"
TARGET="${TRANSFER_TEST_TARGET:-transfer-target}"
A="coolify-dev-$SOURCE"
B="coolify-dev-$TARGET"
STATE_DIR="$ENV_DIR/transfer-test/$SOURCE"
VM_KEY="$STATE_DIR/vm_key"
# The VM servers that the tests transfer, and the profile that is localhost of the source.
TRANSFER_PROFILES=(ubuntu-non-root debian-root)
LOCALHOST_PROFILE=ubuntu-root
PRIVILEGED=()
if [[ $EUID -ne 0 ]]; then
PRIVILEGED=(sudo -n -E)
fi
die() {
echo "ERROR: $*" >&2
exit 1
}
env_value() {
local file="$ENV_DIR/$1.env"
[[ -f "$file" ]] || die "No dev instance '$1' ($file). See ./scripts/dev urls"
grep -E "^$2=" "$file" | cut -d= -f2-
}
A_PORT="$(env_value "$SOURCE" APP_PORT)"
A_SLOT="$(env_value "$SOURCE" DEVELOPMENT_QEMU_SLOT)"
A_URL="$(env_value "$SOURCE" APP_URL)"
target_values() {
B_PORT="$(env_value "$TARGET" APP_PORT)"
B_URL="$(env_value "$TARGET" APP_URL)"
}
if [[ -f "$ENV_DIR/$TARGET.env" ]]; then
target_values
fi
# vm_field <profile> <field>: a value of config/development-qemu.php for the source instance (ip, user, uuid).
vm_field() {
DEVELOPMENT_QEMU_INSTANCE="$SOURCE" DEVELOPMENT_QEMU_SLOT="$A_SLOT" php -r '
function env($key, $default = null) { $value = getenv($key); return $value === false ? $default : $value; }
$config = require "config/development-qemu.php";
echo $config["profiles"][$argv[1]][$argv[2]];
' "$1" "$2"
}
running() { [[ "$(docker inspect --format '{{.State.Running}}' "$1" 2>/dev/null)" == true ]]; }
# tinker <container> <php>: run PHP in a Coolify container.
tinker() { docker exec -u www-data "$1" php artisan tinker --execute "$2" 2>/dev/null; }
# php_file <container> <file>: run a PHP file of this kit in a Coolify container.
php_file() {
docker cp "$2" "$1:/tmp/$(basename "$2")" >/dev/null &&
docker exec -u www-data "$1" php artisan tinker --execute "require '/tmp/$(basename "$2")';" 2>&1 | grep -v '^$'
}
# api <METHOD> <port> <path> <token> [json]
api() {
curl -sS -m 30 -X "$1" "http://127.0.0.1:$2/api/v1$3" -H "Authorization: Bearer $4" \
-H 'Content-Type: application/json' -H 'Accept: application/json' ${5:+-d "$5"}
}
source_token() { cat "$STATE_DIR/source.token"; }
target_token() { cat "$STATE_DIR/target.token"; }
# The VMs accept the dev key that the seeders create as private key 1.
fetch_vm_key() {
mkdir -p "$STATE_DIR"
tinker "$A" 'echo optional(App\Models\PrivateKey::find(1))->private_key;' >"$VM_KEY.tmp"
if grep -q 'PRIVATE KEY' "$VM_KEY.tmp"; then
mv "$VM_KEY.tmp" "$VM_KEY"
chmod 600 "$VM_KEY"
return 0
fi
rm -f "$VM_KEY.tmp"
return 1
}
# ssh_vm <profile> <command>: run a shell command as root on a VM of the source.
ssh_vm() {
local user ip sudo=''
user="$(vm_field "$1" user)"
ip="$(vm_field "$1" ip)"
[[ "$user" != root ]] && sudo='sudo '
ssh -i "$VM_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=5 \
"$user@$ip" "${sudo}sh -c $(printf '%q' "$2")"
}
# workloads <profile>: the containers on a VM, without Sentinel, which a claim restarts on purpose.
workloads() { ssh_vm "$1" 'docker ps --format "{{.Names}} {{.ID}}" | grep -v coolify-sentinel | sort'; }
# stable_workloads <profile>: the workloads once they stop changing. reset.sh returns while databases still start.
stable_workloads() {
local previous='' current
for _ in $(seq 1 30); do
current="$(workloads "$1")"
[[ -n "$current" && "$current" == "$previous" ]] && break
previous="$current"
sleep 10
done
echo "$current"
}
# require_dev_instance <container>: refuse to wipe anything that is not a running local dev instance.
require_dev_instance() {
running "$1" || die "Container $1 is not running."
[[ "$1" == coolify-dev-* ]] || die "$1 is not a scripts/dev instance."
[[ "$(tinker "$1" 'echo app()->environment();' | tail -1)" == local ]] || die "$1 does not run with APP_ENV=local."
}
# Pass/fail counter of the e2e scripts.
FAILS=0
ok() { echo " PASS $*"; }
bad() {
echo " FAIL $*"
FAILS=$((FAILS + 1))
}
check() {
local name="$1"
shift
if "$@" >/dev/null 2>&1; then ok "$name"; else bad "$name"; fi
}
login_php="auth()->login(App\\Models\\User::find(0)); session(['currentTeam' => App\\Models\\Team::find(0)]);"
# transfer_from_page <server uuid>: start a transfer A -> B through the Transfer page, wait, print the final status.
transfer_from_page() {
local status
tinker "$A" "$login_php Livewire\\Livewire::test(App\\Livewire\\Server\\Transfer::class, ['server_uuid' => '$1'])->set('targetUrl', 'http://localhost:$B_PORT')->set('targetToken', '$(target_token)')->call('migrateServer');" >/dev/null
for _ in $(seq 1 60); do
sleep 3
status="$(tinker "$A" "echo data_get(App\\Models\\Server::where('uuid', '$1')->first()?->server_metadata, 'migration.status');" | tail -1)"
[[ "$status" != running ]] && break
done
echo "$status"
}
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Prepare A and B for a manual transfer test: reset, then add the fake extra records of extras-e2e.sh to A
# (tokens, DNS, secret manager link, tunnel token, GitHub App + runner config, resource links, canvas positions,
# team variable, maintenance mode, proxy error page, project icon, a git app with a system-wide GitHub App, and an
# app of the same project on the other VM). Prints what to open and what to enter.
#
# Usage: scripts/transfer-test/manual-setup.sh
source "$(dirname "$0")/lib.sh"
"$TT_DIR/reset.sh" || die 'reset.sh failed'
echo '== add the fake extra records to A'
php_file "$A" "$TT_DIR/seed-extras.php"
cat <<EOF
Ready for a manual transfer test.
A (source): $A_URL
B (target): $B_URL
Login on both: test@example.com / password
Servers on A:
$(vm_field ubuntu-non-root uuid) $(vm_field ubuntu-non-root ip) project "Transfer Demo": whoami, nginx-dockerfile, postgres, redis, uptime-kuma
$(vm_field debian-root uuid) $(vm_field debian-root ip) project "Second Server": whoami-2, mariadb, git-shop, and all the fake extra records
Transfer on A: open a server -> Transfer
Target instance URL: http://localhost:$B_PORT
Target API token: $(target_token)
Transfer back on A (archive -> Transfer):
Instance that manages the server now: http://localhost:$B_PORT (same token)
URL of this instance: http://localhost:$A_PORT
Token of A (for a transfer from B to A): $(source_token)
Reset again: scripts/transfer-test/manual-setup.sh
EOF
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# Put the kit back to its start: A manages its localhost VM and the transfer VMs with deployed resources, B is empty.
# WIPES BOTH DEV DATABASES (migrate:fresh --seed) and the test workloads on the VMs. VM disks are kept. About 4 minutes.
#
# Usage: scripts/transfer-test/reset.sh
source "$(dirname "$0")/lib.sh"
require_dev_instance "$A"
require_dev_instance "$B"
mkdir -p "$STATE_DIR"
echo '== 1. remove test workloads from the VMs (the proxy stays)'
if fetch_vm_key; then
for profile in "${TRANSFER_PROFILES[@]}"; do
ssh_vm "$profile" '
for c in $(docker ps -aq); do n=$(docker inspect -f "{{.Name}}" $c); case "$n" in /coolify-proxy*) ;; *) docker rm -f $c >/dev/null;; esac; done
docker volume ls -q | grep -v "^coolify" | xargs -r docker volume rm >/dev/null 2>&1 || true
rm -rf /data/coolify/applications/* /data/coolify/databases/* /data/coolify/services/* /data/coolify/sentinel/* /data/coolify/transfer
echo " $(hostname): $(docker ps --format "{{.Names}}" | tr "\n" " ")"' || echo " $profile: not reachable, skipped"
done
else
echo ' no VM key yet (first run), skipped'
fi
echo '== 2. fresh databases, Redis, SSH folders'
for C in "$A" "$B"; do
docker exec "$C-redis-1" redis-cli FLUSHALL >/dev/null
docker exec "$C" sh -c 'rm -rf /var/www/html/storage/app/ssh/keys/* /var/www/html/storage/app/ssh/mux/*; mkdir -p /var/www/html/storage/app/ssh/keys /var/www/html/storage/app/ssh/mux; chown -R www-data:www-data /var/www/html/storage/app/ssh'
docker exec -u www-data "$C" php artisan migrate:fresh --seed --force >/dev/null || die "migrate:fresh failed in $C"
echo " $C: database seeded"
done
echo '== 3. seed the VM servers of A'
host="${DEV_BIND_ADDRESS:-$(grep -E '^DEV_BIND_ADDRESS=' .env 2>/dev/null | tail -1 | cut -d= -f2-)}"
[[ -z "$host" || "$host" == 0.0.0.0 ]] && host=127.0.0.1
APP_ENV=local APP_CONFIG_CACHE="$ROOT/bootstrap/cache/dev-qemu-$$.php" LOG_CHANNEL=stderr \
APP_KEY="$(env_value "$SOURCE" APP_KEY)" DB_HOST="$host" DB_PORT="$(env_value "$SOURCE" FORWARD_DB_PORT)" \
REDIS_HOST="$host" REDIS_PORT="$(env_value "$SOURCE" FORWARD_REDIS_PORT)" \
DEVELOPMENT_QEMU_INSTANCE="$SOURCE" DEVELOPMENT_QEMU_SLOT="$A_SLOT" \
DEVELOPMENT_QEMU_DOCKER_NETWORK="$A" DEVELOPMENT_QEMU_COOLIFY_CONTAINER="$A" \
"${PRIVILEGED[@]}" sh -c "php artisan dev:qemu $LOCALHOST_PROFILE --as-localhost && php artisan dev:qemu ${TRANSFER_PROFILES[*]}" 2>&1 | grep -E 'Started|rror'
docker exec "$A" chown -R www-data:www-data /var/www/html/storage/app/ssh
fetch_vm_key || die 'The VM key (private key 1) is missing on A after seeding.'
echo '== 4. helper image on the VMs'
helper="$(tinker "$A" 'echo config("constants.coolify.helper_image").":".config("constants.coolify.helper_version");' | tail -1)"
for profile in "${TRANSFER_PROFILES[@]}"; do
# An unreleased helper version cannot be pulled; tag the newest local helper image with that version instead.
ssh_vm "$profile" "docker image inspect $helper >/dev/null 2>&1 || docker pull -q $helper >/dev/null 2>&1 || {
latest=\$(docker images --format '{{.Repository}}:{{.Tag}}' | grep -E '(^|/)coollabsio/coolify-helper:' | sort -V | tail -1)
[ -n \"\$latest\" ] && docker tag \"\$latest\" $helper && echo \" \$(hostname): tagged \$latest as $helper (not published yet)\"
}"
done
echo '== 5. proxies, API tokens, new code in Horizon'
tinker "$A" 'foreach (App\Models\Server::where("id", "!=", 0)->get() as $s) { $s->proxy->set("type", "TRAEFIK"); $s->proxy->set("status", "exited"); $s->save(); App\Actions\Proxy\StartProxy::run($s->fresh(), async: false, force: true); echo " proxy ok: ".$s->name.PHP_EOL; }'
for side in source target; do
C="$A"
[[ "$side" == target ]] && C="$B"
tinker "$C" '$s = App\Models\InstanceSettings::get(); $s->is_api_enabled = true; $s->save(); session(["currentTeam" => App\Models\Team::find(0)]); echo App\Models\User::find(0)->createToken("transfer-test", ["root"])->plainTextToken;' | tail -1 >"$STATE_DIR/$side.token"
chmod 600 "$STATE_DIR/$side.token"
docker exec -u www-data "$C" php artisan horizon:terminate >/dev/null
done
echo '== 6. create and deploy resources on A'
sleep 10
"$TT_DIR/seed.sh" >/dev/null || die 'seed.sh failed'
echo ' waiting for deployments...'
for _ in $(seq 1 30); do
sleep 10
[[ "$(tinker "$A" 'echo App\Models\ApplicationDeploymentQueue::whereIn("status", ["queued", "in_progress"])->count();' | tail -1)" == 0 ]] && break
done
tinker "$A" 'foreach (App\Models\ApplicationDeploymentQueue::get() as $d) echo " deploy ".$d->application_name.": ".$d->status.PHP_EOL;'
for profile in "${TRANSFER_PROFILES[@]}"; do
ssh_vm "$profile" 'echo " $(hostname): $(docker ps --format "{{.Names}}" | grep -v "^coolify-" | wc -l) workload containers"'
done
echo "Done. A: $A_URL B: $B_URL (login test@example.com / password)"
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Undo paths: the owner marker, transfer back from the archive, a transfer over an archive, take back with automatic
# release, and deleting an archive.
# Uses the debian-root VM. Run reset.sh before and after.
source "$(dirname "$0")/lib.sh"
TA="$(source_token)"
TB="$(target_token)"
PROFILE=debian-root
S="$(vm_field "$PROFILE" uuid)"
marker() { ssh_vm "$PROFILE" 'cat /data/coolify/transfer/owner.json 2>/dev/null' | jq -r .claim_id; }
claim_id() { tinker "$1" "echo data_get(App\\Models\\Server::where('uuid','$S')->first()?->server_metadata,'transfer.claim.claim_id');" | tail -1; }
status() { tinker "$1" "echo data_get(App\\Models\\Server::withTrashed()->where('uuid','$S')->first()?->server_metadata,'transfer.status','none');" | tail -1; }
# migrate <from port> <from token> <to port> <to token>: transfer through the API, wait, print the transfer status JSON.
migrate() {
local r
api POST "$1" "/servers/$S/migrate" "$2" "$(jq -nc --arg u "http://localhost:$3" --arg t "$4" '{target_url:$u,target_token:$t}')" >/dev/null
for _ in $(seq 1 40); do sleep 3; r="$(api GET "$1" "/servers/$S/transfer" "$2")"; [[ "$(jq -r .migration.status <<<"$r")" != running ]] && break; done
echo "$r"
}
before="$(stable_workloads "$PROFILE")"
echo '=== 1. A -> B writes the owner marker of B'
r="$(migrate "$A_PORT" "$TA" "$B_PORT" "$TB")"
check "transfer A -> B ($(jq -r .migration.status <<<"$r"))" test "$(jq -r .migration.status <<<"$r")" = succeeded
cb="$(claim_id "$B")"
check "marker = claim of B ($cb)" test "$(marker)" = "$cb"
echo '=== 2. Transfer back from the Transfer page of the archive on A'
tinker "$A" "$login_php Livewire\\Livewire::test(App\\Livewire\\Server\\Transfer::class, ['server_uuid'=>'$S'])->set('managerUrl','http://localhost:$B_PORT')->set('managerToken','$TB')->set('thisInstanceUrl','http://localhost:$A_PORT')->call('transferBack');" >/dev/null
for _ in $(seq 1 40); do sleep 3; [[ "$(status "$A")" == claimed ]] && break; done
ca="$(claim_id "$A")"
check 'A claimed (archive replaced), B transferred' test "$(status "$A")/$(status "$B")" = claimed/transferred
check 'A has one server with this uuid' test "$(tinker "$A" "echo App\\Models\\Server::withTrashed()->where('uuid','$S')->count();" | tail -1)" = 1
check "marker = claim of A ($ca)" test "$(marker)" = "$ca"
check 'workloads unchanged after the transfer back' test "$(workloads "$PROFILE")" = "$before"
echo '=== 3. A -> B again replaces the archive on B'
r="$(migrate "$A_PORT" "$TA" "$B_PORT" "$TB")"
check "transfer A -> B again ($(jq -r .migration.status <<<"$r"))" test "$(jq -r .migration.status <<<"$r")" = succeeded
check 'B claimed, A transferred' test "$(status "$B")/$(status "$A")" = claimed/transferred
check 'workloads unchanged after the second transfer' test "$(workloads "$PROFILE")" = "$before"
echo '=== 4. Take back on A, then B releases the server on its next connection check'
tinker "$A" "$login_php Livewire\\Livewire::test(App\\Livewire\\Server\\Show::class, ['server_uuid'=>'$S'])->call('enableManagement');" >/dev/null
ca="$(claim_id "$A")"
check 'A claimed again' test "$(status "$A")" = claimed
check "marker = new claim of A ($ca)" test "$(marker)" = "$ca"
check 'A started the finish list' test "$(tinker "$A" "echo count((array) data_get(App\\Models\\Server::where('uuid','$S')->first()->server_metadata,'transfer.finish.items')) > 0 ? 1 : 0;" | tail -1)" = 1
check 'B still claimed before its check' test "$(status "$B")" = claimed
tinker "$B" "(new App\\Jobs\\ServerConnectionCheckJob(App\\Models\\Server::where('uuid','$S')->first()))->handle();" >/dev/null
check 'B released the server after its check' test "$(status "$B")" = transferred
check 'B is force disabled' test "$(tinker "$B" "echo (int) App\\Models\\Server::where('uuid','$S')->first()->settings->force_disabled;" | tail -1)" = 1
tinker "$A" "(new App\\Jobs\\ServerConnectionCheckJob(App\\Models\\Server::where('uuid','$S')->first()))->handle();" >/dev/null
check 'A keeps the server after its own check' test "$(status "$A")" = claimed
echo '=== 5. Delete the archive on B (records only)'
tinker "$B" "$login_php Livewire\\Livewire::test(App\\Livewire\\Server\\Delete::class, ['server_uuid'=>'$S'])->call('delete','password');" >/dev/null
for _ in $(seq 1 20); do sleep 3; [[ "$(status "$B")" == none ]] && break; done
check 'archive is gone on B' test "$(status "$B")" = none
check 'workloads unchanged at the end' test "$(workloads "$PROFILE")" = "$before"
echo "=== $FAILS failure(s)"
exit "$FAILS"
+92
View File
@@ -0,0 +1,92 @@
<?php
// Fake records on the source (A) around development-qemu-debian-root, for extras-e2e.sh.
// Fake credentials are enough: the test checks that the records move, not that the providers accept them.
use App\Models\Application;
use App\Models\DnsProviderZone;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\GithubApp;
use App\Models\GithubRunnerConfig;
use App\Models\GithubRunnerExecution;
use App\Models\IntegrationToken;
use App\Models\ManagedDnsRecord;
use App\Models\ResourceLink;
use App\Models\SecretManagerLink;
use App\Models\Server;
use App\Models\SharedEnvironmentVariable;
use App\Models\StandaloneDocker;
use App\Models\StandaloneMariadb;
use App\Services\ProjectIconStorageService;
$quiet = function (string $class, array $attributes) {
$model = new $class;
$model->forceFill(['uuid' => new_public_id(), ...$attributes])->saveQuietly();
return $model;
};
$server = Server::where('uuid', 'development-qemu-debian-root')->firstOrFail();
$teamId = $server->team_id;
$app = Application::where('name', 'whoami-2')->firstOrFail();
$db = StandaloneMariadb::where('name', 'mariadb')->firstOrFail();
$environment = $app->environment;
$project = $environment->project;
// Team credentials
$cloudflare = $quiet(IntegrationToken::class, ['team_id' => $teamId, 'provider' => 'cloudflare', 'name' => 'fake-cloudflare', 'token' => 'fake-cf-token', 'capabilities' => ['dns', 'tunnel']]);
$infisical = $quiet(IntegrationToken::class, ['team_id' => $teamId, 'provider' => 'infisical', 'name' => 'fake-infisical', 'token' => 'fake-inf-token', 'capabilities' => ['secrets'], 'metadata' => ['client_id' => 'fake-client']]);
// DNS zone + owned record used only by whoami-2
$zone = $quiet(DnsProviderZone::class, ['integration_token_id' => $cloudflare->id, 'provider_zone_id' => 'fake-zone-id', 'name' => 'example.test', 'account_id' => 'fake-account', 'account_name' => 'Fake']);
$record = $quiet(ManagedDnsRecord::class, ['team_id' => $teamId, 'integration_token_id' => $cloudflare->id, 'dns_provider_zone_id' => $zone->id, 'provider_record_id' => 'fake-record-id', 'type' => 'A', 'name' => 'whoami2.example.test', 'content' => $server->ip, 'owned' => true]);
$record->addReference($app);
// Secret manager link
$quiet(SecretManagerLink::class, ['resourceable_type' => $app->getMorphClass(), 'resourceable_id' => $app->id, 'integration_token_id' => $infisical->id, 'settings' => ['project_id' => 'fake-project', 'environment' => 'prod', 'secret_path' => '/']]);
// Server: Cloudflare tunnel token + custom proxy error page
$server->settings->forceFill(['cloudflare_tunnel_integration_token_id' => $cloudflare->id, 'cloudflare_tunnel_id' => 'fake-tunnel', 'cloudflare_tunnel_account_id' => 'fake-account'])->saveQuietly();
$server->forceFill(['proxy_error_page' => '<h1>fake error page</h1>'])->saveQuietly();
// GitHub App + runner config (enabled) + one idle runner
$githubApp = $quiet(GithubApp::class, ['name' => 'fake-gh-app', 'api_url' => 'https://api.github.com', 'html_url' => 'https://github.com', 'organization' => 'fake-org', 'app_id' => 123, 'installation_id' => 456, 'client_id' => 'fake-client', 'client_secret' => 'fake-secret', 'webhook_secret' => 'fake-webhook', 'is_system_wide' => false, 'is_public' => false, 'team_id' => $teamId, 'private_key_id' => $server->private_key_id]);
$runnerConfig = $quiet(GithubRunnerConfig::class, ['server_id' => $server->id, 'github_app_id' => $githubApp->id, 'labels' => ['fake-label'], 'is_enabled' => true, 'max_runners' => 2]);
$quiet(GithubRunnerExecution::class, ['github_app_id' => $githubApp->id, 'github_runner_config_id' => $runnerConfig->id, 'server_id' => $server->id, 'status' => 'idle', 'trigger_workflow_job_id' => 1, 'runner_name' => 'coolify-runner-fake', 'runner_id' => 99, 'labels' => ['fake-label']]);
// A resource of the same project on the OTHER server: same environment (link that stays) and a staging environment
$ubuntu = Server::where('uuid', 'development-qemu-ubuntu-non-root')->firstOrFail();
$ubuntuDestination = StandaloneDocker::where('server_id', $ubuntu->id)->firstOrFail();
$onUbuntu = fn (Environment $env, string $name) => Application::create([
'name' => $name, 'environment_id' => $env->id, 'destination_id' => $ubuntuDestination->id, 'destination_type' => $ubuntuDestination->getMorphClass(),
'build_pack' => 'dockerimage', 'docker_registry_image_name' => 'traefik/whoami', 'docker_registry_image_tag' => 'latest', 'ports_exposes' => '80',
'git_repository' => 'coollabsio/coolify', 'git_branch' => 'main',
]);
$otherServerApp = $onUbuntu($environment, 'fake-on-ubuntu');
$staging = Environment::firstOrCreate(['project_id' => $project->id, 'name' => 'fake-staging']);
$onUbuntu($staging, 'fake-staging-on-ubuntu');
// Resource links: one between two resources of this server, one to the other server
$quiet(ResourceLink::class, ['environment_id' => $environment->id, 'source_type' => $app->getMorphClass(), 'source_id' => $app->id, 'target_type' => $db->getMorphClass(), 'target_id' => $db->id, 'label' => 'uses db']);
$quiet(ResourceLink::class, ['environment_id' => $environment->id, 'source_type' => $app->getMorphClass(), 'source_id' => $app->id, 'target_type' => $otherServerApp->getMorphClass(), 'target_id' => $otherServerApp->id, 'label' => 'calls other']);
// Canvas positions, team shared variable used by an app variable, maintenance mode
$environment->forceFill(['canvas_positions' => [$app->uuid => ['x' => 111, 'y' => 222], $db->uuid => ['x' => 333, 'y' => 444]]])->saveQuietly();
SharedEnvironmentVariable::create(['type' => 'team', 'team_id' => $teamId, 'key' => 'FAKE_TEAM_VAR', 'value' => 'team-value']);
EnvironmentVariable::create(['key' => 'USES_TEAM', 'value' => '{{team.FAKE_TEAM_VAR}}', 'resourceable_type' => $app->getMorphClass(), 'resourceable_id' => $app->id]);
$app->forceFill(['is_maintenance_enabled' => true, 'maintenance_page' => '<h1>fake maintenance</h1>'])->saveQuietly();
// A git application that uses a system-wide GitHub App, which the transfer does not move
$systemApp = $quiet(GithubApp::class, ['name' => 'fake-system-wide-app', 'api_url' => 'https://api.github.com', 'html_url' => 'https://github.com', 'team_id' => 0, 'is_system_wide' => true, 'is_public' => false]);
$gitApp = new Application;
$gitApp->forceFill([
'uuid' => new_public_id(), 'name' => 'git-shop', 'build_pack' => 'nixpacks', 'git_repository' => 'acme/shop', 'git_branch' => 'main', 'ports_exposes' => '3000',
'environment_id' => $environment->id, 'destination_id' => $app->destination_id, 'destination_type' => $app->destination_type,
'source_type' => GithubApp::class, 'source_id' => $systemApp->id,
])->saveQuietly();
// Project icon: a 1x1 JPEG
app(ProjectIconStorageService::class)->storeProjectContents($project, base64_decode('/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAP//////////////////////////////////////////////////////////////////////////////////////wgALCAABAAEBAREA/8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQABPxA='));
echo "seeded\n";
@@ -1,9 +1,10 @@
<?php
/**
* Seed a rich transfer-demo inventory on the current Coolify instance.
* Run: php artisan tinker scripts/seed-transfer-demo.php
* Or: ./scripts/dev exec php artisan tinker --execute "require 'scripts/seed-transfer-demo.php';"
* Seed a rich transfer-demo inventory on the current Coolify instance, in the database only: a server
* "transfer-full-demo" on the testing-host container with many resource types. Nothing is deployed.
* For live transfers between two instances, use the other scripts of scripts/transfer-test (see README.md).
* Run: ./scripts/dev exec php artisan tinker --execute "require 'scripts/transfer-test/seed-inventory.php';"
*/
use App\Models\Application;
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# Create real resources on the two transfer VMs of A through the API, then deploy and start them.
# Run on a fresh A only (reset.sh calls it); it does not clean up. Writes the uuids to <state>/resources.json.
source "$(dirname "$0")/lib.sh"
set -e
T="$(source_token)"
S1="$(vm_field ubuntu-non-root uuid)"
S2="$(vm_field debian-root uuid)"
call() { # call METHOD PATH [JSON]: fails on an HTTP error
local out code
out="$(curl -sS -X "$1" "http://127.0.0.1:$A_PORT/api/v1$2" -H "Authorization: Bearer $T" -H 'Content-Type: application/json' -H 'Accept: application/json' ${3:+-d "$3"} -w $'\n%{http_code}')"
code="${out##*$'\n'}"
out="${out%$'\n'*}"
if [[ "$code" -ge 300 ]]; then
echo "FAIL $1 $2 ($code): $out" >&2
return 1
fi
echo "$out"
}
uuid() { jq -r '.uuid'; }
echo '== projects'
P1=$(call POST /projects '{"name":"Transfer Demo","description":"Resources on the ubuntu non-root VM"}' | uuid)
P2=$(call POST /projects '{"name":"Second Server","description":"Resources on the debian root VM"}' | uuid)
call POST "/projects/$P1/environments" '{"name":"staging"}' >/dev/null
echo '== server 1 (ubuntu non-root): Docker image app with variables, volume, tag, scheduled task'
APP1=$(call POST /applications/dockerimage "$(jq -nc --arg p "$P1" --arg s "$S1" '{project_uuid:$p,server_uuid:$s,environment_name:"production",name:"whoami",docker_registry_image_name:"traefik/whoami",docker_registry_image_tag:"latest",ports_exposes:"80",instant_deploy:false}')" | uuid)
call POST "/applications/$APP1/envs" '{"key":"WHOAMI_NAME","value":"transfer-demo"}' >/dev/null
call POST "/applications/$APP1/envs" '{"key":"APP_SECRET","value":"very-secret-value"}' >/dev/null
call POST "/applications/$APP1/storages" '{"type":"persistent","name":"whoami-data","mount_path":"/data"}' >/dev/null || true
call POST "/applications/$APP1/tags" '{"tag_name":"transfer-demo"}' >/dev/null || true
call POST "/applications/$APP1/scheduled-tasks" '{"name":"hello","command":"echo hello","frequency":"*/30 * * * *"}' >/dev/null || true
echo '== server 1: Dockerfile app (staging)'
DF=$(printf 'FROM nginx:alpine\nRUN echo "built by Coolify A" > /usr/share/nginx/html/index.html\nEXPOSE 80\n' | base64 -w0)
APP2=$(call POST /applications/dockerfile "$(jq -nc --arg p "$P1" --arg s "$S1" --arg d "$DF" '{project_uuid:$p,server_uuid:$s,environment_name:"staging",name:"nginx-dockerfile",dockerfile:$d,ports_exposes:"80",instant_deploy:false}')" | uuid)
echo '== server 1: PostgreSQL with a backup, Redis'
PG=$(call POST /databases/postgresql "$(jq -nc --arg p "$P1" --arg s "$S1" '{project_uuid:$p,server_uuid:$s,environment_name:"production",name:"postgres",postgres_user:"demo",postgres_password:"pg-secret-password",postgres_db:"demo",instant_deploy:false}')" | uuid)
call POST "/databases/$PG/backups" '{"frequency":"0 3 * * *","enabled":true,"save_s3":false,"databases_to_backup":"demo"}' >/dev/null || true
RD=$(call POST /databases/redis "$(jq -nc --arg p "$P1" --arg s "$S1" '{project_uuid:$p,server_uuid:$s,environment_name:"production",name:"redis",instant_deploy:false}')" | uuid)
echo '== server 1: one-click service (uptime-kuma)'
SV=$(call POST /services "$(jq -nc --arg p "$P1" --arg s "$S1" '{type:"uptime-kuma",project_uuid:$p,server_uuid:$s,environment_name:"production",name:"uptime-kuma",instant_deploy:false}')" | uuid)
echo '== server 2 (debian root): whoami-2, MariaDB'
APP3=$(call POST /applications/dockerimage "$(jq -nc --arg p "$P2" --arg s "$S2" '{project_uuid:$p,server_uuid:$s,environment_name:"production",name:"whoami-2",docker_registry_image_name:"traefik/whoami",docker_registry_image_tag:"latest",ports_exposes:"80",instant_deploy:false}')" | uuid)
MD=$(call POST /databases/mariadb "$(jq -nc --arg p "$P2" --arg s "$S2" '{project_uuid:$p,server_uuid:$s,environment_name:"production",name:"mariadb",instant_deploy:false}')" | uuid)
echo '== deploy and start everything'
for u in $APP1 $APP2 $APP3; do call POST "/deploy?uuid=$u" >/dev/null && echo "deploy queued $u"; done
for u in $PG $RD $MD; do call POST "/databases/$u/start" >/dev/null && echo "start queued $u"; done
call POST "/services/$SV/start" >/dev/null && echo "start queued $SV"
jq -n --arg p1 "$P1" --arg p2 "$P2" --arg app1 "$APP1" --arg app2 "$APP2" --arg app3 "$APP3" --arg pg "$PG" --arg rd "$RD" --arg md "$MD" --arg sv "$SV" \
'{projects:[$p1,$p2],apps:[$app1,$app2,$app3],databases:[$pg,$rd,$md],service:$sv}' | tee "$STATE_DIR/resources.json"
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Prepare the transfer test kit: start the target instance (B) next to the source (A), and let them reach
# each other and A's VMs. Safe to run again, for example after a host reboot. Linux with KVM only.
#
# Usage: scripts/transfer-test/setup.sh (then scripts/transfer-test/reset.sh)
source "$(dirname "$0")/lib.sh"
[[ -c /dev/kvm ]] || die 'The kit needs KVM VMs (Linux). See scripts/transfer-test/README.md.'
running "$A" || die "Start the source instance first: ./scripts/dev start"
echo "== 1. target instance $TARGET (side by side, testing-host as its localhost)"
# On a new instance, its server seeding can run before the migrations. That does not matter: reset.sh seeds B again.
COOLIFY_DEV_INSTANCE="$TARGET" COOLIFY_DEV_SIDE_BY_SIDE=1 COOLIFY_DEV_SERVER_BACKEND=testing-host ./scripts/dev start ||
echo ' (the seeding of the new instance failed; reset.sh seeds both databases again)'
running "$B" || die "The target instance $B does not run."
target_values
echo "== 2. firewall: B may reach the VM network of A (10.221.$A_SLOT.0/24)"
subnet="$(docker network inspect "$B" --format '{{(index .IPAM.Config 0).Subnet}}')"
rule=(LIBVIRT_FWI -s "$subnet" -d "10.221.$A_SLOT.0/24" -o "cdevbr$A_SLOT" -j ACCEPT)
if "${PRIVILEGED[@]}" iptables -C "${rule[@]}" 2>/dev/null; then
echo " rule exists"
else
"${PRIVILEGED[@]}" iptables -I "${rule[0]}" 1 "${rule[@]:1}" && echo " rule added for $subnet"
fi
echo "== 3. A and B reach each other's app port"
bind="$(grep -E '^DEV_BIND_ADDRESS=' .env 2>/dev/null | tail -1 | cut -d= -f2-)"
if [[ "${bind:-0.0.0.0}" == 127.0.0.1 ]]; then
# The ports listen on 127.0.0.1 only; containers reach the host through the docker0 gateway.
gateway="$(docker network inspect bridge --format '{{(index .IPAM.Config 0).Gateway}}')"
for port in "$A_PORT" "$B_PORT"; do
name="coolify-transfer-test-forward-$port"
if running "$name"; then
echo " $name runs"
else
docker rm -f "$name" >/dev/null 2>&1 || true
docker run -d --name "$name" --network host --restart unless-stopped alpine/socat \
"tcp-listen:$port,bind=$gateway,fork,reuseaddr" "tcp:127.0.0.1:$port" >/dev/null && echo " $name started ($gateway:$port -> 127.0.0.1:$port)"
fi
done
else
echo " not needed: the ports listen on ${bind:-0.0.0.0}"
fi
echo "Done. A: $A_URL B: $B_URL"
echo "Next: scripts/transfer-test/reset.sh"
@@ -0,0 +1,28 @@
<?php
// Checks on the source (A) after the debian-root transfer of extras-e2e.sh. Prints PASS/FAIL lines.
use App\Models\GithubRunnerConfig;
use App\Models\GithubRunnerExecution;
use App\Models\ManagedDnsRecord;
use App\Models\Server;
$check = function (string $name, bool $ok, string $detail = '') {
echo ($ok ? 'PASS ' : 'FAIL ').$name.($detail !== '' ? " ({$detail})" : '')."\n";
};
$server = Server::where('uuid', 'development-qemu-debian-root')->first();
$migration = (array) data_get($server?->server_metadata, 'migration');
$messages = collect($migration['log'] ?? [])->pluck('message');
$check('source is transferred away', $server?->isTransferredAway() === true);
$check('transfer succeeded', ($migration['status'] ?? null) === 'succeeded', (string) ($migration['error'] ?? ''));
$check('precheck NOTE about resources on the other server', $messages->contains(fn ($m) => str_starts_with($m, "NOTE: project 'Second Server' also has 2 resources on other servers")));
$check('runner config disabled on the source', GithubRunnerConfig::where('server_id', $server?->id)->value('is_enabled') === false);
$check('runner execution cancelled on the source', GithubRunnerExecution::where('runner_name', 'coolify-runner-fake')->value('status')?->value === 'cancelled');
$record = ManagedDnsRecord::where('name', 'whoami2.example.test')->first();
$check('source keeps the DNS record for a take back, not owned', $record !== null && $record->owned === false);
$check('precheck NOTE about the system-wide GitHub App', $messages->contains(fn ($m) => str_starts_with($m, "NOTE: the system-wide GitHub App 'fake-system-wide-app'")));
$checklist = implode("\n", (array) ($migration['checklist'] ?? []));
$check('checklist: GitHub App URLs', str_contains($checklist, 'GitHub App fake-gh-app'));
$check('checklist: enable GitHub runners', str_contains($checklist, 'enable GitHub runners'));
echo "--- warnings\n".implode("\n", (array) ($migration['warnings'] ?? []))."\n--- checklist\n{$checklist}\n";
@@ -0,0 +1,69 @@
<?php
// Checks on the target (B) after the debian-root transfer of extras-e2e.sh. Prints PASS/FAIL lines.
use App\Models\Application;
use App\Models\Environment;
use App\Models\GithubApp;
use App\Models\GithubRunnerConfig;
use App\Models\IntegrationToken;
use App\Models\ManagedDnsRecord;
use App\Models\ResourceLink;
use App\Models\SecretManagerLink;
use App\Models\Server;
use App\Models\SharedEnvironmentVariable;
use App\Models\StandaloneMariadb;
use App\Services\ProjectIconStorageService;
$check = function (string $name, bool $ok, string $detail = '') {
echo ($ok ? 'PASS ' : 'FAIL ').$name.($detail !== '' ? " ({$detail})" : '')."\n";
};
$server = Server::where('uuid', 'development-qemu-debian-root')->first();
$check('server is on B and claimed', data_get($server?->server_metadata, 'transfer.status') === 'claimed');
$check('custom proxy error page', $server?->proxy_error_page === '<h1>fake error page</h1>');
$cloudflare = IntegrationToken::where('name', 'fake-cloudflare')->first();
$infisical = IntegrationToken::where('name', 'fake-infisical')->first();
$check('Cloudflare token moved with its secret', $cloudflare?->token === 'fake-cf-token');
$check('Infisical token moved with its secret', $infisical?->token === 'fake-inf-token');
$check('Cloudflare tunnel uses the moved token', $server?->settings?->cloudflare_tunnel_integration_token_id === $cloudflare?->id && $server?->settings?->cloudflare_tunnel_id === 'fake-tunnel');
$app = Application::where('name', 'whoami-2')->first();
$link = SecretManagerLink::where('resourceable_type', $app?->getMorphClass())->where('resourceable_id', $app?->id)->first();
$check('secret manager link moved to the moved token', $link?->integration_token_id === $infisical?->id && data_get($link?->settings, 'project_id') === 'fake-project');
$record = ManagedDnsRecord::where('name', 'whoami2.example.test')->first();
$check('DNS record + zone moved', $record !== null && $record->zone?->provider_zone_id === 'fake-zone-id' && $record->integration_token_id === $cloudflare?->id);
$check('DNS record references the moved app', $record?->references()->where('resource_type', $app?->getMorphClass())->where('resource_id', $app?->id)->exists() === true);
// Same app.id on both instances: the owner comment does not change, so the record stays owned.
// Different app.id: the target must change the comment at Cloudflare, which fails with a fake token.
$sourceAppId = trim((string) @file_get_contents('/tmp/source-app-id'));
$sameInstanceId = $sourceAppId === (string) config('app.id');
$check('DNS ownership '.($sameInstanceId ? 'kept (same instance ID, no adoption needed)' : 'dropped (fake token cannot adopt)'), $record?->owned === $sameInstanceId);
$githubApp = GithubApp::where('name', 'fake-gh-app')->first();
$runner = GithubRunnerConfig::where('server_id', $server?->id)->first();
$check('GitHub App moved (only the runner config uses it)', $githubApp !== null && $githubApp->organization === 'fake-org');
$check('runner config moved, DISABLED, same App and labels', $runner !== null && $runner->is_enabled === false && $runner->github_app_id === $githubApp?->id && in_array('fake-label', (array) $runner->labels, true));
$db = StandaloneMariadb::where('name', 'mariadb')->first();
$check('resource link between the two moved resources', ResourceLink::where('source_id', $app?->id)->where('target_type', $db?->getMorphClass())->where('target_id', $db?->id)->where('label', 'uses db')->exists());
$check('resource link to the other server is not moved', ! ResourceLink::where('label', 'calls other')->exists());
$environment = $app?->environment;
$check('canvas positions moved', data_get($environment?->canvas_positions, ($app?->uuid).'.x') === 111);
$check('environment without resources on this server is not moved', ! Environment::where('name', 'fake-staging')->exists());
$check('apps on the other server are not moved', ! Application::whereIn('name', ['fake-on-ubuntu', 'fake-staging-on-ubuntu'])->exists());
$check('team shared variable created', SharedEnvironmentVariable::where('type', 'team')->where('key', 'FAKE_TEAM_VAR')->value('value') === 'team-value');
$check('app variable that uses it moved', $app?->environment_variables()->where('key', 'USES_TEAM')->value('value') === '{{team.FAKE_TEAM_VAR}}');
$check('maintenance mode moved', $app?->is_maintenance_enabled === true && $app?->maintenance_page === '<h1>fake maintenance</h1>');
$gitShop = Application::where('name', 'git-shop')->first();
$check('git-shop moved without its system-wide source', $gitShop !== null && $gitShop->source_id === null);
$check('finish list has a git source step for git-shop', collect(data_get($server?->server_metadata, 'transfer.finish.items'))->contains('key', 'git_source:'.$gitShop?->uuid));
$project = $environment?->project;
$icon = $project ? app(ProjectIconStorageService::class)->projectContents($project) : null;
$check('project icon moved', $icon !== null && str_starts_with($icon, "\xFF\xD8\xFF"));
+215 -110
View File
@@ -1,8 +1,13 @@
<?php
use App\Enums\ApplicationDeploymentStatus;
use App\Jobs\MigrateServerToInstanceJob;
use App\Jobs\ValidateAndInstallServerJob;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\GithubApp;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
@@ -10,18 +15,21 @@ use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use App\Services\ServerTransfer\ServerTransferBundle;
use App\Services\ServerTransfer\ServerTransferClaimer;
use App\Services\ServerTransfer\ServerTransferExporter;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Once;
uses(RefreshDatabase::class);
beforeEach(function () {
config(['app.env' => 'local']);
// A claim writes the owner marker on the host over SSH.
Process::fake();
// An import validates the claimed server over SSH; the sync test queue would run it.
Queue::fake([ValidateAndInstallServerJob::class]);
Storage::fake('ssh-keys');
InstanceSettings::forceCreate([
@@ -78,19 +86,19 @@ beforeEach(function () {
});
});
test('server transfer API is unavailable outside development mode', function (string $method, string $uri) {
test('server transfer API is available in production', function () {
config(['app.env' => 'production']);
$this->withHeaders(transferHeaders($this->sensitiveToken))
->json($method, str_replace('{uuid}', $this->server->uuid, $uri))
->assertNotFound();
})->with([
['POST', '/api/v1/servers/import'],
['GET', '/api/v1/servers/{uuid}/export'],
['POST', '/api/v1/servers/{uuid}/export/mailbox'],
['POST', '/api/v1/servers/{uuid}/transfer/complete'],
['POST', '/api/v1/servers/{uuid}/migrate'],
]);
->getJson("/api/v1/servers/{$this->server->uuid}/transfer")
->assertOk()
->assertJsonPath('server_uuid', $this->server->uuid);
});
function transferExportForApiTest(Server $server): array
{
return app(ServerTransferExporter::class)->export($server);
}
function transferHeaders(string $token): array
{
@@ -101,53 +109,15 @@ function transferHeaders(string $token): array
];
}
describe('GET /api/v1/servers/{uuid}/export', function () {
test('exports bundle with sensitive token', function () {
$response = $this->withHeaders(transferHeaders($this->sensitiveToken))
->getJson("/api/v1/servers/{$this->server->uuid}/export");
$response->assertOk()
->assertJsonPath('schema_version', ServerTransferBundle::SCHEMA_VERSION)
->assertJsonPath('server.uuid', $this->server->uuid)
->assertJsonPath('server.ip', '10.66.0.20');
$envs = collect($response->json('projects.0.environments.0.applications.0.environment_variables'));
$tokenEnv = $envs->firstWhere('key', 'API_TOKEN');
expect($response->json('private_key.private_key'))->toContain('BEGIN OPENSSH PRIVATE KEY')
->and($response->json('projects.0.environments.0.applications.0.uuid'))->toBe($this->application->uuid)
->and($tokenEnv)->not->toBeNull()
->and($tokenEnv['value'])->toBe('token-value-123');
});
test('rejects token without read:sensitive', function () {
$this->withHeaders(transferHeaders($this->readToken))
->getJson("/api/v1/servers/{$this->server->uuid}/export")
->assertForbidden();
});
test('returns 404 for unknown or other team server uuid', function () {
$this->withHeaders(transferHeaders($this->sensitiveToken))
->getJson('/api/v1/servers/not-a-real-server-uuid/export')
->assertNotFound();
});
test('can return passphrase-encrypted envelope', function () {
$response = $this->withHeaders(transferHeaders($this->sensitiveToken))
->getJson("/api/v1/servers/{$this->server->uuid}/export?encrypt=1&passphrase=secret-pass");
$response->assertOk()
->assertJsonPath('encrypted', true);
expect($response->json('payload'))->toBeString()->not->toBeEmpty();
});
});
test('the manual export endpoints no longer exist', function (string $path) {
$this->withHeaders(transferHeaders($this->sensitiveToken))
->postJson("/api/v1/servers/{$this->server->uuid}{$path}", ['passphrase' => 'correct horse battery'])
->assertNotFound();
})->with(['/export', '/export/mailbox']);
describe('POST /api/v1/servers/import', function () {
test('dry run does not create resources', function () {
$export = $this->withHeaders(transferHeaders($this->sensitiveToken))
->getJson("/api/v1/servers/{$this->server->uuid}/export")
->json();
$export = transferExportForApiTest($this->server);
$before = Server::count();
@@ -164,9 +134,7 @@ describe('POST /api/v1/servers/import', function () {
});
test('imports after source handoff and preserves application uuid', function () {
$export = $this->withHeaders(transferHeaders($this->sensitiveToken))
->getJson("/api/v1/servers/{$this->server->uuid}/export")
->json();
$export = transferExportForApiTest($this->server);
$appUuid = $this->application->uuid;
$serverUuid = $this->server->uuid;
@@ -195,51 +163,63 @@ describe('POST /api/v1/servers/import', function () {
->toBe('claimed');
});
test('imports encrypted bundle with passphrase', function () {
$export = $this->withHeaders(transferHeaders($this->sensitiveToken))
->getJson("/api/v1/servers/{$this->server->uuid}/export")
->json();
$encrypted = ServerTransferBundle::encryptWithPassphrase($export, 'mailbox-pass');
$this->application->forceDelete();
$this->server->forceDelete();
$this->privateKey->delete();
$this->withHeaders(transferHeaders($this->sensitiveToken))
->postJson('/api/v1/servers/import', [
'bundle' => $encrypted,
'passphrase' => 'mailbox-pass',
])
->assertCreated()
->assertJsonPath('server_uuid', $export['server']['uuid']);
});
test('rejects encrypted bundle without passphrase', function () {
$this->withHeaders(transferHeaders($this->sensitiveToken))
->postJson('/api/v1/servers/import', [
'bundle' => ['encrypted' => true, 'payload' => 'abc', 'schema_version' => 1],
])
->assertStatus(422);
});
test('rejects import when ip still exists', function () {
$export = $this->withHeaders(transferHeaders($this->sensitiveToken))
->getJson("/api/v1/servers/{$this->server->uuid}/export")
->json();
$export = transferExportForApiTest($this->server);
$this->withHeaders(transferHeaders($this->sensitiveToken))
->postJson('/api/v1/servers/import', ['bundle' => $export])
->assertStatus(422)
->assertJsonPath('message', fn ($m) => str_contains($m, 'already exists') || str_contains(json_encode($m), 'already exists') || true);
->assertJsonValidationErrors(['server.uuid']);
});
test('does not reveal the server of another team when the ip exists', function () {
$export = transferExportForApiTest($this->server);
$this->application->forceDelete();
$this->server->forceDelete();
$otherServer = Server::factory()->create([
'team_id' => Team::factory()->create()->id,
'private_key_id' => $this->privateKey->id,
'ip' => '10.66.0.20',
]);
$response = $this->withHeaders(transferHeaders($this->sensitiveToken))
->postJson('/api/v1/servers/import', ['bundle' => $export])
->assertStatus(422);
expect($response->getContent())->toContain('already exists')
->not->toContain($otherServer->uuid);
});
test('rejects a bundle from a newer Coolify version', function () {
$export = transferExportForApiTest($this->server);
$export['coolify_version'] = '99.0.0';
$this->application->forceDelete();
$this->server->forceDelete();
$this->withHeaders(transferHeaders($this->sensitiveToken))
->postJson('/api/v1/servers/import', ['bundle' => $export])
->assertStatus(422)
->assertJsonPath('errors.bundle.0', fn ($message) => str_contains($message, 'Upgrade this instance'));
expect(Server::where('uuid', $export['server']['uuid'])->exists())->toBeFalse();
});
test('adopt mode requires the original uuids', function () {
$export = transferExportForApiTest($this->server);
$this->application->forceDelete();
$this->server->forceDelete();
$this->withHeaders(transferHeaders($this->sensitiveToken))
->postJson('/api/v1/servers/import', ['bundle' => $export, 'preserve_uuids' => false, 'adopt_mode' => true])
->assertStatus(422)
->assertJsonValidationErrors(['preserve_uuids']);
});
test('rejects import when the destination team is at its cloud server limit', function () {
config()->set('constants.coolify.self_hosted', false);
$this->team->update(['custom_server_limit' => 1]);
$export = $this->withHeaders(transferHeaders($this->sensitiveToken))
->getJson("/api/v1/servers/{$this->server->uuid}/export")
->json();
$export = transferExportForApiTest($this->server);
$this->server->forceDelete();
Server::factory()->create([
@@ -259,9 +239,7 @@ describe('POST /api/v1/servers/import', function () {
describe('POST /api/v1/servers/import claim', function () {
test('import takes management of the server and rebinds Sentinel', function () {
$export = $this->withHeaders(transferHeaders($this->sensitiveToken))
->getJson("/api/v1/servers/{$this->server->uuid}/export")
->json();
$export = transferExportForApiTest($this->server);
$this->application->forceDelete();
$this->server->forceDelete();
@@ -315,10 +293,7 @@ describe('POST /api/v1/servers/{uuid}/transfer/complete', function () {
describe('full transfer flow A to B on same process', function () {
test('export complete import sequence', function () {
$export = $this->withHeaders(transferHeaders($this->sensitiveToken))
->getJson("/api/v1/servers/{$this->server->uuid}/export")
->assertOk()
->json();
$export = transferExportForApiTest($this->server);
$this->withHeaders(transferHeaders($this->sensitiveToken))
->postJson("/api/v1/servers/{$this->server->uuid}/transfer/complete", [
@@ -358,15 +333,145 @@ describe('full transfer flow A to B on same process', function () {
});
});
describe('ServerTransferClaimer unit-ish via container', function () {
test('writeMailbox returns path even when remote fails in tests', function () {
$claimer = app(ServerTransferClaimer::class);
$exporter = app(ServerTransferExporter::class);
$bundle = $exporter->export($this->server);
describe('POST /api/v1/servers/{uuid}/migrate', function () {
test('starts the transfer in a queued job', function () {
Queue::fake();
Http::fake(['*/api/v1/servers/import/check*' => Http::response(['message' => 'ok', 'checks' => []])]);
$result = $claimer->writeMailbox($this->server, $bundle, 'pass');
$this->withHeaders(transferHeaders($this->sensitiveToken))
->postJson("/api/v1/servers/{$this->server->uuid}/migrate", [
'target_url' => 'https://8.8.8.8',
'target_token' => 'target-token',
])
->assertStatus(202)
->assertJsonPath('migration.status', 'running');
expect($result['path'])->toContain('/data/coolify/exports/server-transfer-')
->and($result)->toHaveKey('written');
Queue::assertPushed(MigrateServerToInstanceJob::class, fn ($job) => $job->server->is($this->server) && $job->targetToken === 'target-token');
$this->withHeaders(transferHeaders($this->sensitiveToken))
->getJson("/api/v1/servers/{$this->server->uuid}/transfer")
->assertOk()
->assertJsonPath('migration.status', 'running')
->assertJsonPath('migration.target_url', 'https://8.8.8.8');
});
test('rejects a target without https', function () {
Queue::fake();
$this->withHeaders(transferHeaders($this->sensitiveToken))
->postJson("/api/v1/servers/{$this->server->uuid}/migrate", [
'target_url' => 'http://8.8.8.8',
'target_token' => 'target-token',
])
->assertStatus(422)
->assertJsonPath('message', fn ($message) => str_contains($message, 'https'));
Queue::assertNothingPushed();
});
test('rejects a transfer while a deployment runs on the server', function () {
Queue::fake();
ApplicationDeploymentQueue::create([
'application_id' => (string) $this->application->id,
'deployment_uuid' => new_public_id(),
'server_id' => $this->server->id,
'status' => ApplicationDeploymentStatus::IN_PROGRESS->value,
]);
$this->withHeaders(transferHeaders($this->sensitiveToken))
->postJson("/api/v1/servers/{$this->server->uuid}/migrate", [
'target_url' => 'https://8.8.8.8',
'target_token' => 'target-token',
])
->assertStatus(422)
->assertJsonPath('message', fn ($message) => str_contains($message, 'deployment'));
Queue::assertNothingPushed();
});
});
describe('GET /api/v1/servers/import/check', function () {
test('accepts a server that this instance can import and reach', function () {
$listener = stream_socket_server('tcp://127.0.0.1:0');
$port = (int) substr(strrchr(stream_socket_get_name($listener, false), ':'), 1);
$this->withHeaders(transferHeaders($this->writeToken))
->getJson("/api/v1/servers/import/check?server_uuid=new-server-uuid&ip=127.0.0.1&port={$port}")
->assertOk()
->assertJsonPath('checks', fn ($checks) => collect($checks)->every(fn ($check) => $check['ok'] === true)
&& collect($checks)->pluck('label')->contains("the target can reach the SSH port of the server (127.0.0.1:{$port})"));
fclose($listener);
});
test('rejects a server whose SSH port this instance cannot reach', function () {
$listener = stream_socket_server('tcp://127.0.0.1:0');
$port = (int) substr(strrchr(stream_socket_get_name($listener, false), ':'), 1);
fclose($listener);
$this->withHeaders(transferHeaders($this->writeToken))
->getJson("/api/v1/servers/import/check?server_uuid=new-server-uuid&ip=127.0.0.1&port={$port}")
->assertStatus(422)
->assertJsonPath('message', fn ($message) => str_contains($message, 'cannot reach the server at 127.0.0.1'));
});
test('rejects a target that is not an IP address or hostname before it opens a socket', function (string $ip) {
$this->withHeaders(transferHeaders($this->writeToken))
->getJson('/api/v1/servers/import/check?server_uuid=new-server-uuid&ip='.urlencode($ip))
->assertStatus(422)
->assertJsonValidationErrors('ip')
->assertJsonMissingPath('checks');
})->with(['tcp://127.0.0.1', 'unix:///var/run/docker.sock', 'ssl://example.com']);
test('rejects another transfer bundle version', function () {
$this->withHeaders(transferHeaders($this->writeToken))
->getJson('/api/v1/servers/import/check?server_uuid=new-server-uuid&ip=127.0.0.1&schema_version=999')
->assertStatus(422)
->assertJsonPath('message', fn ($message) => str_contains($message, 'Upgrade both instances'));
});
test('lists every check with its result when a server already exists on this instance', function () {
$listener = stream_socket_server('tcp://127.0.0.1:0');
$port = (int) substr(strrchr(stream_socket_get_name($listener, false), ':'), 1);
$this->server->forceFill(['ip' => '127.0.0.1'])->save();
$checks = $this->withHeaders(transferHeaders($this->writeToken))
->getJson("/api/v1/servers/import/check?server_uuid=new-server-uuid&ip=127.0.0.1&port={$port}")
->assertStatus(422)
->assertJsonPath('message', fn ($message) => str_contains($message, 'already exists on this instance'))
->json('checks');
expect(collect($checks)->pluck('ok', 'label')->all())->toMatchArray([
'the transfer bundle version is supported' => true,
'no server with the same UUID exists' => true,
'no server with the same IP exists' => false,
"the target can reach the SSH port of the server (127.0.0.1:{$port})" => true,
]);
fclose($listener);
});
test('rejects a token without the write permission', function () {
$this->withHeaders(transferHeaders($this->readToken))
->getJson('/api/v1/servers/import/check?server_uuid=new-server-uuid&ip=10.66.0.99')
->assertForbidden();
});
test('rejects an invalid token', function () {
$this->withHeaders(transferHeaders('1|not-a-real-token'))
->getJson('/api/v1/servers/import/check?server_uuid=new-server-uuid&ip=10.66.0.99')
->assertUnauthorized();
});
});
test('the import check lists the system-wide git sources that this instance does not have', function () {
$present = GithubApp::create(['name' => 'present', 'api_url' => 'https://api.github.com', 'html_url' => 'https://github.com', 'team_id' => 0, 'is_system_wide' => true]);
$listener = stream_socket_server('tcp://127.0.0.1:0');
$port = (int) substr(strrchr(stream_socket_get_name($listener, false), ':'), 1);
$this->withHeaders(transferHeaders($this->writeToken))
->getJson("/api/v1/servers/import/check?server_uuid=new-server-uuid&ip=127.0.0.1&port={$port}&system_github_apps[]={$present->uuid}&system_github_apps[]=missing-uuid&system_gitlab_apps[]=other-missing")
->assertOk()
->assertJsonPath('missing_system_sources.github', ['missing-uuid'])
->assertJsonPath('missing_system_sources.gitlab', ['other-missing']);
fclose($listener);
});
@@ -110,7 +110,7 @@ test('admin sees deploy controls for application', function () {
session(['currentTeam' => $this->team]);
Livewire::test(ApplicationHeading::class, ['application' => $this->application])
->assertSee('Deploy')
->assertSee('Redeploy')
->assertSee('Restart')
->assertSee('Stop');
});
+49
View File
@@ -705,6 +705,42 @@ describe('provisioning', function () {
Queue::assertPushed(CleanupGithubRunnerJob::class, fn ($job) => $job->executionId === $execution->id);
});
it('stops provisioning without touching GitHub or the host when the server is transferred meanwhile', function (bool $cancelled) {
Queue::fake();
Process::fake(['*' => Process::result(output: '29.8.0')]);
$server = runnerTestServer($this->team);
runnerTestConfig($server, $this->githubApp);
$execution = runnerTestExecution($this->githubApp);
Http::fake([
'https://api.github.com/zen' => Http::response('ok', 200, ['Date' => now()->toRfc7231String()]),
'https://api.github.com/app/installations/*' => Http::response(['token' => 'installation-token'], 201),
'https://api.github.com/orgs/acme/actions/runners/42' => Http::response(null, 204),
'https://api.github.com/orgs/acme/actions/runners/generate-jitconfig' => function () use ($server, $execution, $cancelled) {
// The transfer commits while GitHub registers the runner. Without the cancel, it commits right
// after the job read the execution, so only the server shows the transfer.
$server->settings()->update(['force_disabled' => true]);
$server->forceFill(['server_metadata' => ['transfer' => ['status' => 'transferred']]])->saveQuietly();
if ($cancelled) {
$execution->finish(GithubRunnerStatus::Cancelled);
}
return Http::response(['runner' => ['id' => 42], 'encoded_jit_config' => 'SECRET-JIT-CONFIG'], 201);
},
]);
(new ProvisionGithubRunnerJob($execution->id))->handle();
expect($execution->fresh()->status)->toBe(GithubRunnerStatus::Cancelled)
->and($execution->fresh()->ready_at)->toBeNull();
Process::assertDidntRun(fn (PendingProcess $process) => str_contains($process->command, '/home/runner/run.sh'));
Queue::assertPushed(CleanupGithubRunnerJob::class, fn ($job) => $job->executionId === $execution->id);
(new CleanupGithubRunnerJob($execution->id))->handle();
Process::assertDidntRun(fn (PendingProcess $process) => str_contains($process->command, 'docker rm'));
Http::assertNotSent(fn ($request) => $request->method() === 'DELETE');
})->with(['the transfer cancelled the execution' => true, 'the transfer commits after the last check' => false]);
it('cleans up cancellation during remote provisioning without marking the runner ready', function (bool $duringStart) {
Queue::fake();
runnerTestConfig(runnerTestServer($this->team), $this->githubApp);
@@ -891,6 +927,19 @@ describe('server deletion', function () {
expect(GithubApp::find($this->githubApp->id))->toBeNull();
});
it('keeps the GitHub runners of a transferred server registered when it deletes the archive', function () {
Queue::fake();
$running = runnerTestExecution($this->githubApp, ['status' => GithubRunnerStatus::Running, 'server_id' => $this->server->id, 'github_runner_config_id' => $this->config->id, 'runner_id' => 42]);
$this->server->update(['server_metadata' => ['transfer' => ['status' => 'transferred']]]);
$this->server->delete();
DeleteServer::run($this->server->id);
expect(Server::withTrashed()->find($this->server->id))->toBeNull()
->and(GithubRunnerExecution::whereKey($running->id)->exists())->toBeFalse();
Queue::assertNotPushed(DeregisterGithubRunnerJob::class);
});
it('removes the runner from GitHub and ignores GitHub errors', function (int $status) {
Http::fake([
'https://api.github.com/zen' => Http::response('ok', 200, ['Date' => now()->toRfc7231String()]),
+246 -93
View File
@@ -1,26 +1,47 @@
<?php
use App\Actions\Server\DeleteServer;
use App\Jobs\DeleteResourceJob;
use App\Jobs\MigrateServerToInstanceJob;
use App\Jobs\ReleaseManagedDnsRecordsJob;
use App\Jobs\ValidateAndInstallServerJob;
use App\Livewire\Server\Delete;
use App\Livewire\Server\Transfer;
use App\Livewire\Server\TransferImport;
use App\Models\Application;
use App\Models\DnsProviderZone;
use App\Models\InstanceSettings;
use App\Models\IntegrationToken;
use App\Models\ManagedDnsRecord;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use App\Services\ServerTransfer\ServerTransferClaimer;
use App\Services\ServerTransfer\ServerTransferExporter;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
config(['app.env' => 'local']);
// A claim writes the owner marker on the host over SSH.
Process::fake();
Storage::fake('ssh-keys');
// An import validates the claimed server over SSH; the sync test queue would run it.
Queue::fake([ValidateAndInstallServerJob::class]);
config(['app.env' => 'production']);
$this->withoutVite();
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true, 'fqdn' => 'https://coolify-a.test']);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
// The default password of UserFactory.
$this->userPassword = 'password'; // ggignore
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
@@ -34,47 +55,53 @@ beforeEach(function () {
]);
});
test('server transfer pages are unavailable outside development mode', function (string $uri) {
config(['app.env' => 'production']);
$this->get($uri)->assertNotFound();
})->with([
'/servers/import',
fn () => '/server/'.$this->server->uuid.'/transfer',
]);
test('server transfer links are hidden outside development mode', function () {
config(['app.env' => 'production']);
$this->get('/servers')
->assertOk()
->assertDontSee('/servers/import');
$this->get('/server/'.$this->server->uuid)
->assertOk()
->assertDontSee('Transfer', escape: false);
test('server transfer page is available in production', function () {
$this->get('/server/'.$this->server->uuid.'/transfer')->assertOk();
});
test('transfer state is hidden outside development mode', function (bool $managementDisabled) {
app(ServerTransferClaimer::class)->markTransferred($this->server, managementDisabled: $managementDisabled);
config(['app.env' => 'production']);
$this->get('/servers')
test('server transfer link is shown to admins in production', function () {
$this->get('/server/'.$this->server->uuid)
->assertOk()
->assertDontSee('Transferable')
->assertDontSee('Transferred away');
->assertSee('/server/'.$this->server->uuid.'/transfer', escape: false);
});
test('taking back a transferred server needs a typed confirmation', function () {
app(ServerTransferClaimer::class)->markTransferred($this->server, targetInstanceUrl: 'https://target.example.com');
$this->get('/server/'.$this->server->uuid)
->assertOk()
->assertDontSee('Transfer', escape: false)
->assertDontSee('/transfer"', escape: false)
->assertDontSee('another instance')
->assertDontSee('Enable management')
->assertDontSee('Disable management');
})->with([
'management disabled' => [true],
'transferred away' => [false],
]);
->assertSee('Take back a transferred server?')
->assertSee('Changes made on https://target.example.com after', escape: false);
});
test('team members cannot open the transfer page', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$this->actingAs($member);
Livewire::test(Transfer::class, ['server_uuid' => $this->server->uuid])
->assertRedirect(route('server.index'));
$this->get('/server/'.$this->server->uuid)
->assertOk()
->assertDontSee('/server/'.$this->server->uuid.'/transfer', escape: false);
});
test('a member who owns another team cannot use that role to transfer the server', function () {
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$otherTeam = Team::factory()->create();
$otherTeam->members()->attach($member->id, ['role' => 'owner']);
$this->actingAs($member);
session(['currentTeam' => $this->team]);
Livewire::test(Transfer::class, ['server_uuid' => $this->server->uuid])
->assertRedirect(route('server.index'));
session(['currentTeam' => $otherTeam]);
Livewire::test(Transfer::class, ['server_uuid' => $this->server->uuid])
->assertStatus(404);
});
test('transfer page renders for owned server', function () {
Livewire::test(Transfer::class, ['server_uuid' => $this->server->uuid])
@@ -82,29 +109,14 @@ test('transfer page renders for owned server', function () {
->assertSee('Transfer server')
->assertSee('Target instance URL')
->assertSee('Target API token')
->assertSee('Transfer server')
->assertSee('Manual transfer');
});
test('transfer import page renders for admin', function () {
Livewire::test(TransferImport::class)
->assertOk()
->assertSee('Transfer file')
->assertSee('Dry run')
->assertSee('Import server');
});
test('export bundle streams download from livewire', function () {
Livewire::test(Transfer::class, ['server_uuid' => $this->server->uuid])
->call('exportBundle')
->assertFileDownloaded('server-transfer-'.$this->server->uuid.'.json');
->assertDontSee('Manual transfer')
->assertDontSee('Passphrase');
});
test('transfer page badge shows the management state of the server', function (string $state, string $label, array $hidden) {
$claimer = app(ServerTransferClaimer::class);
match ($state) {
'managed' => null,
'disabled' => $claimer->markTransferred($this->server, managementDisabled: true),
'transferred' => $claimer->markTransferred($this->server, targetInstanceUrl: 'https://coolify-b.test'),
};
@@ -112,53 +124,194 @@ test('transfer page badge shows the management state of the server', function (s
->assertSee($label)
->assertDontSee($hidden);
})->with([
'managed' => ['managed', 'Managed here', ['Transferable', 'Transferred away']],
'management disabled' => ['disabled', 'Transferable', ['Transferred away', 'Managed here']],
'transferred' => ['transferred', 'Transferred away', ['Transferable', 'Managed here']],
'managed' => ['managed', 'Managed here', ['Transferred away']],
'transferred' => ['transferred', 'Transferred away', ['Managed here']],
]);
test('export bundle is encrypted only when a passphrase is set', function (?string $passphrase, string $fileName) {
test('transfer to another instance runs in a queued job', function () {
Queue::fake();
Http::fake(['*/api/v1/servers/import/check*' => Http::response(['message' => 'ok', 'checks' => []])]);
Livewire::test(Transfer::class, ['server_uuid' => $this->server->uuid])
->set('passphrase', $passphrase)
->call('exportBundle')
->assertFileDownloaded('server-transfer-'.$this->server->uuid.$fileName);
->set('targetUrl', 'https://8.8.8.8')
->set('targetToken', 'target-token')
->call('migrateServer')
->assertHasNoErrors()
->assertSet('targetToken', '')
->assertSee('Transfer in progress');
Queue::assertPushed(MigrateServerToInstanceJob::class);
expect(data_get($this->server->fresh()->server_metadata, 'migration.status'))->toBe('running');
});
test('transfer page shows the error of a failed transfer', function () {
$this->server->forceFill(['server_metadata' => ['migration' => [
'status' => 'failed',
'target_url' => 'https://8.8.8.8',
'error' => 'Target import failed (HTTP 401): Unauthenticated.',
]]])->save();
Livewire::test(Transfer::class, ['server_uuid' => $this->server->uuid])
->assertSee('Transfer failed')
->assertSee('Target import failed (HTTP 401): Unauthenticated.');
});
test('a transferred server cannot delete its cloud provider machine', function () {
Queue::fake();
$this->server->forceFill(['hetzner_server_id' => 4242])->save();
app(ServerTransferClaimer::class)->markTransferred($this->server, targetInstanceUrl: 'https://coolify-b.test');
$component = Livewire::test(Delete::class, ['server_uuid' => $this->server->uuid]);
expect(collect($component->viewData('checkboxes'))->pluck('id'))->not->toContain('delete_from_hetzner');
$component->call('delete', $this->userPassword, ['delete_from_hetzner']);
DeleteServer::assertPushed(fn ($action, array $parameters) => $parameters[1] === false && $parameters[2] === 4242);
});
test('a transferred server does not show Hostinger VPS deletion guidance', function () {
$this->server->forceFill(['hostinger_virtual_machine_id' => 4242])->save();
app(ServerTransferClaimer::class)->markTransferred($this->server, targetInstanceUrl: 'https://coolify-b.test');
Livewire::test(Delete::class, ['server_uuid' => $this->server->uuid])
->assertDontSee('Hostinger deletes')
->assertDontSee('hpanel.hostinger.com');
});
test('a managed server can still delete its cloud provider machine', function () {
$this->server->forceFill(['hetzner_server_id' => 4242])->save();
$component = Livewire::test(Delete::class, ['server_uuid' => $this->server->uuid]);
expect(collect($component->viewData('checkboxes'))->pluck('id'))->toContain('delete_from_hetzner');
});
test('deleting a transferred server deletes its resources from this instance only', function () {
Queue::fake();
$destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail();
$application = Application::factory()->create([
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
app(ServerTransferClaimer::class)->markTransferred($this->server, targetInstanceUrl: 'https://coolify-b.test');
$component = Livewire::test(Delete::class, ['server_uuid' => $this->server->uuid]);
expect(collect($component->viewData('checkboxes'))->pluck('id'))->not->toContain('force_delete_resources');
$component->call('delete', $this->userPassword);
Queue::assertPushed(DeleteResourceJob::class, fn (DeleteResourceJob $job) => $job->resource->is($application) && $job->deleteFromCoolifyOnly);
expect(Server::find($this->server->id))->toBeNull();
});
test('deleting a transferred server forgets the DNS records of its resources without releasing them at the provider', function () {
Queue::fake();
$destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail();
$application = Application::factory()->create(['destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass()]);
// An archive that still tracks a record, for example from a transfer before records were forgotten at claim time.
$this->server->forceFill(['server_metadata' => ['transfer' => ['status' => 'transferred']]])->save();
$token = IntegrationToken::factory()->for($this->team)->create(['provider' => 'cloudflare']);
$zone = DnsProviderZone::factory()->create(['integration_token_id' => $token->id]);
$record = ManagedDnsRecord::factory()->owned()->create(['dns_provider_zone_id' => $zone->id]);
$record->addReference($application);
Livewire::test(Delete::class, ['server_uuid' => $this->server->uuid])->call('delete', $this->userPassword);
(new DeleteResourceJob($application->fresh(), deleteFromCoolifyOnly: true))->handle();
Queue::assertNotPushed(ReleaseManagedDnsRecordsJob::class);
expect(ManagedDnsRecord::find($record->id))->toBeNull()
->and(Application::withTrashed()->find($application->id))->toBeNull();
});
test('deleting an application from Coolify only does not touch the server', function () {
$destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail();
$application = Application::factory()->create([
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
Process::fake();
(new DeleteResourceJob($application, deleteFromCoolifyOnly: true))->handle();
Process::assertNothingRan();
expect(Application::withTrashed()->find($application->id))->toBeNull();
});
test('transfer with a token that the target does not accept shows an error and does not start', function () {
Queue::fake();
Http::fake(['*/api/v1/servers/import/check*' => Http::response(['message' => 'Unauthenticated.'], 401)]);
Livewire::test(Transfer::class, ['server_uuid' => $this->server->uuid])
->set('targetUrl', 'https://8.8.8.8')
->set('targetToken', 'wrong-token')
->call('migrateServer')
->assertDispatched('error')
->assertDontSee('Transfer in progress')
->assertSee('A check failed. The transfer did not start.')
->assertSee('OK: this instance can connect to the server over SSH.')
->assertSee('FAILED: the target instance accepts the API token.')
->assertSee('See the transfer log below for details.')
->assertDontSee('checks failed:');
Queue::assertNotPushed(MigrateServerToInstanceJob::class);
});
test('transfer page links to the server on the target and lists the steps after a finished transfer', function () {
$this->server->forceFill(['server_metadata' => ['migration' => [
'status' => 'succeeded',
'target_url' => 'https://8.8.8.8',
'checklist' => ['Set the webhook URL of GitHub App <b>deploy-bot</b> to https://8.8.8.8/webhooks/source/github/events.'],
'target_server_url' => 'https://8.8.8.8/server/abc',
]]])->save();
Livewire::test(Transfer::class, ['server_uuid' => $this->server->uuid])
->assertSee('Transfer finished')
->assertSee('Finish the transfer on the target')
->assertSee('href="https://8.8.8.8/server/abc"', escape: false)
->assertSee('Set the webhook URL of GitHub App <b>deploy-bot</b> to https://8.8.8.8/webhooks/source/github/events.')
->assertDontSee('<b>deploy-bot</b>', escape: false);
});
test('transfer page hides the post-transfer checklist', function (string $status, array $checklist) {
$this->server->forceFill(['server_metadata' => ['migration' => [
'status' => $status,
'target_url' => 'https://8.8.8.8',
'error' => 'Target import failed (HTTP 401): Unauthenticated.',
'checklist' => $checklist,
]]])->save();
Livewire::test(Transfer::class, ['server_uuid' => $this->server->uuid])
->assertDontSee('Finish the transfer on the target')
->assertDontSee('Set the webhook URL');
})->with([
'null' => [null, '.json'],
'empty string' => ['', '.json'],
'only spaces' => [' ', '.json'],
'set' => ['secret-pass', '.encrypted.json'],
'finished transfer without steps' => ['succeeded', []],
'failed transfer' => ['failed', ['Set the webhook URL of GitHub App deploy-bot.']],
]);
test('import dry run from pasted json', function () {
$bundle = app(ServerTransferExporter::class)->export($this->server);
test('the transfer page of an archive offers a transfer back instead of a transfer', function () {
app(ServerTransferClaimer::class)->markTransferred($this->server, targetInstanceUrl: 'https://8.8.8.8');
Http::fake(['https://8.8.8.8/api/v1/servers/*/migrate' => Http::response(['migration' => ['status' => 'running']], 202)]);
// Free IP for dry-run validation on same team would still report existing server
Livewire::test(TransferImport::class)
->set('bundleJson', json_encode($bundle))
->call('dryRun')
->assertSet('lastResult.dry_run', true)
->assertSet('lastResult.server_uuid', $this->server->uuid)
->assertSee('Dry run result')
->assertSee('Nothing written');
Livewire::test(Transfer::class, ['server_uuid' => $this->server->uuid])
->assertSet('managerUrl', 'https://8.8.8.8')
->assertDontSee('Transfer to another instance')
->set('managerToken', 'token-of-b')
->call('transferBack')
->assertDispatched('success')
->assertSee('Transfer back started');
Http::assertSentCount(1);
});
test('import creates server after source removal', function () {
$bundle = app(ServerTransferExporter::class)->export($this->server);
$uuid = $this->server->uuid;
test('team members cannot start a transfer back', function () {
app(ServerTransferClaimer::class)->markTransferred($this->server, targetInstanceUrl: 'https://8.8.8.8');
Http::fake();
$component = Livewire::test(Transfer::class, ['server_uuid' => $this->server->uuid]);
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$this->actingAs($member);
$this->server->forceDelete();
$this->privateKey->delete();
$component->set('managerToken', 'token-of-b')->call('transferBack');
Livewire::test(TransferImport::class)
->set('bundleJson', json_encode($bundle))
->set('preserveUuids', true)
->set('adoptMode', true)
->call('importBundle')
->assertSet('importedServerUuid', $uuid)
->assertSet('lastResult.dry_run', false)
->assertSet('lastResult.claimed', true);
$server = Server::where('uuid', $uuid)->first();
expect($server)->not->toBeNull()
->and(data_get($server->server_metadata, 'transfer.status'))->toBe('claimed');
Http::assertNothingSent();
});
@@ -5,6 +5,7 @@ use App\Models\CloudProviderToken;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Services\ServerTransfer\ServerTransferClaimer;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
@@ -243,3 +244,57 @@ it('gives the job enough time for every connection check step', function () {
expect($job->timeout)->toBeGreaterThanOrEqual(ServerConnectionCheckJob::maximumCheckSeconds())
->and($lock->expiresAfter)->toBeGreaterThan($job->timeout);
});
it('stops managing a server when its owner marker names another instance', function () {
$server = createServerForConnectionIsolationTest([
'ip' => '203.0.113.10',
'server_metadata' => ['transfer' => ['status' => 'claimed', 'claim' => ['claim_id' => 'claim-of-this-instance']]],
]);
$server->settings->update(['is_reachable' => true, 'is_usable' => true]);
Process::fake([
'*ls -la /*' => Process::result(output: '{"claim_id":"claim-of-instance-b","instance_url":"https://coolify-b.test"}', exitCode: 0),
'*' => Process::result(output: '{"Server":{"Version":"29.4.3"}}', exitCode: 0),
]);
(new ServerConnectionCheckJob($server->fresh(), disableMux: false))->handle();
$server->refresh();
expect($server->isTransferredAway())->toBeTrue()
->and(data_get($server->server_metadata, 'transfer.target_instance_url'))->toBe('https://coolify-b.test')
->and((bool) $server->settings->force_disabled)->toBeTrue();
Process::assertDidntRun(fn ($process) => str_contains($process->command, 'docker version'));
});
it('keeps managing a server when its owner marker is its own claim', function () {
$server = createServerForConnectionIsolationTest([
'ip' => '203.0.113.10',
'server_metadata' => ['transfer' => ['status' => 'claimed', 'claim' => ['claim_id' => 'claim-of-this-instance']]],
]);
Process::fake([
'*ls -la /*' => Process::result(output: '{"claim_id":"claim-of-this-instance"}', exitCode: 0),
'*compose*' => Process::result(output: 'v2.32.4', exitCode: 0),
'*' => Process::result(output: '{"Server":{"Version":"29.4.3"}}', exitCode: 0),
]);
(new ServerConnectionCheckJob($server->fresh(), disableMux: false))->handle();
$server->refresh();
expect($server->isTransferredAway())->toBeFalse()
->and((bool) $server->settings->is_usable)->toBeTrue();
});
it('passes the SSH probe on a server without an owner marker', function () {
// The remote shell runs the probe with `set -e`, like this.
$result = Process::input(ServerConnectionCheckJob::probeCommand())->run(['sh', '-se']);
expect(file_exists(ServerTransferClaimer::OWNER_MARKER_PATH))->toBeFalse()
->and($result->exitCode())->toBe(0);
});
it('keeps the owner marker read elevated in the SSH probe on a non-root server', function () {
$server = createServerForConnectionIsolationTest(['user' => 'ubuntu']);
expect(parseCommandsByLineForSudo(collect([ServerConnectionCheckJob::probeCommand()]), $server))->toBe([
'sudo cat '.ServerTransferClaimer::OWNER_MARKER_PATH.' 2>/dev/null || sudo true; ls -la / > /dev/null',
]);
});
@@ -39,7 +39,7 @@ test('danger menu is visible for non-coolify-host servers that use host.docker.i
$this->get(route('server.show', ['server_uuid' => $server->uuid]))
->assertSuccessful()
->assertSee('>Danger</', false)
->assertSee('>Danger Zone</', false)
->assertSee(route('server.delete', ['server_uuid' => $server->uuid]), false);
});
@@ -58,7 +58,6 @@ test('danger menu is hidden for the coolify host server', function () {
$this->get(route('server.show', ['server_uuid' => $server->uuid]))
->assertSuccessful()
->assertDontSee('>Danger</', false)
->assertDontSee(route('server.delete', ['server_uuid' => $server->uuid]), false);
});
+63 -56
View File
@@ -1,19 +1,28 @@
<?php
use App\Livewire\Server\Show;
use App\Livewire\Server\TransferFinish;
use App\Models\Application;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use App\Services\ServerTransfer\ServerTransferClaimer;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
config(['app.env' => 'local']);
// A claim writes the owner marker on the host over SSH.
Process::fake();
Storage::fake('ssh-keys');
config(['app.env' => 'production']);
$this->withoutVite();
InstanceSettings::forceCreate([
'id' => 0,
'fqdn' => 'https://coolify-a.test',
@@ -32,40 +41,13 @@ beforeEach(function () {
session(['currentTeam' => $this->team]);
});
test('owner can stop managing a server from this instance', function () {
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->call('toggleManagement')
->assertDispatched('success');
$this->server->refresh();
expect($this->server->isTransferredAway())->toBeTrue()
->and($this->server->isManagementDisabled())->toBeTrue()
->and((bool) $this->server->settings->force_disabled)->toBeTrue()
->and((bool) $this->server->settings->is_sentinel_enabled)->toBeFalse();
});
test('server with disabled management shows it is transferable, not transferred away', function () {
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->call('toggleManagement')
->assertSee('Transferable to another instance')
->assertSee(route('server.transfer', ['server_uuid' => $this->server->uuid]))
->assertDontSee('Transferred to another instance')
->assertDontSee('Transferred away')
->assertDontSeeHtml('>Transferable<');
});
test('server transferred to another instance is not reported as management disabled', function () {
test('server transferred to another instance shows where it was transferred', function () {
app(ServerTransferClaimer::class)->markTransferred($this->server, targetInstanceUrl: 'https://coolify-b.test');
$this->server->refresh();
expect($this->server->isTransferredAway())->toBeTrue()
->and($this->server->isManagementDisabled())->toBeFalse();
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->assertSee('Transferred to another instance')
->assertDontSee('Transferable to another instance');
->assertSee('https://coolify-b.test')
->assertSee('Enable management');
});
test('owner can take management of a transferred server', function () {
@@ -79,7 +61,7 @@ test('owner can take management of a transferred server', function () {
])->save();
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->call('toggleManagement')
->call('enableManagement')
->assertDispatched('success');
$this->server->refresh();
@@ -112,7 +94,7 @@ test('cloud team over its server limit cannot take management of a transferred s
markServerTransferredAway($this->server);
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->call('toggleManagement')
->call('enableManagement')
->assertDispatched('error')
->assertNotDispatched('success');
@@ -128,7 +110,7 @@ test('cloud team within its server limit can take management of a transferred se
markServerTransferredAway($this->server);
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->call('toggleManagement')
->call('enableManagement')
->assertDispatched('success');
$this->server->refresh();
@@ -137,7 +119,7 @@ test('cloud team within its server limit can take management of a transferred se
->and((bool) $this->server->settings->force_disabled)->toBeFalse();
});
test('claiming a server keeps it force disabled when the cloud team is over its server limit', function () {
test('claiming a server fails without changes when the cloud team is over its server limit', function () {
config()->set('constants.coolify.self_hosted', false);
$this->team->forceFill(['custom_server_limit' => 1])->save();
Server::factory()->create([
@@ -146,15 +128,18 @@ test('claiming a server keeps it force disabled when the cloud team is over its
]);
markServerTransferredAway($this->server);
app(ServerTransferClaimer::class)->claim($this->server);
expect(fn () => app(ServerTransferClaimer::class)->claim($this->server))
->toThrow(RuntimeException::class, 'over its server limit');
$this->server->refresh();
expect(data_get($this->server->server_metadata, 'transfer.status'))->toBe('claimed')
expect($this->server->isTransferredAway())->toBeTrue()
->and((bool) $this->server->settings->force_disabled)->toBeTrue();
Process::assertNothingRan();
});
test('team members cannot change server management ownership', function () {
test('team members cannot take management of a transferred server', function () {
markServerTransferredAway($this->server);
$member = User::factory()->create();
$this->team->members()->attach($member, ['role' => 'member']);
@@ -162,27 +147,25 @@ test('team members cannot change server management ownership', function () {
session(['currentTeam' => $this->team]);
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->call('toggleManagement')
->call('enableManagement')
->assertForbidden();
expect($this->server->fresh()->isTransferredAway())->toBeFalse();
expect($this->server->fresh()->isTransferredAway())->toBeTrue();
});
test('server overview shows a management button that matches the ownership state', function () {
test('a managed server has no management button and cannot be disabled from the server page', function () {
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->assertSee('Disable management')
->assertDontSee('Enable management')
->assertDontSeeHtml('wire:confirm')
->call('toggleManagement', '')
->assertSee('Enable management')
->assertDontSee('Disable management');
->assertDontSee('Disable management')
->call('enableManagement')
->assertNotDispatched('success');
expect(data_get($this->server->fresh()->server_metadata, 'transfer.status'))->toBeNull();
});
test('server validation button is hidden while management is disabled or transferred away', function () {
test('server validation button is hidden while the server is transferred away', function () {
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->assertSeeHtml('wire:click.prevent="validateServer"')
->call('toggleManagement')
->assertDontSeeHtml('wire:click.prevent="validateServer"');
->assertSeeHtml('wire:click.prevent="validateServer"');
app(ServerTransferClaimer::class)->markTransferred($this->server->fresh(), targetInstanceUrl: 'https://coolify-b.test');
@@ -190,13 +173,37 @@ test('server validation button is hidden while management is disabled or transfe
->assertDontSeeHtml('wire:click.prevent="validateServer"');
});
test('management ownership is not available outside development', function () {
config(['app.env' => 'production']);
test('taking back a transferred server fails without changes when the host cannot be reached', function () {
markServerTransferredAway($this->server);
Process::fake(['*' => Process::result(errorOutput: 'Connection refused', exitCode: 1)]);
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->assertDontSee('Disable management')
->call('toggleManagement')
->assertNotFound();
->call('enableManagement')
->assertNotDispatched('success');
expect($this->server->fresh()->isTransferredAway())->toBeFalse();
$this->server->refresh();
expect($this->server->isTransferredAway())->toBeTrue()
->and((bool) $this->server->settings->force_disabled)->toBeTrue();
});
test('after a take back the server page shows the finish list until the user finishes the transfer', function () {
markServerTransferredAway($this->server);
Application::factory()->create([
'name' => 'shop',
'destination_id' => $this->server->standaloneDockers()->first()->id,
'destination_type' => StandaloneDocker::class,
'git_repository' => 'https://github.com/coollabsio/coolify',
]);
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->call('enableManagement')
->assertDispatched('transferFinishStarted');
Livewire::test(TransferFinish::class, ['server' => $this->server->fresh()])
->assertSee('Finish the transfer')
->assertSee('Manual webhook of shop')
->call('finishTransfer')
->assertDontSee('Finish the transfer');
expect(data_get($this->server->fresh()->server_metadata, 'transfer.finish.finished_at'))->not->toBeNull();
});
+29
View File
@@ -128,6 +128,35 @@ it('stores a parsed docker version when gathering server metadata', function ()
->and($this->server->fresh()->composeVersion())->toBe('2.32.4');
});
it('keeps transfer and migration state when gathering server metadata', function () {
$this->server->settings->update([
'is_reachable' => true,
'is_usable' => true,
]);
// Loaded before the transfer state is written, like a validation that runs during a transfer.
$staleServer = Server::find($this->server->id);
$this->server->update(['server_metadata' => [
'os' => 'Old OS',
'transfer' => ['status' => 'claimed', 'export_id' => 'export-1'],
'migration' => ['status' => 'succeeded', 'export_id' => 'export-1'],
]]);
Process::fake([
'*' => Process::result(output: "---PRETTY_NAME---\nDebian GNU/Linux 12 (bookworm)\n---CPUS---\n2", exitCode: 0),
]);
$staleServer->gatherServerMetadata();
expect($this->server->fresh()->server_metadata)
->toMatchArray([
'os' => 'Debian GNU/Linux 12 (bookworm)',
'cpus' => 2,
'transfer' => ['status' => 'claimed', 'export_id' => 'export-1'],
'migration' => ['status' => 'succeeded', 'export_id' => 'export-1'],
]);
});
it('shows the stored docker version in remote server details', function () {
$this->server->update([
'server_metadata' => [
@@ -1,17 +1,20 @@
<?php
use App\Enums\ProxyTypes;
use App\Livewire\Server\Navbar;
use App\Models\InstanceSettings;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use App\Services\ProxyDashboardCacheService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::create(['id' => 0]);
InstanceSettings::forceCreate(['id' => 0]);
});
function makeNavbarServer(bool $isFunctional): array
@@ -98,3 +101,35 @@ it('refreshes sentinel status when sentinel restarts for the server', function (
->assertDontSee('Out of sync')
->assertDispatched('sentinel-status-changed');
});
it('does not render the floating action panel when the server has no proxy actions', function () {
[$server] = makeNavbarServer(isFunctional: false);
Livewire::test('server.navbar', ['server' => $server])
->assertDontSeeHtml('x-teleport="#resource-action-hud-slot"');
});
it('renders the floating action panel with proxy actions for a functional server', function () {
[$server] = makeNavbarServer(isFunctional: true);
$server->forceFill(['proxy' => ['type' => ProxyTypes::TRAEFIK->value]])->save();
$server = $server->fresh();
expect($server->proxySet())->toBeTrue();
Livewire::test('server.navbar', ['server' => $server])
->assertSeeHtml('x-teleport="#resource-action-hud-slot"')
->assertSee('Refresh Proxy Status');
});
it('shows the Traefik dashboard link without proxy actions to members who cannot manage the proxy', function () {
[$server, $user, $team] = makeNavbarServer(isFunctional: true);
$user->teams()->updateExistingPivot($team->id, ['role' => 'member']);
$server->forceFill(['proxy' => ['type' => ProxyTypes::TRAEFIK->value, 'status' => 'running']])->save();
$server = $server->fresh();
Cache::put(ProxyDashboardCacheService::getCacheKey($server), true);
Livewire::test('server.navbar', ['server' => $server])
->assertSeeHtml('x-teleport="#resource-action-hud-slot"')
->assertSee('Traefik Dashboard')
->assertDontSee('Refresh Proxy Status');
});
@@ -126,12 +126,6 @@ test('toggling terminal access records enabled and disabled events', function ()
->and(serverSettingsAuditRows('ui.server.terminal_access.disabled'))->toHaveCount(1);
});
test('toggling terminal access keeps the security sidebar group open', function () {
Livewire::test(TerminalAccess::class, ['server_uuid' => $this->server->uuid])
->call('toggleTerminal', 'password')
->assertSeeHtml("activeGroup: 'Security'");
});
test('server settings saves record changed setting names without the sentinel token', function () {
$newToken = 'audit-secret-sentinel-token-123';
@@ -1,5 +1,6 @@
<?php
use App\Jobs\ValidateAndInstallServerJob;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\InstanceSettings;
@@ -15,12 +16,15 @@ use App\Services\ServerTransfer\ServerTransferExporter;
use App\Services\ServerTransfer\ServerTransferImporter;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\ValidationException;
uses(RefreshDatabase::class);
beforeEach(function () {
// An import validates the claimed server over SSH; the sync test queue would run it.
Queue::fake([ValidateAndInstallServerJob::class]);
config()->set('app.env', 'local');
config()->set('app.maintenance.driver', 'file');
Server::flushIdentityMap();
@@ -80,7 +84,7 @@ test('preview imports reject another team UUID and roll back all imported record
$token->accessToken->forceFill(['team_id' => $team->id])->save();
$counts = [Server::count(), Application::withTrashed()->count(), PrivateKey::count(), Project::count()];
$this->withToken($token->plainTextToken)->postJson('/api/v1/servers/import', ['bundle' => $bundle])
$this->withToken($token->plainTextToken)->postJson('/api/v1/servers/import', ['bundle' => $bundle, 'adopt_mode' => false])
->assertUnprocessable()
->assertJsonValidationErrors('previews');
@@ -103,7 +107,7 @@ test('preview imports reject another team domain without clearing it', function
$serverCount = Server::count();
try {
app(ServerTransferImporter::class)->import($bundle, $team->id, preserveUuids: $preserveUuids);
app(ServerTransferImporter::class)->import($bundle, $team->id, preserveUuids: $preserveUuids, adoptMode: false);
$this->fail('A foreign preview domain must be rejected.');
} catch (ValidationException $exception) {
expect($exception->errors())->toHaveKey('previews');
@@ -130,7 +134,7 @@ test('preview imports preserve same-team UUID handoffs and preview volumes', fun
'mount_path' => '/data',
]);
$result = app(ServerTransferImporter::class)->import($fixture['bundle'], $team->id);
$result = app(ServerTransferImporter::class)->import($fixture['bundle'], $team->id, adoptMode: false);
$imported = Server::whereUuid($result['server_uuid'])->firstOrFail()->applications()->firstOrFail();
expect($preview->fresh()->application_id)->toBe($imported->id)
@@ -146,7 +150,7 @@ test('preview imports can release a same-team domain collision', function () {
$bundle = $fixture['bundle'];
$bundle['projects'][0]['environments'][0]['applications'][0]['previews'][0]['uuid'] = new_public_id();
$result = app(ServerTransferImporter::class)->import($bundle, $team->id);
$result = app(ServerTransferImporter::class)->import($bundle, $team->id, adoptMode: false);
$imported = Server::whereUuid($result['server_uuid'])->firstOrFail()->applications()->firstOrFail();
expect($fixture['preview']->fresh()->fqdn)->toBeNull();
@@ -46,49 +46,32 @@ test('assertValid throws validation exception', function () {
ServerTransferBundle::assertValid(['schema_version' => 99]);
})->throws(ValidationException::class);
test('passphrase encrypt decrypt round trip', function () {
$original = ServerTransferBundle::wrap([
'private_key' => ['private_key' => 'secret-key-material'],
'server' => [
'uuid' => 'srv-1',
'name' => 'web',
'ip' => '10.0.0.1',
'port' => 22,
'user' => 'root',
],
'destinations' => [['uuid' => 'd1', 'name' => 'coolify', 'network' => 'coolify', 'type' => 'standalone']],
'projects' => [],
]);
$encrypted = ServerTransferBundle::encryptWithPassphrase($original, 'correct horse battery staple');
expect($encrypted['encrypted'])->toBeTrue()
->and($encrypted['payload'])->toBeString();
$restored = ServerTransferBundle::decryptWithPassphrase($encrypted, 'correct horse battery staple');
expect($restored['export_id'])->toBe($original['export_id'])
->and($restored['server']['uuid'])->toBe('srv-1')
->and($restored['private_key']['private_key'])->toBe('secret-key-material');
});
test('wrong passphrase fails decrypt', function () {
$original = ServerTransferBundle::wrap([
function bundleTestMinimalBundle(): array
{
return ServerTransferBundle::wrap([
'private_key' => ['private_key' => 'x'],
'server' => ['uuid' => 's', 'name' => 'n', 'ip' => '1.1.1.1', 'port' => 22, 'user' => 'root'],
'destinations' => [],
'projects' => [],
]);
}
$encrypted = ServerTransferBundle::encryptWithPassphrase($original, 'good-pass');
test('validate rejects a bundle from a newer or unknown Coolify version', function (?string $version, string $error) {
$bundle = bundleTestMinimalBundle();
$bundle['coolify_version'] = $version;
ServerTransferBundle::decryptWithPassphrase($encrypted, 'bad-pass');
})->throws(RuntimeException::class);
$result = ServerTransferBundle::validate($bundle);
test('app key seal unseal round trip', function () {
$original = ['hello' => 'world', 'n' => 1];
$sealed = ServerTransferBundle::sealWithAppKey($original);
$restored = ServerTransferBundle::unsealWithAppKey($sealed);
expect($result['valid'])->toBeFalse()
->and(implode(' ', $result['errors']))->toContain($error);
})->with([
'newer' => ['99.0.0', 'Upgrade this instance'],
'missing' => [null, 'Missing coolify_version'],
]);
expect($restored)->toBe($original);
test('validate accepts a bundle from an older Coolify version', function () {
$bundle = bundleTestMinimalBundle();
$bundle['coolify_version'] = '4.0.0';
expect(ServerTransferBundle::validate($bundle)['valid'])->toBeTrue();
});
@@ -1,16 +1,35 @@
<?php
use App\Enums\GithubRunnerStatus;
use App\Jobs\CleanupGithubRunnerJob;
use App\Jobs\DeregisterGithubRunnerJob;
use App\Jobs\ProvisionGithubRunnerJob;
use App\Models\Application;
use App\Models\DnsProviderZone;
use App\Models\GithubApp;
use App\Models\GithubRunnerConfig;
use App\Models\GithubRunnerExecution;
use App\Models\InstanceSettings;
use App\Models\IntegrationToken;
use App\Models\ManagedDnsRecord;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Services\ServerTransfer\ServerTransferClaimer;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
// A claim writes the owner marker on the host over SSH.
Process::fake();
Storage::fake('ssh-keys');
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true, 'fqdn' => 'https://coolify-a.test']);
$this->team = Team::factory()->create();
@@ -59,3 +78,213 @@ test('claim persists ownership metadata and always rebinds Sentinel', function (
->and((bool) $this->server->settings->is_sentinel_enabled)->toBeTrue()
->and((string) $this->server->settings->sentinel_custom_url)->toBe('https://coolify-a.test');
});
test('claim writes the owner marker with its claim id on the host', function () {
$result = app(ServerTransferClaimer::class)->claim($this->server);
$claimId = data_get($result, 'claim.claim_id');
expect($claimId)->toBeString()->not->toBeEmpty()
->and(data_get($this->server->fresh()->server_metadata, 'transfer.claim.claim_id'))->toBe($claimId);
Process::assertRan(function ($process) use ($claimId) {
$marker = json_decode((string) $process->input, true);
return str_contains($process->command, ServerTransferClaimer::OWNER_MARKER_PATH)
&& data_get($marker, 'claim_id') === $claimId
&& data_get($marker, 'instance_url') === 'https://coolify-a.test';
});
});
test('claim changes nothing when the owner marker cannot be written', function () {
Process::fake(['*' => Process::result(errorOutput: 'Permission denied', exitCode: 1)]);
$sentinelUrlBefore = $this->server->settings->sentinel_custom_url;
expect(fn () => app(ServerTransferClaimer::class)->claim($this->server))->toThrow(RuntimeException::class);
$this->server->refresh();
expect(data_get($this->server->server_metadata, 'transfer.status'))->toBe('imported')
->and(data_get($this->server->server_metadata, 'transfer.claim'))->toBeNull()
->and($this->server->settings->sentinel_custom_url)->toBe($sentinelUrlBefore);
});
test('a server is released when its owner marker names the claim of another instance', function () {
app(ServerTransferClaimer::class)->claim($this->server);
$released = app(ServerTransferClaimer::class)->releaseIfClaimedElsewhere($this->server->fresh(), json_encode([
'claim_id' => 'claim-of-instance-b',
'instance_url' => 'https://coolify-b.test',
]));
$this->server->refresh();
expect($released)->toBeTrue()
->and($this->server->isTransferredAway())->toBeTrue()
->and(data_get($this->server->server_metadata, 'transfer.target_instance_url'))->toBe('https://coolify-b.test')
->and((bool) $this->server->settings->force_disabled)->toBeTrue()
->and((bool) $this->server->settings->is_sentinel_enabled)->toBeFalse();
});
test('a server is not released when the owner marker is its own claim or missing', function (?string $marker) {
$claimId = data_get(app(ServerTransferClaimer::class)->claim($this->server), 'claim.claim_id');
$marker = $marker === 'own' ? json_encode(['claim_id' => $claimId, 'instance_url' => 'https://coolify-a.test']) : $marker;
$released = app(ServerTransferClaimer::class)->releaseIfClaimedElsewhere($this->server->fresh(), $marker);
expect($released)->toBeFalse()
->and($this->server->fresh()->isTransferredAway())->toBeFalse()
->and((bool) $this->server->fresh()->settings->force_disabled)->toBeFalse();
})->with([
'own claim' => 'own',
'no marker' => '',
'unreadable marker' => 'not json',
'marker without claim id' => '{"instance_url":"https://coolify-b.test"}',
]);
test('a server that this instance never claimed ignores the owner marker', function () {
$released = app(ServerTransferClaimer::class)->releaseIfClaimedElsewhere($this->server, json_encode([
'claim_id' => 'left-over-claim-of-a-deleted-instance',
'instance_url' => 'https://coolify-b.test',
]));
expect($released)->toBeFalse()
->and($this->server->fresh()->isTransferredAway())->toBeFalse();
});
test('markTransferred keeps the DNS records of the server resources as not owned, without provider calls', function () {
Http::fake();
$destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail();
$application = Application::factory()->create(['destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass()]);
$otherApplication = Application::factory()->create();
$token = IntegrationToken::factory()->for($this->team)->create(['provider' => 'cloudflare']);
$zone = DnsProviderZone::factory()->create(['integration_token_id' => $token->id]);
$onlyThisServer = ManagedDnsRecord::factory()->owned()->create(['dns_provider_zone_id' => $zone->id]);
$onlyThisServer->addReference($application);
$shared = ManagedDnsRecord::factory()->owned()->create(['dns_provider_zone_id' => $zone->id]);
$shared->addReference($application);
$shared->addReference($otherApplication);
app(ServerTransferClaimer::class)->markTransferred($this->server, targetInstanceUrl: 'https://coolify-b.test');
Http::assertNothingSent();
expect($onlyThisServer->fresh()->owned)->toBeFalse()
->and($onlyThisServer->fresh()->references()->pluck('resource_id')->all())->toBe([$application->id])
->and(data_get($this->server->fresh()->server_metadata, 'transfer.parked_dns_record_ids'))->toBe([$onlyThisServer->id])
->and($shared->fresh()->owned)->toBeTrue()
->and($shared->fresh()->references()->pluck('resource_id')->all())->toBe([$otherApplication->id]);
});
test('take back owns the parked DNS records again and starts the finish list', function (string $remoteComment, bool $reclaimed) {
$destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail();
$application = Application::factory()->create(['destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass()]);
$token = IntegrationToken::factory()->for($this->team)->create(['provider' => 'cloudflare']);
$zone = DnsProviderZone::factory()->create(['integration_token_id' => $token->id]);
$record = ManagedDnsRecord::factory()->owned()->create(['dns_provider_zone_id' => $zone->id, 'name' => 'app.example.test', 'type' => 'A', 'content' => '10.0.0.1']);
$record->addReference($application);
$claimer = app(ServerTransferClaimer::class);
$claimer->markTransferred($this->server, targetInstanceUrl: 'https://coolify-b.test');
$claimer->claim($this->server->fresh());
$remote = ['type' => 'A', 'name' => 'app.example.test', 'content' => '10.0.0.1', 'comment' => str_replace('{uuid}', $record->uuid, $remoteComment)];
Http::fake([
'api.cloudflare.com/*' => fn ($request) => $request->method() === 'GET'
? Http::response(['success' => true, 'result' => $remote])
: Http::response(['success' => true, 'result' => [...$remote, 'comment' => $record->ownershipComment()]]),
]);
$items = $claimer->restoreAfterTakeBack($this->server->fresh());
expect($record->fresh()->owned)->toBe($reclaimed)
->and(data_get($this->server->fresh()->server_metadata, 'transfer.parked_dns_record_ids'))->toBeNull()
->and(array_column($items, 'key'))->toContain("dns_record:{$record->uuid}", "manual_webhook:{$application->uuid}")
->and(data_get($this->server->fresh()->server_metadata, 'transfer.finish.items'))->toBe($items);
if ($reclaimed) {
Http::assertSent(fn ($request) => $request->method() === 'PATCH' && $request['comment'] === $record->ownershipComment());
} else {
Http::assertNotSent(fn ($request) => $request->method() === 'PATCH');
}
})->with([
'the other instance adopted the record' => ['managed-by: coolify other-instance/{uuid}', true],
'someone changed the record at the provider' => ['set by hand', false],
]);
function transferRunnerSetup(Server $server): array
{
$githubApp = GithubApp::create([
'name' => 'runner-app',
'organization' => 'acme',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'app_id' => 5151,
'installation_id' => 5252,
'webhook_secret' => 'runner-secret',
'team_id' => $server->team_id,
'is_system_wide' => false,
]);
$config = GithubRunnerConfig::create(['server_id' => $server->id, 'github_app_id' => $githubApp->id, 'labels' => ['coolify'], 'is_enabled' => true]);
return [$githubApp, $config];
}
test('markTransferred disables the runner config and stops tracking the runners without removing them at GitHub', function () {
Queue::fake();
[$githubApp, $config] = transferRunnerSetup($this->server);
$execution = fn (GithubRunnerStatus $status, array $attributes = []) => GithubRunnerExecution::create([
'github_app_id' => $githubApp->id,
'github_runner_config_id' => $config->id,
'server_id' => $this->server->id,
'trigger_workflow_job_id' => fake()->unique()->numberBetween(1000, 9999),
'status' => $status,
'runner_id' => fake()->unique()->numberBetween(1, 999),
...$attributes,
]);
$active = collect([
$execution(GithubRunnerStatus::Queued, ['runner_id' => null]),
$execution(GithubRunnerStatus::Provisioning),
$execution(GithubRunnerStatus::Idle),
$execution(GithubRunnerStatus::Running),
]);
$completed = $execution(GithubRunnerStatus::Completed, ['completed_at' => now()->subDay(), 'error_message' => null]);
app(ServerTransferClaimer::class)->markTransferred($this->server, targetInstanceUrl: 'https://coolify-b.test');
expect($config->fresh()->is_enabled)->toBeFalse()
->and($completed->fresh()->only(['status', 'error_message']))->toBe(['status' => GithubRunnerStatus::Completed, 'error_message' => null]);
foreach ($active as $row) {
$row->refresh();
expect($row->status)->toBe(GithubRunnerStatus::Cancelled)
->and($row->completed_at)->not->toBeNull()
->and($row->error_message)->toBe('The server was transferred to another Coolify instance. This instance stopped managing its runners.');
}
// Deleting the archive later must not pull the runners that now belong to the target.
$this->server->fresh()->forceDelete();
Queue::assertNotPushed(DeregisterGithubRunnerJob::class);
Queue::assertNotPushed(CleanupGithubRunnerJob::class);
});
test('a GitHub webhook does not queue a runner for a transferred server', function () {
Queue::fake();
[$githubApp] = transferRunnerSetup($this->server);
$send = function () use ($githubApp) {
$body = json_encode([
'action' => 'queued',
'workflow_job' => ['id' => fake()->unique()->numberBetween(1000, 9999), 'labels' => ['self-hosted', 'coolify'], 'name' => 'test'],
'repository' => ['id' => 99, 'full_name' => 'acme/api'],
'installation' => ['id' => $githubApp->installation_id],
], JSON_THROW_ON_ERROR);
return $this->call('POST', '/webhooks/source/github/events', [], [], [], [
'HTTP_X-GitHub-Event' => 'workflow_job',
'HTTP_X-GitHub-Hook-Installation-Target-Id' => (string) $githubApp->app_id,
'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $body, 'runner-secret'),
'CONTENT_TYPE' => 'application/json',
], $body);
};
$send()->assertSee('Runner queued.');
GithubRunnerExecution::query()->delete();
app(ServerTransferClaimer::class)->markTransferred($this->server, targetInstanceUrl: 'https://coolify-b.test');
$send()->assertSee('No runner configuration matches the job labels.');
expect(GithubRunnerExecution::count())->toBe(0);
Queue::assertPushed(ProvisionGithubRunnerJob::class, 1);
});
@@ -0,0 +1,208 @@
<?php
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\InstanceSettings;
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\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Models\SharedEnvironmentVariable;
use App\Models\SslCertificate;
use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
use App\Models\Tag;
use App\Models\Team;
use App\Services\ServerTransfer\ServerTransferCoverage;
use App\Services\ServerTransfer\ServerTransferExporter;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
/**
* Saves a record without model events. Required columns that $attributes leaves out get a dummy value.
*
* @param class-string<Model> $modelClass
* @param array<string, mixed> $attributes
*/
function transferCoverageRecord(string $modelClass, array $attributes = []): Model
{
$model = new $modelClass;
foreach (Schema::getColumns($model->getTable()) as $column) {
$name = $column['name'];
if (array_key_exists($name, $attributes) || $column['nullable'] || $column['default'] !== null || $name === 'id' || $column['auto_increment']) {
continue;
}
// The SQLite test schema reports dates as text, so the model casts decide first.
$type = strtolower($column['type_name']);
$attributes[$name] = match (true) {
$name === 'uuid' => new_public_id(),
$model->hasCast($name, ['date', 'datetime', 'immutable_date', 'immutable_datetime', 'timestamp']) => now(),
$model->hasCast($name, ['bool', 'boolean']) || str_contains($type, 'bool') => false,
$model->hasCast($name, ['array', 'json', 'collection', 'object']) || str_contains($type, 'json') => [],
str_contains($type, 'int') => 1,
default => 'coverage_'.$name,
};
}
return $modelClass::withoutEvents(function () use ($model, $attributes) {
$model->forceFill($attributes)->save();
return $model;
});
}
/**
* Every key that the records at these bundle paths contain, also the keys of their `attributes` object.
* A record with a `model` key counts only for that model.
*
* @param array<string, mixed> $bundle
* @param list<string> $paths
* @return array{keys: list<string>, records: int}
*/
function transferCoverageBundleKeys(array $bundle, array $paths, string $modelClass): array
{
$records = collect($paths)
->flatMap(function (string $path) use ($bundle) {
$found = data_get($bundle, $path);
// A path to one record returns the record itself; a path with `*` returns a list of records.
return is_array($found) && ! array_is_list($found) ? [$found] : Arr::wrap($found);
})
->filter(fn ($record) => is_array($record) && ! array_is_list($record))
->filter(fn (array $record) => ! isset($record['model']) || $record['model'] === $modelClass);
$keys = $records->flatMap(fn (array $record) => [...array_keys($record), ...array_keys((array) ($record['attributes'] ?? []))]);
return ['keys' => $keys->unique()->values()->all(), 'records' => $records->count()];
}
beforeEach(function () {
Server::flushIdentityMap();
Process::fake();
Storage::fake('ssh-keys');
Storage::fake('images');
Queue::fake([ValidateAndInstallServerJob::class]);
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true, 'fqdn' => 'https://coolify-a.test']);
$team = Team::factory()->create();
$teamId = ['team_id' => $team->id];
$privateKey = PrivateKey::factory()->create($teamId);
$deployKey = PrivateKey::factory()->create([...$teamId, 'private_key' => generateSSHKey('ed25519')['private']]);
$token = CloudProviderToken::factory()->create($teamId);
$this->server = Server::factory()->create([...$teamId, 'private_key_id' => $privateKey->id, 'cloud_provider_token_id' => $token->id, 'ip' => '10.88.0.10']);
$destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail();
transferCoverageRecord(SwarmDocker::class, ['server_id' => $this->server->id]);
transferCoverageRecord(SslCertificate::class, ['server_id' => $this->server->id]);
$project = Project::factory()->create($teamId);
Storage::disk('images')->put("project-icons/{$project->uuid}/icon.jpg", "\xFF\xD8\xFF\xE0\x00\x10JFIF\x00coverage");
$project->forceFill(['icon_path' => "project-icons/{$project->uuid}/icon.jpg", 'icon_storage_type' => 'local'])->saveQuietly();
$environment = $project->environments()->first() ?? Environment::factory()->create(['project_id' => $project->id]);
$onDestination = ['environment_id' => $environment->id, 'destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass()];
$githubApp = transferCoverageRecord(GithubApp::class, [...$teamId, 'is_system_wide' => false, 'private_key_id' => $deployKey->id]);
$gitlabApp = transferCoverageRecord(GitlabApp::class, [...$teamId, 'is_system_wide' => false]);
$application = Application::factory()->create([...$onDestination, 'source_type' => GithubApp::class, 'source_id' => $githubApp->id, 'private_key_id' => $deployKey->id]);
Application::factory()->create([...$onDestination, 'source_type' => GitlabApp::class, 'source_id' => $gitlabApp->id]);
$preview = transferCoverageRecord(ApplicationPreview::class, ['application_id' => $application->id]);
$s3 = transferCoverageRecord(S3Storage::class, $teamId);
$service = Service::factory()->create([...$onDestination, 'server_id' => $this->server->id]);
transferCoverageRecord(ResourceLink::class, [
'environment_id' => $environment->id,
'source_type' => $application->getMorphClass(), 'source_id' => $application->id,
'target_type' => $service->getMorphClass(), 'target_id' => $service->id,
]);
transferCoverageRecord(GithubRunnerConfig::class, ['server_id' => $this->server->id, 'github_app_id' => $githubApp->id, 'labels' => ['coverage']]);
transferCoverageRecord(ServiceApplication::class, ['service_id' => $service->id]);
transferCoverageRecord(ServiceDatabase::class, ['service_id' => $service->id]);
$databases = collect(ServerTransferCoverage::TRANSFERRED)->keys()
->filter(fn (string $class) => str_starts_with(class_basename($class), 'Standalone') && ! in_array($class, [StandaloneDocker::class], true))
->map(fn (string $class) => transferCoverageRecord($class, $onDestination));
$volume = transferCoverageRecord(LocalPersistentVolume::class, ['resource_type' => $application->getMorphClass(), 'resource_id' => $application->id]);
transferCoverageRecord(LocalFileVolume::class, ['resource_type' => $application->getMorphClass(), 'resource_id' => $application->id]);
transferCoverageRecord(ScheduledVolumeBackup::class, [...$teamId, 'backupable_type' => $volume->getMorphClass(), 'backupable_id' => $volume->id, 's3_storage_id' => $s3->id]);
transferCoverageRecord(ScheduledTask::class, [...$teamId, 'application_id' => $application->id]);
transferCoverageRecord(ScheduledTask::class, [...$teamId, 'service_id' => $service->id]);
transferCoverageRecord(ScheduledDatabaseBackup::class, [...$teamId, 'database_type' => $databases->first()->getMorphClass(), 'database_id' => $databases->first()->id, 's3_storage_id' => $s3->id]);
$tag = transferCoverageRecord(Tag::class, $teamId);
foreach ([$application, $databases->first(), $service] as $tagged) {
$tagged->tags()->attach($tag->id);
}
foreach ([$application, $preview->application, $databases->first(), $service] as $resource) {
transferCoverageRecord(EnvironmentVariable::class, ['resourceable_type' => $resource->getMorphClass(), 'resourceable_id' => $resource->id, 'key' => 'COVERAGE_'.fake()->unique()->numberBetween(), 'value' => '{{team.COVERAGE_TEAM}}']);
}
transferCoverageRecord(EnvironmentVariable::class, ['resourceable_type' => $application->getMorphClass(), 'resourceable_id' => $application->id, 'key' => 'COVERAGE_PREVIEW', 'value' => 'x', 'is_preview' => true]);
transferCoverageRecord(SharedEnvironmentVariable::class, [...$teamId, 'type' => 'team', 'key' => 'COVERAGE_TEAM']);
transferCoverageRecord(SharedEnvironmentVariable::class, [...$teamId, 'type' => 'server', 'server_id' => $this->server->id]);
transferCoverageRecord(SharedEnvironmentVariable::class, [...$teamId, 'type' => 'project', 'project_id' => $project->id]);
transferCoverageRecord(SharedEnvironmentVariable::class, [...$teamId, 'type' => 'environment', 'environment_id' => $environment->id]);
$cloudflareToken = IntegrationToken::factory()->create([...$teamId, 'provider' => 'cloudflare', 'capabilities' => ['dns', 'tunnel'], 'metadata' => ['automatic_dns' => true]]);
$secretsToken = IntegrationToken::factory()->create([...$teamId, 'provider' => 'infisical', 'capabilities' => ['secrets'], 'metadata' => ['client_id' => 'coverage']]);
$zone = DnsProviderZone::factory()->create(['integration_token_id' => $cloudflareToken->id]);
ManagedDnsRecord::factory()->owned()->create(['dns_provider_zone_id' => $zone->id])->addReference($application);
$application->secretManagerLink()->create(['integration_token_id' => $secretsToken->id, 'settings' => ['project_id' => 'coverage', 'environment' => 'prod']]);
$this->server->settings->forceFill(['cloudflare_tunnel_integration_token_id' => $cloudflareToken->id])->saveQuietly();
$this->bundle = app(ServerTransferExporter::class)->export($this->server->fresh());
});
test('every model has a server transfer decision', function () {
$models = collect(glob(app_path('Models/*.php')))
->map(fn (string $file) => 'App\\Models\\'.basename($file, '.php'))
->filter(fn (string $class) => is_subclass_of($class, Model::class) && ! (new ReflectionClass($class))->isAbstract());
$undecided = $models
->reject(fn (string $class) => array_key_exists($class, ServerTransferCoverage::TRANSFERRED)
|| array_key_exists($class, ServerTransferCoverage::NOT_TRANSFERRED))
->values()->all();
expect($undecided)->toBe([], 'Add these models to the server transfer (ServerTransferExporter/Importer and ServerTransferCoverage::TRANSFERRED), or to ServerTransferCoverage::NOT_TRANSFERRED with a reason.');
});
test('every column of a transferred model is in the bundle or has a reason why not', function (string $modelClass) {
['keys' => $exported, 'records' => $records] = transferCoverageBundleKeys($this->bundle, ServerTransferCoverage::TRANSFERRED[$modelClass], $modelClass);
expect($records)->toBeGreaterThan(0, "The coverage fixture exports no {$modelClass}. Add a record of it to the beforeEach of this test.");
$notExported = [...ServerTransferCoverage::ALWAYS_NOT_EXPORTED, ...array_keys(ServerTransferCoverage::NOT_EXPORTED_COLUMNS[$modelClass] ?? [])];
$columns = Schema::getColumnListing((new $modelClass)->getTable());
expect(array_values(array_diff($columns, $exported, $notExported)))
->toBe([], "These {$modelClass} columns are not in the transfer bundle. Export and import them (with a round-trip test), or add them to ServerTransferCoverage::NOT_EXPORTED_COLUMNS with a reason.")
->and(array_values(array_intersect(array_keys(ServerTransferCoverage::NOT_EXPORTED_COLUMNS[$modelClass] ?? []), $exported)))
->toBe([], "These {$modelClass} columns are exported, so remove them from ServerTransferCoverage::NOT_EXPORTED_COLUMNS.")
->and(array_values(array_diff(array_keys(ServerTransferCoverage::NOT_EXPORTED_COLUMNS[$modelClass] ?? []), $columns)))
->toBe([], "These {$modelClass} columns no longer exist, so remove them from ServerTransferCoverage::NOT_EXPORTED_COLUMNS.");
})->with(array_keys(ServerTransferCoverage::TRANSFERRED));
@@ -0,0 +1,250 @@
<?php
use App\Jobs\ReleaseManagedDnsRecordsJob;
use App\Jobs\ValidateAndInstallServerJob;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\DnsProviderZone;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\IntegrationToken;
use App\Models\ManagedDnsRecord;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Services\ServerTransfer\ServerTransferExporter;
use App\Services\ServerTransfer\ServerTransferImporter;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
Process::fake();
Storage::fake('ssh-keys');
// Deleting the source resources must not release their DNS records at the provider.
Queue::fake([ValidateAndInstallServerJob::class, ReleaseManagedDnsRecordsJob::class]);
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true, 'fqdn' => 'https://coolify-a.test']);
config(['app.id' => 'source-instance']);
$this->team = Team::factory()->create();
$privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
$this->server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $privateKey->id, 'ip' => '10.66.0.10']);
$destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = $project->environments()->first() ?? Environment::factory()->create(['project_id' => $project->id]);
$onDestination = ['environment_id' => $this->environment->id, 'destination_id' => $destination->id, 'destination_type' => $destination->getMorphClass()];
$this->application = Application::factory()->create([...$onDestination, 'name' => 'web', 'fqdn' => 'https://app.example.com']);
$this->preview = new ApplicationPreview;
$this->preview->forceFill(['application_id' => $this->application->id, 'pull_request_id' => 3, 'pull_request_html_url' => '', 'fqdn' => 'https://pr-3.example.com']);
$this->preview->uuid = 'dns-transfer-preview';
$this->preview->saveQuietly();
$this->service = Service::factory()->create([...$onDestination, 'server_id' => $this->server->id]);
$this->serviceApplication = new ServiceApplication;
$this->serviceApplication->forceFill(['service_id' => $this->service->id, 'name' => 'whoami', 'fqdn' => 'https://svc.example.com']);
$this->serviceApplication->uuid = 'dns-transfer-service-app';
$this->serviceApplication->save();
$this->cloudflareToken = IntegrationToken::factory()->create([
'team_id' => $this->team->id, 'provider' => 'cloudflare', 'token' => 'cf-api-token', 'capabilities' => ['dns', 'tunnel'],
]);
$this->zone = DnsProviderZone::factory()->create(['integration_token_id' => $this->cloudflareToken->id, 'name' => 'example.com', 'provider_zone_id' => 'zone-1']);
$this->exporter = app(ServerTransferExporter::class);
$this->importer = app(ServerTransferImporter::class);
});
/**
* @param list<Model> $resources
*/
function dnsTransferRecord(object $test, string $name, array $resources, bool $owned = true): ManagedDnsRecord
{
$record = ManagedDnsRecord::factory()->create([
'dns_provider_zone_id' => $test->zone->id, 'provider_record_id' => 'cf-'.$name,
'type' => 'A', 'name' => $name, 'content' => '10.66.0.10', 'owned' => $owned,
]);
foreach ($resources as $resource) {
$record->addReference($resource);
}
return $record;
}
/**
* The source rows are removed so the import can use the same UUIDs. The source forgets the DNS records at transfer.
*/
function removeDnsTransferSource(object $test): void
{
ManagedDnsRecord::query()->delete();
ApplicationPreview::withoutEvents(fn () => ApplicationPreview::withTrashed()->get()->each->forceDelete());
ServiceApplication::withTrashed()->get()->each->forceDelete();
$test->service->forceDelete();
$test->application->forceDelete();
$test->server->forceDelete();
}
test('integration tokens move with the transfer and a token with the same uuid in the team is reused', function () {
$secretsToken = IntegrationToken::factory()->create([
'team_id' => $this->team->id, 'provider' => 'infisical', 'token' => 'infisical-secret', 'capabilities' => ['secrets'], 'metadata' => ['client_id' => 'client'],
]);
$this->application->secretManagerLink()->create(['integration_token_id' => $secretsToken->id, 'settings' => ['project_id' => 'p1', 'environment' => 'prod']]);
$this->service->secretManagerLink()->create(['integration_token_id' => $secretsToken->id, 'settings' => ['project_id' => 'p2', 'environment' => 'dev']]);
$this->server->settings->forceFill(['cloudflare_tunnel_integration_token_id' => $this->cloudflareToken->id])->saveQuietly();
$bundle = $this->exporter->export($this->server->fresh());
expect(collect($bundle['integration_tokens'])->pluck('token', 'uuid')->all())
->toEqualCanonicalizing([$secretsToken->uuid => 'infisical-secret', $this->cloudflareToken->uuid => 'cf-api-token'])
->and($bundle['server']['settings']['cloudflare_tunnel_integration_token_uuid'])->toBe($this->cloudflareToken->uuid)
->and($bundle['server']['settings'])->not->toHaveKey('cloudflare_tunnel_integration_token_id')
->and(implode(' ', $bundle['warnings']))->not->toContain('secret manager')
->and(implode(' ', $bundle['warnings']))->not->toContain('Cloudflare API token');
removeDnsTransferSource($this);
$result = $this->importer->import($bundle, teamId: $this->team->id);
$application = Application::where('uuid', $this->application->uuid)->firstOrFail();
$service = Service::where('uuid', $this->service->uuid)->firstOrFail();
expect(IntegrationToken::count())->toBe(2)
->and($result['created']['integration_tokens'])->toBe(2)
->and($application->secretManagerLink->integration_token_id)->toBe($secretsToken->id)
->and($application->secretManagerLink->settings)->toBe(['project_id' => 'p1', 'environment' => 'prod'])
->and($service->secretManagerLink->integration_token_id)->toBe($secretsToken->id)
->and($service->secretManagerLink->settings)->toBe(['project_id' => 'p2', 'environment' => 'dev'])
->and(Server::where('uuid', $result['server_uuid'])->firstOrFail()->settings->cloudflare_tunnel_integration_token_id)->toBe($this->cloudflareToken->id);
});
test('integration tokens get a new uuid in the target team when another team has the uuid', function () {
$secretsToken = IntegrationToken::factory()->create(['team_id' => $this->team->id, 'provider' => 'doppler', 'token' => 'dp.st.secret', 'capabilities' => ['secrets']]);
$this->application->secretManagerLink()->create(['integration_token_id' => $secretsToken->id, 'settings' => ['project' => 'web', 'config' => 'prd']]);
$this->server->settings->forceFill(['cloudflare_tunnel_integration_token_id' => $this->cloudflareToken->id])->saveQuietly();
$bundle = $this->exporter->export($this->server->fresh());
$targetTeam = Team::factory()->create();
removeDnsTransferSource($this);
$result = $this->importer->import($bundle, teamId: $targetTeam->id);
$targetTokens = IntegrationToken::where('team_id', $targetTeam->id)->get()->keyBy('provider');
$link = Application::where('uuid', $this->application->uuid)->firstOrFail()->secretManagerLink;
expect($targetTokens)->toHaveCount(2)
->and($targetTokens['doppler']->uuid)->not->toBe($secretsToken->uuid)
->and($targetTokens['doppler']->token)->toBe('dp.st.secret')
->and($targetTokens['cloudflare']->uuid)->not->toBe($this->cloudflareToken->uuid)
->and($link->integration_token_id)->toBe($targetTokens['doppler']->id)
->and(Server::where('uuid', $result['server_uuid'])->firstOrFail()->settings->cloudflare_tunnel_integration_token_id)->toBe($targetTokens['cloudflare']->id)
->and($secretsToken->fresh()->team_id)->toBe($this->team->id);
});
test('managed DNS records, their zone and references move with the resources', function () {
Http::fake();
$appRecord = dnsTransferRecord($this, 'app.example.com', [$this->application]);
dnsTransferRecord($this, 'pr-3.example.com', [$this->preview], owned: false);
$serviceRecord = dnsTransferRecord($this, 'svc.example.com', [$this->serviceApplication]);
$bundle = $this->exporter->export($this->server->fresh());
expect($bundle['dns_zones'])->toHaveCount(1)
->and($bundle['integration_tokens'][0]['uuid'])->toBe($this->cloudflareToken->uuid)
->and(collect($bundle['managed_dns_records'])->pluck('references', 'name')->all())->toBe([
'app.example.com' => [['kind' => 'application', 'uuid' => $this->application->uuid]],
'pr-3.example.com' => [['kind' => 'preview', 'uuid' => 'dns-transfer-preview']],
'svc.example.com' => [['kind' => 'service_application', 'uuid' => 'dns-transfer-service-app']],
])
->and($bundle['managed_dns_records'][0]['ownership_comment'])->toBe($appRecord->ownershipComment())
->and(implode(' ', $bundle['warnings']))->not->toContain('DNS record');
removeDnsTransferSource($this);
$result = $this->importer->import($bundle, teamId: $this->team->id);
$records = ManagedDnsRecord::with('references.resource')->get()->keyBy('name');
expect($result['created']['managed_dns_records'])->toBe(3)
->and(DnsProviderZone::count())->toBe(1)
->and($records)->toHaveCount(3)
->and($records['app.example.com']->uuid)->toBe($appRecord->uuid)
->and($records['app.example.com']->dns_provider_zone_id)->toBe($this->zone->id)
->and($records['app.example.com']->integration_token_id)->toBe($this->cloudflareToken->id)
->and($records['app.example.com']->owned)->toBeTrue()
->and($records['pr-3.example.com']->owned)->toBeFalse()
->and($records['svc.example.com']->uuid)->toBe($serviceRecord->uuid)
->and($records['app.example.com']->references->sole()->resource->uuid)->toBe($this->application->uuid)
->and($records['pr-3.example.com']->references->sole()->resource)->toBeInstanceOf(ApplicationPreview::class)
->and($records['pr-3.example.com']->references->sole()->resource->uuid)->toBe('dns-transfer-preview')
->and($records['svc.example.com']->references->sole()->resource)->toBeInstanceOf(ServiceApplication::class)
->and($records['svc.example.com']->references->sole()->resource->uuid)->toBe('dns-transfer-service-app');
// Same instance id and UUID: the provider comment already proves ownership here.
Http::assertNothingSent();
});
test('a DNS record that a resource staying on the source also uses is not exported', function () {
$otherServer = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->server->private_key_id, 'ip' => '10.66.0.11']);
$otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->firstOrFail();
$staying = Application::factory()->create([
'environment_id' => $this->environment->id, 'destination_id' => $otherDestination->id,
'destination_type' => $otherDestination->getMorphClass(), 'fqdn' => 'https://shared.example.com',
]);
dnsTransferRecord($this, 'shared.example.com', [$this->application, $staying]);
$bundle = $this->exporter->export($this->server->fresh());
expect($bundle['managed_dns_records'])->toBe([])
->and($bundle['dns_zones'])->toBe([])
->and($bundle['integration_tokens'])->toBe([])
->and(implode(' ', $bundle['warnings']))->toContain('DNS record shared.example.com stays managed by this instance');
});
test('the target takes ownership of an owned record when the provider still has the source comment', function () {
$record = dnsTransferRecord($this, 'app.example.com', [$this->application]);
dnsTransferRecord($this, 'pr-3.example.com', [$this->preview], owned: false);
$sourceComment = $record->ownershipComment();
$bundle = $this->exporter->export($this->server->fresh());
removeDnsTransferSource($this);
$state = fakeCloudflareDns([
['id' => 'cf-app.example.com', 'type' => 'A', 'name' => 'app.example.com', 'content' => '10.66.0.10', 'comment' => $sourceComment],
['id' => 'cf-pr-3.example.com', 'type' => 'A', 'name' => 'pr-3.example.com', 'content' => '10.66.0.10', 'comment' => null],
]);
config(['app.id' => 'target-instance']);
$result = $this->importer->import($bundle, teamId: $this->team->id);
$targetComment = ManagedDnsRecord::ownershipCommentFor($record->uuid);
expect($targetComment)->not->toBe($sourceComment)
->and(sentDnsRequests('PATCH'))->toBe(1)
->and(Http::recorded(fn (Request $request) => $request->method() === 'PATCH')->sole()[0]->data())->toBe(['comment' => $targetComment])
->and($state['records']['cf-app.example.com']['comment'])->toBe($targetComment)
->and(ManagedDnsRecord::where('name', 'app.example.com')->sole()->owned)->toBeTrue()
->and(ManagedDnsRecord::where('name', 'pr-3.example.com')->sole()->owned)->toBeFalse()
->and(implode(' ', $result['warnings']))->not->toContain('could not take ownership');
});
test('the target does not own a record it cannot adopt and the import still succeeds', function (Closure $fakeProvider) {
$record = dnsTransferRecord($this, 'app.example.com', [$this->application]);
$bundle = $this->exporter->export($this->server->fresh());
removeDnsTransferSource($this);
$fakeProvider($record);
config(['app.id' => 'target-instance']);
$result = $this->importer->import($bundle, teamId: $this->team->id);
expect(sentDnsRequests('PATCH'))->toBe(0)
->and(Server::where('uuid', $result['server_uuid'])->exists())->toBeTrue()
->and(ManagedDnsRecord::where('name', 'app.example.com')->sole()->owned)->toBeFalse()
->and($result['warnings'])->toContain('DNS record app.example.com: this instance could not take ownership; it will not change or delete it.');
})->with([
'the provider comment changed' => [fn (ManagedDnsRecord $record) => fakeCloudflareDns([
['id' => 'cf-app.example.com', 'type' => 'A', 'name' => 'app.example.com', 'content' => '10.66.0.10', 'comment' => 'edited by hand'],
])],
'the provider cannot be reached' => [fn (ManagedDnsRecord $record) => Http::fake(fn () => throw new ConnectionException('Connection refused'))],
]);
@@ -1,17 +1,22 @@
<?php
use App\Jobs\ValidateAndInstallServerJob;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\Environment;
use App\Models\EnvironmentVariable;
use App\Models\GithubApp;
use App\Models\GithubRunnerConfig;
use App\Models\GitlabApp;
use App\Models\InstanceSettings;
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;
@@ -25,17 +30,28 @@ use App\Models\StandalonePostgresql;
use App\Models\StandaloneSqlite;
use App\Models\Tag;
use App\Models\Team;
use App\Models\User;
use App\Services\ServerTransfer\ServerTransferBundle;
use App\Services\ServerTransfer\ServerTransferClaimer;
use App\Services\ServerTransfer\ServerTransferExporter;
use App\Services\ServerTransfer\ServerTransferImporter;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\ValidationException;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
// A claim writes the owner marker on the host over SSH.
Process::fake();
Storage::fake('ssh-keys');
// An import validates the claimed server over SSH; the sync test queue would run it.
Queue::fake([ValidateAndInstallServerJob::class]);
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true, 'fqdn' => 'https://coolify-a.test']);
$this->team = Team::factory()->create();
@@ -324,12 +340,98 @@ test('round trip import preserves uuids secrets and related resources after sour
->and($shared->value)->toBe('shared-secret');
});
test('import validates the claimed server in the background, but a dry run does not', function () {
$bundle = $this->exporter->export($this->server);
$serverUuid = $this->server->uuid;
$this->importer->import($bundle, teamId: $this->team->id, dryRun: true);
Queue::assertNotPushed(ValidateAndInstallServerJob::class);
$this->service->forceDelete();
$this->application->forceDelete();
$this->database->forceDelete();
$this->server->forceDelete();
$result = $this->importer->import($bundle, teamId: $this->team->id);
expect($result['claimed'])->toBeTrue();
Queue::assertPushed(ValidateAndInstallServerJob::class, 1);
Queue::assertPushed(ValidateAndInstallServerJob::class, fn (ValidateAndInstallServerJob $job) => $job->server->uuid === $serverUuid);
});
test('import fails when server ip already exists', function () {
$bundle = $this->exporter->export($this->server);
$this->importer->import($bundle, teamId: $this->team->id);
})->throws(ValidationException::class);
test('the Cloudflare tunnel token moves with the server by uuid and the raw token id of an old bundle is ignored', function () {
$token = IntegrationToken::factory()->for($this->team)->create(['provider' => 'cloudflare', 'token' => 'cf-tunnel-token', 'capabilities' => ['dns', 'tunnel']]);
$this->server->settings->forceFill(['cloudflare_tunnel_integration_token_id' => $token->id])->saveQuietly();
$bundle = $this->exporter->export($this->server);
expect(data_get($bundle, 'server.settings'))->not->toHaveKey('cloudflare_tunnel_integration_token_id')
->and(data_get($bundle, 'server.settings.cloudflare_tunnel_integration_token_uuid'))->toBe($token->uuid)
->and(data_get($bundle, 'integration_tokens.0.token'))->toBe('cf-tunnel-token')
->and(implode(' ', $bundle['warnings']))->not->toContain('Cloudflare API token');
$bundle['server']['settings']['cloudflare_tunnel_integration_token_id'] = 999999;
$this->service->forceDelete();
$this->application->forceDelete();
$this->database->forceDelete();
$this->server->forceDelete();
$token->delete();
$result = $this->importer->import($bundle, teamId: $this->team->id);
$imported = IntegrationToken::where('uuid', $token->uuid)->firstOrFail();
expect(Server::where('uuid', $result['server_uuid'])->first()->settings->cloudflare_tunnel_integration_token_id)->toBe($imported->id)
->and($imported->team_id)->toBe($this->team->id)
->and($imported->token)->toBe('cf-tunnel-token');
});
test('a transfer back replaces the archive of the same team in the import transaction', function () {
$bundle = $this->exporter->export($this->server);
$bundle['server']['name'] = 'changed-on-the-other-instance';
app(ServerTransferClaimer::class)->markTransferred($this->server, targetInstanceUrl: 'https://coolify-b.test');
$archiveId = $this->server->id;
expect(collect($this->importer->importChecks($this->server->uuid, $this->server->ip, $this->team->id))->every(fn ($check) => $check['ok']))->toBeTrue();
$result = $this->importer->import($bundle, teamId: $this->team->id);
$server = Server::where('uuid', $this->server->uuid)->firstOrFail();
expect($result['claimed'])->toBeTrue()
->and($server->id)->not->toBe($archiveId)
->and($server->name)->toBe('changed-on-the-other-instance')
->and($server->isTransferredAway())->toBeFalse()
->and(Server::withTrashed()->find($archiveId))->toBeNull()
->and(Application::where('uuid', $this->application->uuid)->firstOrFail()->destination->server_id)->toBe($server->id);
Process::assertDidntRun(fn ($process) => str_contains($process->command, 'docker rm') || str_contains($process->command, 'docker stop'));
});
test('a failed transfer back keeps the archive', function () {
$bundle = $this->exporter->export($this->server);
$bundle['projects'][0]['environments'][0]['databases'][] = [
'type' => 'NotARealDatabaseType', 'uuid' => 'broken-db-uuid', 'attributes' => ['name' => 'broken'], 'destination_uuid' => $this->destination->uuid,
];
app(ServerTransferClaimer::class)->markTransferred($this->server, targetInstanceUrl: 'https://coolify-b.test');
expect(fn () => $this->importer->import($bundle, teamId: $this->team->id))->toThrow(RuntimeException::class, 'Unsupported database type');
expect($this->server->fresh()?->isTransferredAway())->toBeTrue()
->and(Application::where('uuid', $this->application->uuid)->exists())->toBeTrue();
});
test('the archive of another team is not replaced', function () {
$bundle = $this->exporter->export($this->server);
app(ServerTransferClaimer::class)->markTransferred($this->server, targetInstanceUrl: 'https://coolify-b.test');
$otherTeam = Team::factory()->create();
expect(fn () => $this->importer->import($bundle, teamId: $otherTeam->id))
->toThrow(ValidationException::class, 'Another team of this instance has the archived copy');
});
test('import rolls back all created rows when a later resource fails', function () {
$bundle = $this->exporter->export($this->server);
$originalServerUuid = $this->server->uuid;
@@ -370,6 +472,7 @@ test('import reuses existing private key fingerprint on same team', function ()
$originalKeyId = $this->privateKey->id;
// Free the IP without deleting the key.
$this->service->forceDelete();
$this->application->forceDelete();
$this->database->forceDelete();
$this->server->forceDelete();
@@ -386,6 +489,7 @@ test('import into another team creates its own key when the same key exists in a
$targetTeam = Team::factory()->create();
// Free the IP without deleting the source team's key.
$this->service->forceDelete();
$this->application->forceDelete();
$this->database->forceDelete();
$this->server->forceDelete();
@@ -400,21 +504,6 @@ test('import into another team creates its own key when the same key exists in a
->and(PrivateKey::whereKey($sourceKeyId)->where('team_id', $this->team->id)->exists())->toBeTrue();
});
test('encrypted export decrypts for import', function () {
$bundle = $this->exporter->export($this->server);
$encrypted = ServerTransferBundle::encryptWithPassphrase($bundle, 'transfer-pass');
$this->application->forceDelete();
$this->database->forceDelete();
$this->server->forceDelete();
$this->privateKey->delete();
$plain = ServerTransferBundle::decryptWithPassphrase($encrypted, 'transfer-pass');
$result = $this->importer->import($plain, teamId: $this->team->id);
expect(Server::where('uuid', $result['server_uuid'])->exists())->toBeTrue();
});
test('system-wide github apps are not exported and re-link on import by uuid', function () {
$systemWide = new GithubApp;
$systemWide->forceFill([
@@ -990,7 +1079,7 @@ test('transfer keeps the link between an application volume and its sqlite datab
ScheduledTask::query()->delete();
$this->privateKey->delete();
$this->importer->import($bundle, teamId: $this->team->id, dryRun: false, preserveUuids: false, adoptMode: true);
$this->importer->import($bundle, teamId: $this->team->id, dryRun: false, preserveUuids: false, adoptMode: false);
$importedSqlite = StandaloneSqlite::where('name', 'app-sqlite')->sole();
$connectedVolume = Application::where('name', $originalAppName)->sole()
@@ -1002,23 +1091,22 @@ test('transfer keeps the link between an application volume and its sqlite datab
->and($importedSqlite->persistentStorages()->sole()->isSharedWithAnotherResource())->toBeTrue();
});
test('export warns about secret manager links without exporting the integration token', function () {
test('secret manager links of applications and databases move with their integration token', function () {
$token = IntegrationToken::factory()->create([
'team_id' => $this->team->id,
'provider' => 'vault',
'token' => 'vault-root-token',
'capabilities' => ['secrets'],
]);
$this->application->secretManagerLink()->create(['integration_token_id' => $token->id]);
$this->database->secretManagerLink()->create(['integration_token_id' => $token->id]);
$this->application->secretManagerLink()->create(['integration_token_id' => $token->id, 'settings' => ['mount' => 'secret', 'path' => 'app']]);
$this->database->secretManagerLink()->create(['integration_token_id' => $token->id, 'settings' => ['mount' => 'kv', 'path' => 'db']]);
$bundle = $this->exporter->export($this->server);
$warning = collect($bundle['warnings'])->first(fn (string $warning) => str_contains($warning, 'secret manager'));
expect($warning)->not->toBeNull()
->and($warning)->toContain('my-app')
->and($warning)->toContain('app-db')
->and(json_encode($bundle))->not->toContain('vault-root-token');
expect(collect($bundle['warnings'])->filter(fn (string $warning) => str_contains($warning, 'secret manager')))->toBeEmpty()
->and($bundle['integration_tokens'])->toHaveCount(1)
->and($bundle['integration_tokens'][0]['token'])->toBe('vault-root-token')
->and(data_get($bundle, 'projects.0.environments.0.databases.0.secret_manager_link.integration_token_uuid'))->toBe($token->uuid);
$this->service->forceDelete();
$this->application->forceDelete();
@@ -1028,10 +1116,18 @@ test('export warns about secret manager links without exporting the integration
ScheduledDatabaseBackup::query()->delete();
ScheduledTask::query()->delete();
$this->privateKey->delete();
$token->delete();
$result = $this->importer->import($bundle, teamId: $this->team->id, dryRun: false, preserveUuids: true, adoptMode: true);
$this->importer->import($bundle, teamId: $this->team->id, dryRun: false, preserveUuids: true, adoptMode: true);
expect($result['warnings'])->toContain($warning);
$imported = IntegrationToken::where('uuid', $token->uuid)->where('team_id', $this->team->id)->firstOrFail();
$application = Application::where('uuid', $this->application->uuid)->firstOrFail();
$database = StandalonePostgresql::where('uuid', $this->database->uuid)->firstOrFail();
expect($imported->token)->toBe('vault-root-token')
->and($application->secretManagerLink->integration_token_id)->toBe($imported->id)
->and($application->secretManagerLink->settings)->toBe(['mount' => 'secret', 'path' => 'app'])
->and($database->secretManagerLink->integration_token_id)->toBe($imported->id)
->and($database->secretManagerLink->settings)->toBe(['mount' => 'kv', 'path' => 'db']);
});
test('transfer keeps the Traefik certificate resolver of applications and service applications', function () {
@@ -1071,3 +1167,648 @@ test('transfer keeps the Traefik certificate resolver of applications and servic
expect(Application::where('uuid', $originalAppUuid)->firstOrFail()->settings->traefik_certificate_resolver)->toBe('none')
->and(ServiceApplication::where('uuid', 'svc-app-resolver')->firstOrFail()->traefik_certificate_resolver)->toBe('cloudflare');
});
test('transfer keeps the cloud provider instance id of the server', function () {
$this->server->forceFill(['hetzner_server_id' => 4242, 'vultr_instance_id' => 'vultr-7'])->save();
$bundle = $this->exporter->export($this->server->fresh());
$this->service->forceDelete();
$this->application->forceDelete();
$this->database->forceDelete();
$this->server->forceDelete();
$result = $this->importer->import($bundle, teamId: $this->team->id);
$server = Server::where('uuid', $result['server_uuid'])->sole();
expect((int) $server->hetzner_server_id)->toBe(4242)
->and($server->vultr_instance_id)->toBe('vultr-7');
});
test('adopt mode stops the import when a resource uuid is already used', function () {
$bundle = $this->exporter->export($this->server);
$appUuid = $this->application->uuid;
// The server is gone, but the application row still holds its UUID on this instance.
$this->service->forceDelete();
$this->database->forceDelete();
$this->server->forceDelete();
expect(fn () => $this->importer->import($bundle, teamId: $this->team->id, adoptMode: true))
->toThrow(ValidationException::class, "The application UUID {$appUuid} is already used");
expect(Server::where('uuid', $bundle['server']['uuid'])->exists())->toBeFalse();
});
test('import rejects a database model class that is not a supported database type', function () {
$bundle = $this->exporter->export($this->server);
$bundle['projects'][0]['environments'][0]['databases'][] = [
'type' => 'Unknown',
'model' => Application::class,
'uuid' => 'not-a-database',
'attributes' => ['name' => 'not-a-database'],
'destination_uuid' => $this->destination->uuid,
];
$this->service->forceDelete();
$this->application->forceDelete();
$this->database->forceDelete();
$this->server->forceDelete();
expect(fn () => $this->importer->import($bundle, teamId: $this->team->id))
->toThrow(RuntimeException::class, 'Unsupported database type');
expect(Application::where('uuid', 'not-a-database')->exists())->toBeFalse();
});
test('import rejects unsafe server connection fields', function (string $field, mixed $value) {
$bundle = $this->exporter->export($this->server);
$bundle['server'][$field] = $value;
$this->service->forceDelete();
$this->application->forceDelete();
$this->database->forceDelete();
$this->server->forceDelete();
expect(fn () => $this->importer->import($bundle, teamId: $this->team->id))
->toThrow(ValidationException::class);
})->with([
'ip with shell characters' => ['ip', '10.0.0.1;reboot'],
'user with shell characters' => ['user', 'root$(id)'],
'port out of range' => ['port', 70000],
]);
test('export refuses credentials of another team', function () {
$otherKey = PrivateKey::factory()->create(['team_id' => Team::factory()->create()->id]);
$this->application->forceFill(['private_key_id' => $otherKey->id])->save();
expect(fn () => $this->exporter->export($this->server))
->toThrow(RuntimeException::class, 'does not belong to the server team');
});
test('transfer keeps maintenance mode, the server error page and swarm cluster, and environment variable flags', function () {
$this->application->forceFill(['is_maintenance_enabled' => true, 'maintenance_page' => '<h1>App down</h1>'])->saveQuietly();
$this->service->forceFill(['is_maintenance_enabled' => true, 'maintenance_page' => '<h1>Service down</h1>'])->saveQuietly();
$this->server->forceFill(['proxy_error_page' => '<h1>Proxy error</h1>', 'swarm_cluster' => 7])->saveQuietly();
EnvironmentVariable::withoutEvents(function () {
$env = new EnvironmentVariable;
$env->forceFill([
'key' => 'COMPOSE_NULL',
'value' => null,
'preserve_compose_null' => true,
'version' => '4.0.0-beta.400',
'resourceable_type' => Application::class,
'resourceable_id' => $this->application->id,
]);
$env->uuid = new_public_id();
$env->save();
});
$appUuid = $this->application->uuid;
$serviceUuid = $this->service->uuid;
$bundle = $this->exporter->export($this->server->fresh());
$this->service->forceDelete();
$this->application->forceDelete();
$this->database->forceDelete();
$this->server->forceDelete();
$result = $this->importer->import($bundle, teamId: $this->team->id);
$server = Server::where('uuid', $result['server_uuid'])->sole();
$application = Application::where('uuid', $appUuid)->sole();
$service = Service::where('uuid', $serviceUuid)->sole();
$variable = $application->environment_variables()->where('key', 'COMPOSE_NULL')->sole();
expect($application->is_maintenance_enabled)->toBeTrue()
->and($application->maintenance_page)->toBe('<h1>App down</h1>')
->and($service->is_maintenance_enabled)->toBeTrue()
->and($service->maintenance_page)->toBe('<h1>Service down</h1>')
->and($server->proxy_error_page)->toBe('<h1>Proxy error</h1>')
->and((int) $server->swarm_cluster)->toBe(7)
->and($variable->preserve_compose_null)->toBeTrue()
->and($variable->value)->toBeNull()
->and($variable->version)->toBe('4.0.0-beta.400');
});
test('transfer keeps preview, backup, GitHub App runner fields and canvas positions when uuids change', function () {
$githubApp = new GithubApp;
$githubApp->forceFill([
'name' => 'Runner App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'team_id' => $this->team->id,
'organization' => 'acme',
'organization_self_hosted_runners' => 'write',
'actions' => 'read',
'webhook_events' => ['workflow_job', 'push'],
'runner_group_id' => 12,
]);
$githubApp->uuid = 'runner-github-app';
$githubApp->save();
$this->application->forceFill(['source_type' => GithubApp::class, 'source_id' => $githubApp->id])->saveQuietly();
$preview = new ApplicationPreview;
$preview->forceFill([
'application_id' => $this->application->id,
'pull_request_id' => 7,
'pull_request_html_url' => 'https://github.com/example/app/pull/7',
'fqdn' => 'https://pr-7.app.example.com',
'domain_port_overrides' => ['https://pr-7.app.example.com' => 8080],
'max_restart_count' => 5,
]);
$preview->uuid = 'preview-pr-7';
$preview->saveQuietly();
$this->backup->update(['missing_backup_notification_days' => 3]);
ScheduledVolumeBackup::create([
'backupable_type' => LocalPersistentVolume::class,
'backupable_id' => $this->application->persistentStorages()->sole()->id,
'team_id' => $this->team->id,
'frequency' => '0 4 * * *',
'missing_backup_notification_days' => 2,
]);
$this->environment->forceFill(['canvas_positions' => [
$this->application->uuid => ['x' => 10, 'y' => -20],
$this->database->uuid => ['x' => 300, 'y' => 40],
'resource-on-another-server' => ['x' => 1, 'y' => 1],
]])->save();
$bundle = $this->exporter->export($this->server);
expect($bundle['projects'][0]['environments'][0]['canvas_positions'])->toHaveCount(2);
ApplicationPreview::withoutEvents(fn () => ApplicationPreview::withTrashed()->get()->each->forceDelete());
DB::table('scheduled_volume_backups')->delete();
DB::table('scheduled_database_backups')->delete();
$this->service->forceDelete();
$this->application->forceDelete();
$this->database->forceDelete();
$this->server->forceDelete();
GithubApp::where('uuid', 'runner-github-app')->delete();
$this->importer->import($bundle, teamId: $this->team->id, preserveUuids: false, adoptMode: false);
$application = Application::where('name', 'my-app')->sole();
$database = StandalonePostgresql::where('name', 'app-db')->sole();
$importedPreview = $application->previews()->sole();
$importedGithubApp = $application->source;
expect($importedPreview->domain_port_overrides)->toBe(['https://pr-7.app.example.com' => 8080])
->and($importedPreview->max_restart_count)->toBe(5)
->and(ScheduledDatabaseBackup::where('database_id', $database->id)->sole()->missing_backup_notification_days)->toBe(3)
->and(ScheduledVolumeBackup::sole()->missing_backup_notification_days)->toBe(2)
->and($importedGithubApp)->toBeInstanceOf(GithubApp::class)
->and($importedGithubApp->organization_self_hosted_runners)->toBe('write')
->and($importedGithubApp->actions)->toBe('read')
->and($importedGithubApp->webhook_events)->toBe(['workflow_job', 'push'])
->and($importedGithubApp->runner_group_id)->toBe(12)
->and($application->uuid)->not->toBe($this->application->uuid)
->and($application->environment->canvas_positions)->toBe([
$application->uuid => ['x' => 10, 'y' => -20],
$database->uuid => ['x' => 300, 'y' => 40],
]);
});
test('transfer creates the referenced team shared variables in the target team', function () {
$this->application->environment_variables()->where('key', 'APP_SECRET')->sole()->update(['value' => 'Bearer {{team.API_TOKEN}}']);
EnvironmentVariable::withoutEvents(function () {
$env = new EnvironmentVariable;
$env->forceFill([
'key' => 'SERVICE_URL',
'value' => '{{ team.SERVICE_HOST }}/api',
'resourceable_type' => Service::class,
'resourceable_id' => $this->service->id,
]);
$env->uuid = new_public_id();
$env->save();
});
foreach (['API_TOKEN' => 'team-secret', 'SERVICE_HOST' => 'https://svc.example.com', 'UNUSED' => 'not-referenced'] as $key => $value) {
SharedEnvironmentVariable::create(['key' => $key, 'value' => $value, 'type' => 'team', 'team_id' => $this->team->id, 'is_literal' => true]);
}
$bundle = $this->exporter->export($this->server);
expect(collect($bundle['shared_environment_variables']['team'])->pluck('key')->sort()->values()->all())->toBe(['API_TOKEN', 'SERVICE_HOST']);
$this->service->forceDelete();
$this->application->forceDelete();
$this->database->forceDelete();
$this->server->forceDelete();
$targetTeam = Team::factory()->create();
$this->importer->import($bundle, teamId: $targetTeam->id);
$teamVariables = SharedEnvironmentVariable::where('type', 'team')->where('team_id', $targetTeam->id)->get()->keyBy('key');
expect($teamVariables->keys()->sort()->values()->all())->toBe(['API_TOKEN', 'SERVICE_HOST'])
->and($teamVariables['API_TOKEN']->value)->toBe('team-secret')
->and((bool) $teamVariables['API_TOKEN']->is_literal)->toBeTrue()
->and($teamVariables['SERVICE_HOST']->value)->toBe('https://svc.example.com');
});
test('transfer keeps the value of a team shared variable that already exists in the target team and warns', function () {
$this->application->environment_variables()->where('key', 'APP_SECRET')->sole()->update(['value' => '{{team.API_TOKEN}}']);
$variable = SharedEnvironmentVariable::create(['key' => 'API_TOKEN', 'value' => 'source-value', 'type' => 'team', 'team_id' => $this->team->id]);
$bundle = $this->exporter->export($this->server);
$variable->update(['value' => 'target-value']);
$this->service->forceDelete();
$this->application->forceDelete();
$this->database->forceDelete();
$this->server->forceDelete();
$result = $this->importer->import($bundle, teamId: $this->team->id);
expect(SharedEnvironmentVariable::where('type', 'team')->where('team_id', $this->team->id)->sole()->value)->toBe('target-value')
->and(collect($result['warnings'])->filter(fn (string $warning) => str_contains($warning, 'API_TOKEN')))->toHaveCount(1);
});
test('import warns when the git source, private key or S3 storage of a resource cannot be found', function () {
$githubApp = new GithubApp;
$githubApp->forceFill([
'name' => 'Team GitHub App',
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'team_id' => $this->team->id,
]);
$githubApp->uuid = 'missing-github-app';
$githubApp->save();
$deployKey = PrivateKey::factory()->create([
'team_id' => $this->team->id,
'name' => 'deploy-key',
'private_key' => PrivateKey::generateNewKeyPair('ed25519')['private_key'],
]);
$this->application->forceFill([
'source_type' => GithubApp::class,
'source_id' => $githubApp->id,
'private_key_id' => $deployKey->id,
])->saveQuietly();
$storage = S3Storage::create([
'name' => 'Backups',
'region' => 'us-east-1',
'key' => 'key',
'secret' => 'secret',
'bucket' => 'bucket',
'endpoint' => 'https://93.184.216.34',
'team_id' => $this->team->id,
]);
$this->backup->update(['s3_storage_id' => $storage->id, 'save_s3' => true]);
$bundle = $this->exporter->export($this->server);
// The target instance cannot resolve these references.
$bundle['github_apps'] = [];
$bundle['s3_storages'] = [];
$bundle['private_keys'] = collect($bundle['private_keys'])->reject(fn (array $key) => $key['uuid'] === $deployKey->uuid)->values()->all();
$this->service->forceDelete();
$this->application->forceDelete();
$this->database->forceDelete();
$this->server->forceDelete();
$result = $this->importer->import($bundle, teamId: Team::factory()->create()->id);
expect($result['warnings'])->toContain('Application my-app: its GitHub App was not found on this instance; set the source again.')
->and($result['warnings'])->toContain('Application my-app: its private key was not found on this instance; select the private key again.')
->and($result['warnings'])->toContain('Backup of database app-db: S3 upload was turned off because its S3 storage was not found.');
});
test('export warns about DNS records of another team', function () {
$bundle = $this->exporter->export($this->server);
expect(implode(' ', $bundle['warnings']))->not->toContain('DNS record');
ManagedDnsRecord::factory()->create()->references()->create([
'resource_type' => $this->application->getMorphClass(),
'resource_id' => $this->application->id,
]);
$bundle = $this->exporter->export($this->server->fresh());
expect(implode(' ', $bundle['warnings']))->toContain('because its DNS provider token belongs to another team. The target instance does not manage it.');
});
/**
* Removes the source copies of the resources of beforeEach, so the target can import them with their uuids.
*/
function removeTransferSourceResources(object $test): void
{
$test->service->forceDelete();
$test->application->forceDelete();
$test->database->forceDelete();
$test->server->forceDelete();
}
function transferTestGithubApp(Team $team, string $uuid, string $name): GithubApp
{
$githubApp = new GithubApp;
$githubApp->forceFill([
'name' => $name,
'api_url' => 'https://api.github.com',
'html_url' => 'https://github.com',
'organization' => 'acme',
'app_id' => 4242,
'installation_id' => 4343,
'team_id' => $team->id,
'is_system_wide' => false,
'private_key_id' => PrivateKey::factory()->create([
'team_id' => $team->id,
'name' => "{$name} key",
'private_key' => PrivateKey::generateNewKeyPair('ed25519')['private_key'],
])->id,
]);
$githubApp->uuid = $uuid;
$githubApp->save();
return $githubApp;
}
test('canvas links between transferred resources move with their environment and the other links are left out', function () {
$user = User::factory()->create();
$otherServer = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id, 'ip' => '10.55.0.11']);
$otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->firstOrFail();
$appOnOtherServer = Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $otherDestination->id,
'destination_type' => $otherDestination->getMorphClass(),
'name' => 'stays-here',
]);
$link = fn (Model $source, Model $target, ?string $label) => ResourceLink::create([
'environment_id' => $this->environment->id,
'source_type' => $source->getMorphClass(),
'source_id' => $source->id,
'target_type' => $target->getMorphClass(),
'target_id' => $target->id,
'label' => $label,
'created_by' => $user->id,
]);
$appToDatabase = $link($this->application, $this->database, 'reads');
$link($this->service, $this->application, null);
$link($this->application, $appOnOtherServer, 'calls');
$bundle = $this->exporter->export($this->server);
expect($bundle['projects'][0]['environments'][0]['resource_links'])->toHaveCount(2)
->and($bundle['warnings'])->toContain('1 link between resources on the environment canvas is not transferred, because one of the linked resources stays on this instance.');
removeTransferSourceResources($this);
ResourceLink::query()->delete();
$targetTeam = Team::factory()->create();
$this->importer->import($bundle, teamId: $targetTeam->id);
$environment = Project::where('team_id', $targetTeam->id)->sole()->environments()->sole();
$application = Application::where('uuid', $this->application->uuid)->sole();
$database = StandalonePostgresql::where('uuid', $this->database->uuid)->sole();
$service = Service::where('uuid', $this->service->uuid)->sole();
$links = ResourceLink::where('environment_id', $environment->id)->orderBy('id')->get();
expect($links)->toHaveCount(2)
->and($links[0]->only(['uuid', 'source_type', 'source_id', 'target_type', 'target_id', 'label', 'created_by']))->toBe([
'uuid' => $appToDatabase->uuid,
'source_type' => $application->getMorphClass(),
'source_id' => $application->id,
'target_type' => $database->getMorphClass(),
'target_id' => $database->id,
'label' => 'reads',
'created_by' => null,
])
->and($links[1]->only(['source_type', 'source_id', 'target_type', 'target_id', 'label']))->toBe([
'source_type' => $service->getMorphClass(),
'source_id' => $service->id,
'target_type' => $application->getMorphClass(),
'target_id' => $application->id,
'label' => null,
]);
});
test('the project icon moves with the project into the icon storage of the target', function () {
Storage::fake('images');
$icon = "\xFF\xD8\xFF\xE0\x00\x10JFIF\x00transfer-icon";
Storage::disk('images')->put("project-icons/{$this->project->uuid}/icon.jpg", $icon);
$this->project->forceFill(['icon_path' => "project-icons/{$this->project->uuid}/icon.jpg", 'icon_storage_type' => 'local'])->saveQuietly();
$bundle = $this->exporter->export($this->server);
expect($bundle['projects'][0]['icon'])->toBe(['content_base64' => base64_encode($icon), 'extension' => 'jpg', 'mime' => 'image/jpeg']);
removeTransferSourceResources($this);
$targetTeam = Team::factory()->create();
$this->importer->import($bundle, teamId: $targetTeam->id);
$project = Project::where('team_id', $targetTeam->id)->sole();
expect($project->uuid)->not->toBe($this->project->uuid)
->and($project->only(['icon_path', 'icon_storage_type', 'icon_s3_storage_id']))->toBe([
'icon_path' => "project-icons/{$project->uuid}/icon.jpg",
'icon_storage_type' => 'local',
'icon_s3_storage_id' => null,
])
->and(Storage::disk('images')->get($project->icon_path))->toBe($icon);
});
test('a project icon that cannot be read or is too large is not exported and the export warns', function (?string $stored, string $warning) {
Storage::fake('images');
if ($stored !== null) {
Storage::disk('images')->put('project-icons/transfer/icon.jpg', $stored);
}
$this->project->forceFill(['icon_path' => 'project-icons/transfer/icon.jpg', 'icon_storage_type' => 'local'])->saveQuietly();
$bundle = $this->exporter->export($this->server);
expect($bundle['projects'][0]['icon'])->toBeNull()
->and($bundle['warnings'])->toContain($warning);
})->with([
'missing file' => [null, 'The icon of project Transfer Project cannot be read, so it is not transferred. Upload it again on the target instance.'],
'larger than 2 MB' => [str_repeat('x', ServerTransferExporter::MAX_PROJECT_ICON_BYTES + 1), 'The icon of project Transfer Project is larger than 2 MB, so it is not transferred. Upload it again on the target instance.'],
]);
test('import stores only a JPEG project icon and warns about other content', function () {
Storage::fake('images');
$bundle = $this->exporter->export($this->server);
$bundle['projects'][0]['icon'] = ['content_base64' => base64_encode('<svg onload="alert(1)"/>'), 'extension' => 'jpg', 'mime' => 'image/jpeg'];
removeTransferSourceResources($this);
$targetTeam = Team::factory()->create();
$result = $this->importer->import($bundle, teamId: $targetTeam->id);
expect(Project::where('team_id', $targetTeam->id)->sole()->icon_path)->toBeNull()
->and(Storage::disk('images')->allFiles())->toBe([])
->and($result['warnings'])->toContain('The icon of project Transfer Project was not imported, because it is not a JPEG image. Upload it again in the project settings.');
});
test('only environments with a resource of the server are exported', function () {
SharedEnvironmentVariable::create(['key' => 'PROJECT_KEY', 'value' => 'project', 'type' => 'project', 'project_id' => $this->project->id, 'team_id' => $this->team->id]);
SharedEnvironmentVariable::create(['key' => 'PRODUCTION_KEY', 'value' => 'production', 'type' => 'environment', 'environment_id' => $this->environment->id, 'team_id' => $this->team->id]);
$staging = Environment::factory()->create(['project_id' => $this->project->id, 'name' => 'staging']);
SharedEnvironmentVariable::create(['key' => 'STAGING_KEY', 'value' => 'staging', 'type' => 'environment', 'environment_id' => $staging->id, 'team_id' => $this->team->id]);
$otherServer = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id, 'ip' => '10.55.0.11']);
$otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->firstOrFail();
Application::factory()->create([
'environment_id' => $staging->id,
'destination_id' => $otherDestination->id,
'destination_type' => $otherDestination->getMorphClass(),
'name' => 'staging-app',
]);
$bundle = $this->exporter->export($this->server);
expect(collect($bundle['projects'][0]['environments'])->pluck('name')->all())->toBe([$this->environment->name])
->and(collect($bundle['projects'][0]['shared_environment_variables'])->pluck('key')->all())->toBe(['PROJECT_KEY'])
->and(collect($bundle['projects'][0]['environments'][0]['shared_environment_variables'])->pluck('key')->all())->toBe(['PRODUCTION_KEY']);
removeTransferSourceResources($this);
$targetTeam = Team::factory()->create();
$this->importer->import($bundle, teamId: $targetTeam->id);
$project = Project::where('team_id', $targetTeam->id)->sole();
expect($project->environments()->pluck('name')->all())->toBe([$this->environment->name])
->and(Application::where('name', 'staging-app')->count())->toBe(1)
->and(SharedEnvironmentVariable::where('key', 'STAGING_KEY')->count())->toBe(1);
});
test('the GitHub runner config moves disabled with its GitHub App and the bundle lists the manual steps', function () {
$githubApp = transferTestGithubApp($this->team, 'runner-only-app', 'Runner App');
$gitlabApp = new GitlabApp;
$gitlabApp->forceFill(['name' => 'Team GitLab', 'api_url' => 'https://gitlab.com/api/v4', 'html_url' => 'https://gitlab.com', 'team_id' => $this->team->id, 'is_system_wide' => false]);
$gitlabApp->save();
$this->application->forceFill(['source_type' => GitlabApp::class, 'source_id' => $gitlabApp->id])->saveQuietly();
$config = GithubRunnerConfig::create([
'server_id' => $this->server->id,
'github_app_id' => $githubApp->id,
'labels' => ['coolify', 'gpu'],
'is_enabled' => true,
'is_dedicated' => true,
'allow_pull_requests' => true,
'max_runners' => 4,
'docker_mode' => 'dind',
'runner_image' => 'ghcr.io/acme/runner:1',
'cpu_limit' => '1.5',
'memory_limit' => '2g',
'capacity_wait_timeout' => 30,
'idle_timeout' => 5,
'job_timeout' => 120,
]);
$bundle = $this->exporter->export($this->server);
expect(collect($bundle['github_apps'])->pluck('uuid')->all())->toBe(['runner-only-app'])
->and(collect($bundle['private_keys'])->pluck('uuid'))->toContain($githubApp->privateKey->uuid)
->and($bundle['github_runner_config']['github_app_uuid'])->toBe('runner-only-app')
->and($bundle['post_transfer_checklist'])->toBe([
'On GitHub, open the settings of GitHub App Runner App. 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.',
'In GitLab, open Settings > Webhooks of every project that uses GitLab App Team GitLab, and set the webhook URL to {target_url}/webhooks/source/gitlab/events.',
'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.',
])
->and(implode(' ', $bundle['warnings']))->not->toContain('webhook URL')
->and(implode(' ', $bundle['warnings']))->not->toContain('runner');
removeTransferSourceResources($this);
$githubApp->delete();
$gitlabApp->delete();
$targetTeam = Team::factory()->create();
$result = $this->importer->import($bundle, teamId: $targetTeam->id);
$importedApp = GithubApp::where('uuid', 'runner-only-app')->where('team_id', $targetTeam->id)->sole();
$imported = GithubRunnerConfig::where('server_id', Server::where('uuid', $result['server_uuid'])->sole()->id)->sole();
expect($imported->uuid)->toBe($config->uuid)
->and($imported->github_app_id)->toBe($importedApp->id)
->and($imported->is_enabled)->toBeFalse()
->and($imported->only(['labels', 'is_dedicated', 'allow_pull_requests', 'max_runners', 'runner_image', 'cpu_limit', 'memory_limit', 'capacity_wait_timeout', 'idle_timeout', 'job_timeout']))->toBe([
'labels' => ['coolify', 'gpu'],
'is_dedicated' => true,
'allow_pull_requests' => true,
'max_runners' => 4,
'runner_image' => 'ghcr.io/acme/runner:1',
'cpu_limit' => '1.5',
'memory_limit' => '2g',
'capacity_wait_timeout' => 30,
'idle_timeout' => 5,
'job_timeout' => 120,
])
->and($imported->docker_mode->value)->toBe('dind')
->and($result['checklist'])->toContain('On GitHub, open the settings of GitHub App Runner App. Set the Webhook URL to https://coolify-a.test/webhooks/source/github/events, the Setup URL to https://coolify-a.test/webhooks/source/github/install, and the Callback URL to https://coolify-a.test/login/github/app.')
->and(implode(' ', $result['warnings']))->not->toContain('webhook URL');
});
test('the GitHub runner config re-links a system-wide GitHub App of the target by uuid', function () {
$systemWide = transferTestGithubApp($this->team, 'system-runner-app', 'System Runner App');
$systemWide->forceFill(['is_system_wide' => true])->save();
GithubRunnerConfig::create(['server_id' => $this->server->id, 'github_app_id' => $systemWide->id, 'labels' => ['coolify']]);
$bundle = $this->exporter->export($this->server);
expect($bundle['github_apps'])->toBe([]);
removeTransferSourceResources($this);
$result = $this->importer->import($bundle, teamId: Team::factory()->create()->id);
expect(GithubRunnerConfig::where('server_id', Server::where('uuid', $result['server_uuid'])->sole()->id)->sole()->github_app_id)->toBe($systemWide->id);
});
test('the GitHub runner config is not imported when its GitHub App is missing on the target', function () {
$githubApp = transferTestGithubApp($this->team, 'missing-runner-app', 'Runner App');
GithubRunnerConfig::create(['server_id' => $this->server->id, 'github_app_id' => $githubApp->id, 'labels' => ['coolify']]);
$bundle = $this->exporter->export($this->server);
$bundle['github_apps'] = [];
removeTransferSourceResources($this);
$githubApp->delete();
$result = $this->importer->import($bundle, teamId: Team::factory()->create()->id);
expect(GithubRunnerConfig::count())->toBe(0)
->and($result['warnings'])->toContain('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.');
});
test('the checklist asks to move manual webhooks of git applications without a private App', function () {
$this->application->forceFill(['source_type' => null, 'source_id' => null])->saveQuietly();
$checklist = implode("\n", $this->exporter->export($this->server)['post_transfer_checklist']);
expect($checklist)->toContain('If a repository of these applications has a manual webhook to Coolify, change its URL to the target: my-app.')
->toContain('{target_url}/webhooks/source/gitea/events/manual');
});
test('the checklist does not ask about manual webhooks for an application that deploys through a private GitHub App', function () {
$githubApp = GithubApp::create([
'name' => 'private-app', 'api_url' => 'https://api.github.com', 'html_url' => 'https://github.com',
'team_id' => $this->team->id, 'is_system_wide' => false, 'is_public' => false,
]);
$this->application->forceFill(['source_type' => GithubApp::class, 'source_id' => $githubApp->id])->saveQuietly();
$checklist = implode("\n", $this->exporter->export($this->server)['post_transfer_checklist']);
expect($checklist)->not->toContain('manual webhook');
});
test('a claimed import starts the finish list and returns the server URL', function () {
$bundle = $this->exporter->export($this->server);
$this->service->forceDelete();
$this->application->forceDelete();
$this->database->forceDelete();
$this->server->forceDelete();
$result = $this->importer->import($bundle, teamId: $this->team->id);
$server = Server::where('uuid', $result['server_uuid'])->firstOrFail();
expect($result['server_url'])->toBe('https://coolify-a.test/server/'.$server->uuid)
->and(data_get($server->server_metadata, 'transfer.status'))->toBe('claimed')
->and(collect(data_get($server->server_metadata, 'transfer.finish.items'))->pluck('key'))->toContain('server_connection:server');
});
test('an application with a system-wide GitHub App that the target lacks is listed for a git source check', function () {
$systemApp = GithubApp::create(['name' => 'Coolify GitHub', 'api_url' => 'https://api.github.com', 'html_url' => 'https://github.com', 'team_id' => 0, 'is_system_wide' => true]);
$this->application->forceFill(['source_type' => GithubApp::class, 'source_id' => $systemApp->id])->saveQuietly();
$bundle = $this->exporter->export($this->server);
$this->service->forceDelete();
$this->application->forceDelete();
$this->database->forceDelete();
$this->server->forceDelete();
$systemApp->delete();
$result = $this->importer->import($bundle, teamId: $this->team->id);
$server = Server::where('uuid', $result['server_uuid'])->firstOrFail();
expect(data_get($bundle, 'projects.0.environments.0.applications.0.source.system_wide'))->toBeTrue()
->and(data_get($server->server_metadata, 'transfer.git_source_applications'))->toBe([$this->application->uuid])
->and(collect(data_get($server->server_metadata, 'transfer.finish.items'))->pluck('key'))->toContain("git_source:{$this->application->uuid}");
});
@@ -1,23 +1,39 @@
<?php
use App\Jobs\MigrateServerToInstanceJob;
use App\Jobs\ValidateAndInstallServerJob;
use App\Models\Application;
use App\Models\Environment;
use App\Models\GithubApp;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use App\Services\ServerTransfer\ServerTransferClaimer;
use App\Services\ServerTransfer\ServerTransferExporter;
use App\Services\ServerTransfer\ServerTransferMigrator;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Sleep;
use Illuminate\Validation\ValidationException;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
// A claim writes the owner marker on the host over SSH.
Process::fake();
Storage::fake('ssh-keys');
// An import validates the claimed server over SSH; the sync test queue would run it.
Queue::fake([ValidateAndInstallServerJob::class]);
Sleep::fake();
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true, 'fqdn' => 'https://coolify-a.test']);
$this->team = Team::factory()->create();
@@ -29,9 +45,10 @@ beforeEach(function () {
'name' => 'migrate-me',
]);
$destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail();
$project = Project::factory()->create(['team_id' => $this->team->id]);
$project = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'Shop']);
$environment = $project->environments()->first()
?? Environment::factory()->create(['project_id' => $project->id]);
$this->environment = $environment;
Application::factory()->create([
'environment_id' => $environment->id,
'destination_id' => $destination->id,
@@ -43,7 +60,7 @@ beforeEach(function () {
test('migrate exports imports via http and completes locally', function () {
Http::fake([
'http://8.8.8.8/api/v1/servers/import' => Http::response([
'https://8.8.8.8/api/v1/servers/import' => Http::response([
'dry_run' => false,
'server_uuid' => $this->server->uuid,
'claimed' => true,
@@ -55,12 +72,12 @@ test('migrate exports imports via http and completes locally', function () {
$result = app(ServerTransferMigrator::class)->migrate(
server: $this->server,
targetUrl: 'http://8.8.8.8',
targetUrl: 'https://8.8.8.8',
targetToken: 'target-token-xyz',
);
expect($result['server_uuid'])->toBe($this->server->uuid)
->and($result['target_url'])->toBe('http://8.8.8.8')
->and($result['target_url'])->toBe('https://8.8.8.8')
->and($result['import']['claimed'])->toBeTrue()
->and($result['message'])->toContain('migrated');
@@ -69,7 +86,7 @@ test('migrate exports imports via http and completes locally', function () {
->and((bool) $this->server->settings->force_disabled)->toBeTrue();
Http::assertSent(function ($request) {
return $request->url() === 'http://8.8.8.8/api/v1/servers/import'
return $request->url() === 'https://8.8.8.8/api/v1/servers/import'
&& $request->hasHeader('Authorization', 'Bearer target-token-xyz')
&& ! array_key_exists('write_remote', $request->data())
&& data_get($request->data(), 'bundle.server.uuid') === $this->server->uuid;
@@ -82,7 +99,7 @@ test('migrate rejects a private target before sending the bundle', function () {
expect(fn () => app(ServerTransferMigrator::class)->migrate(
$this->server,
'http://127.0.0.1:8001',
'https://127.0.0.1:8001',
'token',
))->toThrow(ValidationException::class);
@@ -116,7 +133,7 @@ test('migrate still rejects other private targets in development', function () {
expect(fn () => app(ServerTransferMigrator::class)->migrate(
$this->server,
'http://10.0.0.5:8001',
'https://10.0.0.5:8001',
'token',
))->toThrow(ValidationException::class);
@@ -125,26 +142,26 @@ test('migrate still rejects other private targets in development', function () {
test('migrate fails clearly when target is unreachable', function () {
Http::fake([
'http://8.8.4.4/*' => Http::failedConnection(),
'https://8.8.4.4/*' => Http::failedConnection(),
]);
expect(fn () => app(ServerTransferMigrator::class)->migrate(
$this->server,
'http://8.8.4.4',
'https://8.8.4.4',
'token',
))->toThrow(RuntimeException::class, 'Could not reach target');
});
test('migrate fails when target returns error', function () {
Http::fake([
'http://8.8.8.8/api/v1/servers/import' => Http::response([
'https://8.8.8.8/api/v1/servers/import' => Http::response([
'message' => 'A server with IP/domain already exists',
], 422),
]);
expect(fn () => app(ServerTransferMigrator::class)->migrate(
$this->server,
'http://8.8.8.8',
'https://8.8.8.8',
'token',
))->toThrow(RuntimeException::class, 'Target import failed');
@@ -156,7 +173,7 @@ test('migrate fails when target returns error', function () {
test('migrate surfaces recovery guidance when complete fails after successful remote import', function () {
Http::fake([
'http://8.8.8.8/api/v1/servers/import' => Http::response([
'https://8.8.8.8/api/v1/servers/import' => Http::response([
'dry_run' => false,
'server_uuid' => $this->server->uuid,
'claimed' => true,
@@ -172,14 +189,14 @@ test('migrate surfaces recovery guidance when complete fails after successful re
expect(fn () => app(ServerTransferMigrator::class)->migrate(
$this->server,
'http://8.8.8.8',
'https://8.8.8.8',
'token',
))->toThrow(RuntimeException::class, 'Disable management of this server here');
))->toThrow(RuntimeException::class, 'transfer/complete on this instance');
});
test('migrate keeps managing the server here when the target could not take management', function () {
Http::fake([
'http://8.8.8.8/api/v1/servers/import' => Http::response([
'https://8.8.8.8/api/v1/servers/import' => Http::response([
'dry_run' => false,
'server_uuid' => $this->server->uuid,
'claimed' => false,
@@ -189,7 +206,7 @@ test('migrate keeps managing the server here when the target could not take mana
expect(fn () => app(ServerTransferMigrator::class)->migrate(
$this->server,
'http://8.8.8.8',
'https://8.8.8.8',
'token',
))->toThrow(RuntimeException::class, 'could not take management');
@@ -197,3 +214,488 @@ test('migrate keeps managing the server here when the target could not take mana
expect($this->server->isTransferredAway())->toBeFalse()
->and((bool) $this->server->settings->force_disabled)->toBeFalse();
});
test('migrate rejects a target without https outside development', function () {
Http::fake();
expect(fn () => app(ServerTransferMigrator::class)->migrate(
$this->server,
'http://8.8.8.8',
'token',
))->toThrow(RuntimeException::class, 'must use https');
Http::assertNothingSent();
});
test('migrate tells the user to upgrade a target without server transfer', function () {
Http::fake([
'https://8.8.8.8/api/v1/servers/import' => Http::response(['message' => 'Not found.'], 404),
]);
expect(fn () => app(ServerTransferMigrator::class)->migrate(
$this->server,
'https://8.8.8.8',
'token',
))->toThrow(RuntimeException::class, 'does not support server transfer');
});
test('migrate completes when the import request times out but the target claimed the server', function () {
$sentExportId = null;
Http::fake(function ($request) use (&$sentExportId) {
if (str_ends_with($request->url(), '/api/v1/servers/import')) {
$sentExportId = data_get($request->data(), 'bundle.export_id');
return Http::response('Gateway Timeout', 504);
}
return Http::response([
'server_uuid' => $this->server->uuid,
'transfer' => ['status' => 'claimed', 'export_id' => $sentExportId],
]);
});
$result = app(ServerTransferMigrator::class)->migrate($this->server, 'https://8.8.8.8', 'token');
expect($result['import']['claimed'])->toBeTrue()
->and($this->server->fresh()->isTransferredAway())->toBeTrue();
Http::assertSent(fn ($request) => $request->url() === "https://8.8.8.8/api/v1/servers/{$this->server->uuid}/transfer");
});
test('migrate keeps the server here when the target never confirms a timed out import', function () {
Http::fake(function ($request) {
if (str_ends_with($request->url(), '/api/v1/servers/import')) {
return Http::response('Gateway Timeout', 504);
}
return Http::response(['message' => 'Server not found.'], 404);
});
expect(fn () => app(ServerTransferMigrator::class)->migrate($this->server, 'https://8.8.8.8', 'token'))
->toThrow(RuntimeException::class, 'Target import failed (HTTP 504)');
expect($this->server->fresh()->isTransferredAway())->toBeFalse();
});
test('start records the running transfer and queues the job once', function () {
Queue::fake();
Http::fake(['*/api/v1/servers/import/check*' => Http::response(['message' => 'ok', 'checks' => []])]);
$migrator = app(ServerTransferMigrator::class);
$migrator->start($this->server, 'https://8.8.8.8/', 'token');
expect(data_get($this->server->fresh()->server_metadata, 'migration.status'))->toBe('running')
->and(data_get($this->server->fresh()->server_metadata, 'migration.target_url'))->toBe('https://8.8.8.8');
Queue::assertPushed(MigrateServerToInstanceJob::class, 1);
expect(fn () => $migrator->start($this->server, 'https://8.8.8.8', 'token'))
->toThrow(RuntimeException::class, 'already running');
Queue::assertPushed(MigrateServerToInstanceJob::class, 1);
});
test('the migrate job records a failed transfer', function () {
Http::fake(['*' => Http::response(['message' => 'Unauthenticated.'], 401)]);
(new MigrateServerToInstanceJob($this->server, 'https://8.8.8.8', 'token'))
->handle(app(ServerTransferMigrator::class));
$migration = data_get($this->server->fresh()->server_metadata, 'migration');
expect($migration['status'])->toBe('failed')
->and($migration['error'])->toContain('HTTP 401');
});
test('the migrate job records a finished transfer', function () {
Http::fake([
'https://8.8.8.8/api/v1/servers/import' => Http::response([
'server_uuid' => $this->server->uuid,
'claimed' => true,
'warnings' => ['check webhooks'],
], 201),
]);
(new MigrateServerToInstanceJob($this->server, 'https://8.8.8.8', 'token'))
->handle(app(ServerTransferMigrator::class));
$server = $this->server->fresh();
expect(data_get($server->server_metadata, 'migration.status'))->toBe('succeeded')
->and(data_get($server->server_metadata, 'migration.warnings'))->toContain('check webhooks')
->and($server->isTransferredAway())->toBeTrue();
});
test('start replaces a running state that a stopped worker left behind', function () {
Queue::fake();
Http::fake(['*/api/v1/servers/import/check*' => Http::response(['message' => 'ok', 'checks' => []])]);
$this->server->forceFill(['server_metadata' => ['migration' => [
'status' => 'running',
'started_at' => now()->subHour()->toIso8601String(),
]]])->save();
app(ServerTransferMigrator::class)->start($this->server, 'https://8.8.8.8', 'token');
Queue::assertPushed(MigrateServerToInstanceJob::class, 1);
});
test('start checks the target before it queues the transfer', function (int $status, string $message) {
Queue::fake();
Http::fake(['*/api/v1/servers/import/check*' => Http::response(['message' => 'A server with IP/domain 10.0.0.1 already exists on this instance.'], $status)]);
expect(fn () => app(ServerTransferMigrator::class)->start($this->server, 'https://8.8.8.8', 'wrong-token'))
->toThrow(RuntimeException::class, $message);
Queue::assertNothingPushed();
$migration = data_get($this->server->fresh()->server_metadata, 'migration');
expect($migration['status'])->toBe('failed')
->and($migration['phase'])->toBe('checks')
->and(collect($migration['log'])->last())->toMatchArray(['level' => 'error'])
->and(collect($migration['log'])->where('level', 'error')->pluck('message')->implode("\n"))->toContain('FAILED: ')->toContain($message);
Http::assertSent(fn ($request) => str_contains($request->url(), 'server_uuid='.$this->server->uuid)
&& $request->hasHeader('Authorization', 'Bearer wrong-token'));
})->with([
'invalid token' => [401, 'does not accept the API token'],
'token without write permission' => [403, 'write permission'],
'conflict on the target' => [422, 'already exists on this instance'],
'target without server transfer' => [404, 'does not support server transfer'],
]);
test('start fails at once when the target cannot be reached', function () {
Queue::fake();
Http::fake(fn () => throw new ConnectionException('Connection refused'));
expect(fn () => app(ServerTransferMigrator::class)->start($this->server, 'https://8.8.8.8', 'token'))
->toThrow(RuntimeException::class, 'This instance cannot reach the target instance');
Queue::assertNothingPushed();
});
test('migrate stops waiting for a timed out import when the target rejects the token', function () {
Http::fake(function ($request) {
if (str_ends_with($request->url(), '/api/v1/servers/import')) {
return Http::response('Gateway Timeout', 504);
}
return Http::response(['message' => 'Unauthenticated.'], 401);
});
expect(fn () => app(ServerTransferMigrator::class)->migrate($this->server, 'https://8.8.8.8', 'token'))
->toThrow(RuntimeException::class, 'Target import failed (HTTP 504)');
Sleep::assertSleptTimes(1);
expect($this->server->fresh()->isTransferredAway())->toBeFalse();
});
test('start logs every check as OK and the queued transfer', function () {
Queue::fake();
Http::fake(['*/api/v1/servers/import/check*' => Http::response([
'message' => 'ok',
'checks' => [
['label' => 'no server with the same IP exists', 'ok' => true, 'message' => null],
['label' => 'the target can reach the SSH port of the server (10.0.0.1:22)', 'ok' => true, 'message' => null],
],
])]);
app(ServerTransferMigrator::class)->start($this->server, 'https://8.8.8.8', 'token');
$log = collect(data_get($this->server->fresh()->server_metadata, 'migration.log'))->pluck('message');
expect($log)->toContain('OK: the server is not the Coolify host.')
->toContain('OK: this instance manages the server.')
->toContain('OK: no deployment is queued or running on the server.')
->toContain('OK: this instance can connect to the server over SSH.')
->toContain('OK: the target instance at https://8.8.8.8 can be reached.')
->toContain('OK: the target instance accepts the API token.')
->toContain('OK: on the target: the target can reach the SSH port of the server (10.0.0.1:22).')
->and($log->last())->toBe('All checks passed. The transfer is queued.');
Http::assertSent(fn ($request) => str_contains($request->url(), 'schema_version=') && str_contains($request->url(), 'port='));
});
test('start runs every check and logs which are OK and which failed', function () {
Queue::fake();
Process::fake(['*' => Process::result(errorOutput: 'Connection refused', exitCode: 255)]);
Http::fake(['*/api/v1/servers/import/check*' => Http::response([
'message' => 'A server with IP/domain 10.0.0.1 already exists on this instance.',
'checks' => [
['label' => 'the team can have one more server', 'ok' => true, 'message' => null],
['label' => 'no server with the same IP exists', 'ok' => false, 'message' => 'A server with IP/domain 10.0.0.1 already exists on this instance.'],
],
], 422)]);
expect(fn () => app(ServerTransferMigrator::class)->start($this->server, 'https://8.8.8.8', 'token'))
->toThrow(RuntimeException::class, '2 of 10 checks failed');
$log = collect(data_get($this->server->fresh()->server_metadata, 'migration.log'));
expect($log->where('level', 'success')->pluck('message'))->toContain('OK: the target instance accepts the API token.')
->toContain('OK: on the target: the team can have one more server.')
->and($log->where('level', 'error')->pluck('message')->implode("\n"))
->toContain('FAILED: this instance can connect to the server over SSH.')
->toContain('FAILED: on the target: no server with the same IP exists. A server with IP/domain 10.0.0.1 already exists')
->and($log->last()['message'])->toContain('2 check(s) failed');
Queue::assertNothingPushed();
});
test('start reports the target checks as not checked when the token is rejected', function () {
Queue::fake();
Http::fake(['*/api/v1/servers/import/check*' => Http::response(['message' => 'Unauthenticated.'], 401)]);
expect(fn () => app(ServerTransferMigrator::class)->start($this->server, 'https://8.8.8.8', 'token'))
->toThrow(RuntimeException::class);
$log = collect(data_get($this->server->fresh()->server_metadata, 'migration.log'))->pluck('message');
expect($log)->toContain('OK: the target instance supports server transfer.')
->toContain('NOT CHECKED: the checks on the target instance. They did not run, because "the target instance accepts the API token" failed.');
});
test('the migrate job logs each step of a finished transfer', function () {
Http::fake([
'https://8.8.8.8/api/v1/servers/import' => Http::response(['server_uuid' => $this->server->uuid, 'claimed' => true], 201),
]);
(new MigrateServerToInstanceJob($this->server, 'https://8.8.8.8', 'token'))
->handle(app(ServerTransferMigrator::class));
$log = collect(data_get($this->server->fresh()->server_metadata, 'migration.log'));
expect($log->pluck('message')->first())->toBe('Exporting the server configuration and its resources.')
->and($log->pluck('message')->implode("\n"))->toContain('Sending it to https://8.8.8.8')
->toContain('manages it now')
->and($log->last())->toMatchArray(['level' => 'success', 'message' => 'Transfer finished. The target instance manages the server now.']);
});
test('the migrate job logs each attempt to read the result of a timed out import', function () {
Http::fake(function ($request) {
if (str_ends_with($request->url(), '/api/v1/servers/import')) {
return Http::response('Gateway Timeout', 504);
}
return Http::response(['message' => 'Server not found.'], 404);
});
(new MigrateServerToInstanceJob($this->server, 'https://8.8.8.8', 'token'))
->handle(app(ServerTransferMigrator::class));
$messages = collect(data_get($this->server->fresh()->server_metadata, 'migration.log'))->pluck('message');
expect($messages->filter(fn ($message) => str_starts_with($message, 'Asking the target for the import result')))->toHaveCount(ServerTransferMigrator::STATUS_PROBE_ATTEMPTS)
->and($messages->last())->toStartWith('Transfer failed: Target import failed (HTTP 504)');
});
test('the migrate job stores the post-transfer checklist with the target URL', function () {
Http::fake([
'https://8.8.8.8/api/v1/servers/import' => Http::response(['server_uuid' => $this->server->uuid, 'claimed' => true], 201),
]);
$exporter = app(ServerTransferExporter::class);
$fakeExporter = Mockery::mock(ServerTransferExporter::class);
$fakeExporter->shouldReceive('export')->once()->andReturnUsing(fn (Server $server, bool $includeSensitive = true) => [
...$exporter->export($server, $includeSensitive),
'post_transfer_checklist' => [
'Set the webhook URL of GitHub App deploy-bot to {target_url}/webhooks/source/github/events.',
['not a step'],
'Point the DNS record of shop.example.com to the server.',
],
]);
app()->instance(ServerTransferExporter::class, $fakeExporter);
(new MigrateServerToInstanceJob($this->server, 'https://8.8.8.8', 'token'))
->handle(app(ServerTransferMigrator::class));
$migration = data_get($this->server->fresh()->server_metadata, 'migration');
expect($migration['status'])->toBe('succeeded')
->and($migration['checklist'])->toBe([
'Set the webhook URL of GitHub App deploy-bot to https://8.8.8.8/webhooks/source/github/events.',
'Point the DNS record of shop.example.com to the server.',
]);
});
test('the migrate job stores the checklist of the target, which has the public URL of the target', function () {
Http::fake([
'https://8.8.8.8/api/v1/servers/import' => Http::response([
'server_uuid' => $this->server->uuid,
'claimed' => true,
'checklist' => ['Set the webhook URL of GitHub App deploy-bot to https://coolify-b.example.com/webhooks/source/github/events.'],
], 201),
]);
(new MigrateServerToInstanceJob($this->server, 'https://8.8.8.8', 'token'))
->handle(app(ServerTransferMigrator::class));
expect(data_get($this->server->fresh()->server_metadata, 'migration.checklist'))
->toBe(['Set the webhook URL of GitHub App deploy-bot to https://coolify-b.example.com/webhooks/source/github/events.']);
});
test('start notes projects that also have resources on other servers', function (int $checkStatus, bool $queued) {
Queue::fake();
Http::fake(['*/api/v1/servers/import/check*' => Http::response(['message' => 'ok', 'checks' => []], $checkStatus)]);
$otherServer = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id, 'ip' => '10.88.0.11']);
$otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->firstOrFail();
foreach (['web', 'worker'] as $name) {
Application::factory()->create([
'environment_id' => $this->environment->id,
'destination_id' => $otherDestination->id,
'destination_type' => $otherDestination->getMorphClass(),
'name' => $name,
]);
}
// A project with resources only on the other server is not affected by the transfer.
$otherProject = Project::factory()->create(['team_id' => $this->team->id, 'name' => 'Blog']);
Application::factory()->create([
'environment_id' => $otherProject->environments()->first()->id,
'destination_id' => $otherDestination->id,
'destination_type' => $otherDestination->getMorphClass(),
]);
try {
app(ServerTransferMigrator::class)->start($this->server, 'https://8.8.8.8', 'token');
} catch (RuntimeException) {
}
$notes = collect(data_get($this->server->fresh()->server_metadata, 'migration.log'))
->filter(fn (array $entry) => str_starts_with($entry['message'], 'NOTE: '));
expect($notes->pluck('message')->all())->toBe([
"NOTE: project 'Shop' also has 2 resources on other servers. They stay on this instance; only the resources of this server are transferred.",
])
->and($notes->first()['level'])->toBe('info');
$queued ? Queue::assertPushed(MigrateServerToInstanceJob::class, 1) : Queue::assertNothingPushed();
})->with([
'checks pass' => [200, true],
'a check fails' => [401, false],
]);
test('start adds no note when the projects have no resources on other servers', function () {
Queue::fake();
Http::fake(['*/api/v1/servers/import/check*' => Http::response(['message' => 'ok', 'checks' => []])]);
app(ServerTransferMigrator::class)->start($this->server, 'https://8.8.8.8', 'token');
$messages = collect(data_get($this->server->fresh()->server_metadata, 'migration.log'))->pluck('message');
expect($messages->filter(fn (string $message) => str_starts_with($message, 'NOTE: ')))->toBeEmpty();
Queue::assertPushed(MigrateServerToInstanceJob::class, 1);
});
test('the migrate job timeout covers the import request and every status probe', function () {
$worstCase = ServerTransferMigrator::IMPORT_TIMEOUT_SECONDS
+ ServerTransferMigrator::STATUS_PROBE_ATTEMPTS
* (ServerTransferMigrator::STATUS_PROBE_INTERVAL_SECONDS + ServerTransferMigrator::STATUS_REQUEST_TIMEOUT_SECONDS);
$job = new MigrateServerToInstanceJob($this->server, 'https://8.8.8.8', 'token');
// The rest of the timeout is for the export and the database writes.
expect($job->timeout)->toBeGreaterThanOrEqual($worstCase + 300)
->and(ServerTransferMigrator::STALE_MIGRATION_SECONDS)->toBeGreaterThan($job->timeout);
});
function stopMigrateJobAfterExport(Server $server): void
{
$metadata = $server->fresh()->server_metadata ?? [];
$metadata['migration'] = ['status' => 'running', 'target_url' => 'https://8.8.8.8', 'export_id' => 'export-1', 'log' => []];
$server->forceFill(['server_metadata' => $metadata])->save();
(new MigrateServerToInstanceJob($server, 'https://8.8.8.8', 'token'))
->failed(new RuntimeException('The job has timed out.'));
}
test('a stopped migrate job completes the transfer when the target manages the server', function () {
Http::fake(['*' => Http::response(['transfer' => ['status' => 'claimed', 'export_id' => 'export-1']])]);
stopMigrateJobAfterExport($this->server);
$server = $this->server->fresh();
expect(data_get($server->server_metadata, 'migration.status'))->toBe('succeeded')
->and($server->isTransferredAway())->toBeTrue();
});
test('a stopped migrate job records an unknown result when the target cannot be asked', function () {
Http::fake(fn () => throw new ConnectionException('Connection timed out'));
stopMigrateJobAfterExport($this->server);
$server = $this->server->fresh();
expect(data_get($server->server_metadata, 'migration.status'))->toBe('unknown')
->and(data_get($server->server_metadata, 'migration.error'))->toContain("/api/v1/servers/{$server->uuid}/transfer/complete")
->and($server->isTransferredAway())->toBeFalse();
});
test('a stopped migrate job records a failure when the target does not have the export', function (array $body, int $status) {
Http::fake(['*' => Http::response($body, $status)]);
stopMigrateJobAfterExport($this->server);
$server = $this->server->fresh();
expect(data_get($server->server_metadata, 'migration.status'))->toBe('failed')
->and($server->isTransferredAway())->toBeFalse();
})->with([
'not found' => [['message' => 'Server not found.'], 404],
'other export' => [['transfer' => ['status' => 'claimed', 'export_id' => 'export-0']], 200],
]);
test('a migrate job stopped before the export records a failure without asking the target', function () {
Http::fake();
(new MigrateServerToInstanceJob($this->server, 'https://8.8.8.8', 'token'))
->failed(new RuntimeException('The job has timed out.'));
expect(data_get($this->server->fresh()->server_metadata, 'migration.status'))->toBe('failed');
Http::assertNothingSent();
});
test('migrate saves the export ID before it sends the bundle', function () {
$savedExportId = null;
Http::fake(function ($request) use (&$savedExportId) {
$savedExportId = data_get($this->server->fresh()->server_metadata, 'migration.export_id');
return Http::response(['server_uuid' => $this->server->uuid, 'claimed' => true], 201);
});
$result = app(ServerTransferMigrator::class)->migrate($this->server, 'https://8.8.8.8', 'token');
expect($savedExportId)->toBe($result['export_id']);
});
test('transfer back asks the managing instance to transfer the archive here with a short-lived token of its team', function () {
$user = User::factory()->create();
$this->team->members()->attach($user->id, ['role' => 'owner']);
app(ServerTransferClaimer::class)->markTransferred($this->server, targetInstanceUrl: 'https://8.8.8.8');
Http::fake(['https://8.8.8.8/api/v1/servers/*/migrate' => Http::response(['migration' => ['status' => 'running']], 202)]);
app(ServerTransferMigrator::class)->requestTransferBack($this->server->fresh(), $user, 'https://8.8.8.8', 'token-of-b', 'https://coolify-a.test');
$token = $user->tokens()->sole();
expect((int) $token->team_id)->toBe($this->team->id)
->and($token->abilities)->toBe(['read', 'write'])
->and($token->expires_at->isFuture())->toBeTrue()
->and(data_get($this->server->fresh()->server_metadata, 'transfer.transfer_back.from'))->toBe('https://8.8.8.8');
Http::assertSent(fn ($request) => $request->url() === "https://8.8.8.8/api/v1/servers/{$this->server->uuid}/migrate"
&& $request->hasHeader('Authorization', 'Bearer token-of-b')
&& $request['target_url'] === 'https://coolify-a.test'
&& str_starts_with((string) $request['target_token'], $token->id.'|'));
});
test('a refused transfer back deletes the token and shows the reason of the other instance', function () {
$user = User::factory()->create();
$this->team->members()->attach($user->id, ['role' => 'owner']);
app(ServerTransferClaimer::class)->markTransferred($this->server, targetInstanceUrl: 'https://8.8.8.8');
Http::fake(['*' => Http::response(['message' => '1 of 13 checks failed: no server with the same IP exists.'], 422)]);
expect(fn () => app(ServerTransferMigrator::class)->requestTransferBack($this->server->fresh(), $user, 'https://8.8.8.8', 'token-of-b', 'https://coolify-a.test'))
->toThrow(RuntimeException::class, 'no server with the same IP exists');
expect($user->tokens()->count())->toBe(0)
->and(data_get($this->server->fresh()->server_metadata, 'transfer.transfer_back'))->toBeNull();
});
test('start notes system-wide git sources that the target does not have', function (bool $noted) {
Queue::fake();
$systemApp = GithubApp::create([
'name' => 'Coolify GitHub', 'api_url' => 'https://api.github.com', 'html_url' => 'https://github.com',
'team_id' => 0, 'is_system_wide' => true, 'is_public' => false,
]);
Application::where('name', 'app-on-server')->update(['source_type' => GithubApp::class, 'source_id' => $systemApp->id]);
Http::fake(['*/api/v1/servers/import/check*' => Http::response(['message' => 'ok', 'checks' => [], 'missing_system_sources' => ['github' => $noted ? [$systemApp->uuid] : [], 'gitlab' => []]])]);
app(ServerTransferMigrator::class)->start($this->server, 'https://8.8.8.8', 'token');
$notes = collect(data_get($this->server->fresh()->server_metadata, 'migration.log'))->pluck('message')
->filter(fn (string $message) => str_contains($message, 'system-wide'));
expect($notes->isNotEmpty())->toBe($noted);
if ($noted) {
expect($notes->first())->toBe("NOTE: the system-wide GitHub App 'Coolify GitHub' of this instance is not transferred, and the target has no matching one. These applications need a new git source on the target: app-on-server. The finish list on the target shows them.");
}
Http::assertSent(fn ($request) => str_contains(urldecode($request->url()), "system_github_apps[0]={$systemApp->uuid}"));
Queue::assertPushed(MigrateServerToInstanceJob::class, 1);
})->with([
'the target has no matching App' => [true],
'the target has the App' => [false],
]);
@@ -1,5 +1,6 @@
<?php
use App\Jobs\ValidateAndInstallServerJob;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
@@ -16,11 +17,19 @@ use App\Models\Team;
use App\Services\ServerTransfer\ServerTransferExporter;
use App\Services\ServerTransfer\ServerTransferImporter;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
// A claim writes the owner marker on the host over SSH.
Process::fake();
Storage::fake('ssh-keys');
// An import validates the claimed server over SSH; the sync test queue would run it.
Queue::fake([ValidateAndInstallServerJob::class]);
Server::flushIdentityMap();
InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true, 'fqdn' => 'https://coolify-a.test']);
@@ -0,0 +1,278 @@
<?php
use App\Events\TransferFinishChanged;
use App\Jobs\CheckTransferFinishStepsJob;
use App\Livewire\Server\TransferFinish;
use App\Models\Application;
use App\Models\DnsProviderZone;
use App\Models\GithubApp;
use App\Models\GithubRunnerConfig;
use App\Models\GitlabApp;
use App\Models\InstanceSettings;
use App\Models\IntegrationToken;
use App\Models\ManagedDnsRecord;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Models\User;
use App\Services\ServerTransfer\TransferFinishChecklist;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Storage;
use Livewire\Livewire;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
Process::fake();
Storage::fake('ssh-keys');
InstanceSettings::forceCreate(['id' => 0, 'fqdn' => 'https://coolify-b.test']);
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$this->server = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail();
$rsaKey = openssl_pkey_new(['private_key_bits' => 2048, 'private_key_type' => OPENSSL_KEYTYPE_RSA]);
openssl_pkey_export($rsaKey, $pem);
$this->githubApp = GithubApp::create([
'name' => 'deploy-bot', 'organization' => 'acme', 'api_url' => 'https://api.github.com', 'html_url' => 'https://github.com',
'app_id' => 1234, 'installation_id' => 5678, 'webhook_secret' => 'gh-secret', 'is_system_wide' => false, 'is_public' => false,
'team_id' => $this->team->id,
'private_key_id' => PrivateKey::create(['name' => 'gh', 'private_key' => $pem, 'team_id' => $this->team->id])->id,
]);
$this->checklist = app(TransferFinishChecklist::class);
});
function finishTestApplication(object $test, array $attributes = []): Application
{
return Application::factory()->create([
'destination_id' => $test->destination->id,
'destination_type' => $test->destination->getMorphClass(),
...$attributes,
]);
}
function finishTestItem(Server $server, string $key): array
{
return collect(data_get($server->fresh()->server_metadata, 'transfer.finish.items'))->firstWhere('key', $key);
}
test('start lists a step for every kind of setting the server uses', function () {
$githubApplication = finishTestApplication($this, ['source_type' => GithubApp::class, 'source_id' => $this->githubApp->id, 'fqdn' => 'https://shop.example.test']);
$gitlabApp = GitlabApp::create(['name' => 'gitlab', 'api_url' => 'https://gitlab.com/api/v4', 'html_url' => 'https://gitlab.com', 'team_id' => $this->team->id, 'is_system_wide' => false]);
finishTestApplication($this, ['source_type' => GitlabApp::class, 'source_id' => $gitlabApp->id]);
$publicApplication = finishTestApplication($this, ['source_type' => null, 'source_id' => null, 'git_repository' => 'https://github.com/acme/public']);
$token = IntegrationToken::factory()->for($this->team)->create(['provider' => 'cloudflare']);
$record = ManagedDnsRecord::factory()->owned()->create(['dns_provider_zone_id' => DnsProviderZone::factory()->create(['integration_token_id' => $token->id])->id]);
$record->addReference($githubApplication);
$runnerConfig = GithubRunnerConfig::create(['server_id' => $this->server->id, 'github_app_id' => $this->githubApp->id, 'labels' => ['coolify']]);
$keys = array_column($this->checklist->start($this->server), 'key');
expect($keys)->toContain(
'server_connection:server',
'sentinel:server',
"github_app_webhook:{$this->githubApp->uuid}",
"github_app_urls:{$this->githubApp->uuid}",
"gitlab_webhook:{$gitlabApp->uuid}",
"manual_webhook:{$publicApplication->uuid}",
"dns_record:{$record->uuid}",
'domain:shop.example.test',
"github_runners:{$runnerConfig->uuid}",
)->and($keys)->not->toContain("manual_webhook:{$githubApplication->uuid}");
});
test('the GitHub App webhook step checks the URL at GitHub and sets it with one click', function () {
finishTestApplication($this, ['source_type' => GithubApp::class, 'source_id' => $this->githubApp->id]);
$this->checklist->start($this->server);
$key = "github_app_webhook:{$this->githubApp->uuid}";
$hookUrl = 'https://coolify-a.test/webhooks/source/github/events';
Http::fake([
'https://api.github.com/zen' => Http::response('ok', 200, ['Date' => now()->toRfc7231String()]),
'https://api.github.com/app/hook/config' => function ($request) use (&$hookUrl) {
if ($request->method() === 'PATCH') {
$hookUrl = $request['url'];
}
return Http::response(['url' => $hookUrl, 'content_type' => 'json']);
},
]);
expect($this->checklist->check($this->server, $key)['status'])->toBe('action');
$item = $this->checklist->fix($this->server, $key);
expect($item['status'])->toBe('ok')
->and(finishTestItem($this->server, $key)['status'])->toBe('ok');
Http::assertSent(fn ($request) => $request->method() === 'PATCH' && $request['url'] === 'https://coolify-b.test/webhooks/source/github/events');
});
test('a webhook step turns OK when this instance receives a webhook after the transfer', function () {
$application = finishTestApplication($this, ['source_type' => null, 'source_id' => null, 'git_repository' => 'https://github.com/acme/public']);
$this->checklist->start($this->server);
$key = "manual_webhook:{$application->uuid}";
expect($this->checklist->check($this->server, $key)['status'])->toBe('action');
$this->travel(1)->minutes();
TransferFinishChecklist::recordWebhook('application', $application->uuid);
expect($this->checklist->check($this->server, $key)['status'])->toBe('ok');
});
test('a verified GitHub App webhook is recorded for the finish list', function () {
$body = json_encode(['action' => 'created', 'installation' => ['id' => 5678]]);
$this->call('POST', '/webhooks/source/github/events', [], [], [], [
'HTTP_X-GitHub-Event' => 'installation',
'HTTP_X-GitHub-Hook-Installation-Target-Id' => '1234',
'HTTP_X-Hub-Signature-256' => 'sha256='.hash_hmac('sha256', $body, 'gh-secret'),
'CONTENT_TYPE' => 'application/json',
], $body);
expect(Cache::get("transfer-finish:webhook:github_app:{$this->githubApp->uuid}"))->not->toBeNull();
});
test('I finished closes the list and keeps the open steps as skipped', function () {
$this->checklist->start($this->server);
$this->checklist->markDone($this->server, 'server_connection:server');
$this->checklist->finish($this->server->fresh(), $this->user);
$finish = $this->checklist->of($this->server->fresh());
expect($finish['finished_by'])->toBe($this->user->id)
->and($finish['skipped'])->toBe(['sentinel:server'])
->and($this->checklist->isOpen($this->server->fresh()))->toBeFalse();
});
test('the panel shows the open steps by group, collapses done steps, and finishes', function () {
$this->checklist->start($this->server);
Livewire::test(TransferFinish::class, ['server' => $this->server])
->assertSee('Finish the transfer')
->assertSee('0 of 2 done.')
->assertSee('Server')
->assertSee('Restart Sentinel')
->call('markStepDone', 'sentinel:server')
->assertSee('1 of 2 done.')
->assertSee('1 step is OK or done')
->assertSee('Finish with open steps')
->call('markStepDone', 'server_connection:server')
->assertDontSee('Finish with open steps')
->call('finishTransfer')
->assertDontSee('Finish the transfer');
expect(finishTestItem($this->server, 'sentinel:server')['status'])->toBe('done');
});
test('the panel starts background checks when they are stale, and not again within 30 seconds', function () {
Queue::fake();
$this->checklist->start($this->server);
$component = Livewire::test(TransferFinish::class, ['server' => $this->server]);
$component->call('refreshSteps');
Queue::assertPushed(CheckTransferFinishStepsJob::class, 1);
$this->checklist->checkAll($this->server->fresh());
$component->call('refreshSteps');
Queue::assertPushed(CheckTransferFinishStepsJob::class, 1);
});
test('a failed check shows a short message and keeps the technical reason as details', function () {
finishTestApplication($this, ['source_type' => GithubApp::class, 'source_id' => $this->githubApp->id]);
$this->checklist->start($this->server);
Http::fake([
'https://api.github.com/zen' => Http::response('ok', 200, ['Date' => now()->toRfc7231String()]),
'https://api.github.com/app/hook/config' => Http::response(['message' => 'Bad credentials'], 401),
]);
$item = $this->checklist->check($this->server, "github_app_webhook:{$this->githubApp->uuid}");
expect($item['message'])->toBe('GitHub cannot be asked with the App key. No webhook arrived since the transfer.')
->and($item['error'])->toContain('Bad credentials')
->and($item['hint'])->toBe('GitHub must send webhooks to this instance.')
->and($item['details'][0]['value'])->toBe('https://coolify-b.test/webhooks/source/github/events');
});
test('team members cannot check or finish the transfer', function () {
$this->checklist->start($this->server);
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$this->actingAs($member);
Livewire::test(TransferFinish::class, ['server' => $this->server])->call('finishTransfer')->assertForbidden();
Livewire::test(TransferFinish::class, ['server' => $this->server])->call('markStepDone', 'sentinel:server')->assertForbidden();
Livewire::test(TransferFinish::class, ['server' => $this->server])->call('fixStep', 'sentinel:server')->assertForbidden();
expect($this->checklist->isOpen($this->server->fresh()))->toBeTrue();
});
test('a Docker image application has no manual webhook step', function () {
$application = finishTestApplication($this, ['source_type' => null, 'source_id' => null, 'build_pack' => 'dockerimage', 'git_repository' => 'coollabsio/coolify']);
expect(array_column($this->checklist->start($this->server), 'key'))->not->toContain("manual_webhook:{$application->uuid}");
});
test('an application that lost its system-wide git source gets a git source step with a link', function () {
$environment = Project::factory()->create(['team_id' => $this->team->id])->environments()->first();
$application = finishTestApplication($this, ['source_type' => null, 'source_id' => null, 'environment_id' => $environment->id]);
$this->server->forceFill(['server_metadata' => ['transfer' => ['status' => 'claimed', 'git_source_applications' => [$application->uuid]]]])->save();
$key = "git_source:{$application->uuid}";
$items = collect($this->checklist->start($this->server));
$item = $items->firstWhere('key', $key);
expect($items->pluck('key'))->not->toContain("manual_webhook:{$application->uuid}")
->and($item['link'])->toStartWith('/project/')->toEndWith("/application/{$application->uuid}/source")
->and($this->checklist->check($this->server, $key)['status'])->toBe('action');
$gitlabApp = GitlabApp::create(['name' => 'gitlab', 'api_url' => 'https://gitlab.com/api/v4', 'html_url' => 'https://gitlab.com', 'team_id' => $this->team->id, 'is_system_wide' => false]);
$application->forceFill(['source_type' => GitlabApp::class, 'source_id' => $gitlabApp->id])->save();
expect($this->checklist->check($this->server, $key)['status'])->toBe('ok');
});
test('restarting Sentinel waits for its data instead of failing the step at once', function () {
$this->checklist->start($this->server);
Livewire::test(TransferFinish::class, ['server' => $this->server])
->call('fixStep', 'sentinel:server')
->assertDispatched('info')
->assertNotDispatched('error');
expect(finishTestItem($this->server, 'sentinel:server')['status'])->toBe('pending');
});
test('a changed step is broadcast to the team, and the open panel shows it without a reload', function () {
Event::fake([TransferFinishChanged::class]);
$this->checklist->start($this->server);
$component = Livewire::test(TransferFinish::class, ['server' => $this->server])->assertSee('0 of 2 done.');
$this->checklist->markDone($this->server->fresh(), 'sentinel:server');
Event::assertDispatched(TransferFinishChanged::class, fn ($event) => $event->serverUuid === $this->server->uuid && $event->teamId === $this->team->id);
$component->call('handleTransferFinishChanged', ['serverUuid' => $this->server->uuid])->assertSee('1 of 2 done.');
});
test('the GitHub App steps link to the App settings at GitHub', function () {
finishTestApplication($this, ['source_type' => GithubApp::class, 'source_id' => $this->githubApp->id]);
$items = collect($this->checklist->start($this->server));
expect($this->checklist->linkOf($items->firstWhere('key', "github_app_webhook:{$this->githubApp->uuid}")))->toBe(['https://github.com/organizations/acme/settings/apps/deploy-bot', 'Open on GitHub'])
->and($this->checklist->linkOf($items->firstWhere('key', "github_app_urls:{$this->githubApp->uuid}")))->toBe(['https://github.com/organizations/acme/settings/apps/deploy-bot', 'Open on GitHub']);
Livewire::test(TransferFinish::class, ['server' => $this->server])
->assertSeeHtml('href="https://github.com/organizations/acme/settings/apps/deploy-bot" target="_blank"')
->assertSee('Open on GitHub');
});
@@ -0,0 +1,50 @@
<?php
use App\Models\Server;
use App\Models\Team;
use App\Services\ServerTransfer\TransferFinishChecklist;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
uses(RefreshDatabase::class);
beforeEach(function () {
config()->set('app.maintenance.store', 'array');
$this->stack = seedBrowserResourceStack();
$this->server = Server::create(['name' => 'transferred-web', 'ip' => '10.0.0.5', 'team_id' => 0, 'private_key_id' => 1]);
$this->server->forceFill(['uuid' => 'transferred-1'])->save();
createBrowserApplication([...$this->stack, 'destination' => $this->server->standaloneDockers()->first()], [
'name' => 'shop',
'build_pack' => 'nixpacks',
'git_repository' => 'https://github.com/acme/shop',
]);
app(TransferFinishChecklist::class)->start($this->server);
app(TransferFinishChecklist::class)->markDone($this->server, 'server_connection:server');
});
it('shows the finish list on the server page and finishes it after a confirmation', function () {
Team::query()->update(['show_boarding' => false]);
Cache::flush();
$page = visit('/login')
->fill('email', 'test@example.com')
->fill('password', 'password')
->click('Login')
->assertSee('Dashboard');
$page->navigate('/server/transferred-1')
->assertSee('Finish the transfer')
->assertSee('Manual webhook of shop')
->assertSee('1 step is OK or done')
->screenshot(filename: 'transfer-finish-list');
$page->click('li:has-text("Manual webhook of shop") button:has-text("Details")')
->wait(0.5)
->assertSee('/webhooks/source/github/events/manual')
->screenshot(filename: 'transfer-finish-details');
$page->click('button:visible:has-text("Finish with open steps")')
->wait(1)
->assertSee('Finish with open steps?')
->assertSee('Skipped: Manual webhook of shop')
->screenshot(filename: 'transfer-finish-confirm');
});