From 3d763c1577306811b4aab8db4dd93c8a2c91e444 Mon Sep 17 00:00:00 2001
From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Date: Tue, 29 Sep 2026 10:56:11 +0200
Subject: [PATCH] refactor(server-transfer): always claim on import and drop
remote claim file
Import now always claims the server and rebinds Sentinel. Remove the
SSH-written ownership file, the write_remote/rebind_sentinel/claim
options, and the POST /servers/{uuid}/claim endpoint. The migrator now
fails when the target cannot take management. Disabling management
records transfer.management_disabled so the server shows as
transferable, and the transfer and import pages are simplified.
Co-Authored-By: Claude Opus 5.5
---
app/Console/Commands/Generate/OpenApi.php | 5 +-
.../Api/ServerTransferController.php | 88 +---
app/Livewire/Server/Show.php | 4 +-
app/Livewire/Server/Transfer.php | 74 +---
app/Livewire/Server/TransferImport.php | 10 +-
app/Models/Server.php | 9 +
.../ServerTransfer/ServerTransferBundle.php | 2 -
.../ServerTransfer/ServerTransferClaimer.php | 90 +----
.../ServerTransfer/ServerTransferImporter.php | 17 +-
.../ServerTransfer/ServerTransferMigrator.php | 22 +-
openapi.json | 376 ------------------
openapi.yaml | 245 ------------
.../views/livewire/server/index.blade.php | 5 +-
.../views/livewire/server/show.blade.php | 45 ++-
.../livewire/server/transfer-import.blade.php | 147 ++++---
.../views/livewire/server/transfer.blade.php | 104 ++---
routes/api.php | 1 -
tests/Feature/Api/ServerTransferApiTest.php | 55 ++-
.../Feature/Livewire/ServerTransferUiTest.php | 62 ++-
.../Feature/ServerManagementOwnershipTest.php | 38 +-
.../ServerTransferClaimerTest.php | 13 +-
.../ServerTransferExporterImporterTest.php | 1 -
.../ServerTransferMigratorTest.php | 26 +-
23 files changed, 357 insertions(+), 1082 deletions(-)
diff --git a/app/Console/Commands/Generate/OpenApi.php b/app/Console/Commands/Generate/OpenApi.php
index 224c107928..d1e018b0c4 100644
--- a/app/Console/Commands/Generate/OpenApi.php
+++ b/app/Console/Commands/Generate/OpenApi.php
@@ -20,6 +20,9 @@ class OpenApi extends Command
$process = Process::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',
@@ -32,7 +35,7 @@ class OpenApi extends Command
echo $process->output();
$yaml = file_get_contents('openapi.yaml');
-
+
$json = json_encode(Yaml::parse($yaml), JSON_PRETTY_PRINT)."\n";
file_put_contents('openapi.json', $json);
echo "Converted OpenAPI YAML to JSON.\n";
diff --git a/app/Http/Controllers/Api/ServerTransferController.php b/app/Http/Controllers/Api/ServerTransferController.php
index 5c4d675110..f43189abf8 100644
--- a/app/Http/Controllers/Api/ServerTransferController.php
+++ b/app/Http/Controllers/Api/ServerTransferController.php
@@ -43,8 +43,6 @@ 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: 'write_remote', type: 'boolean', default: false),
- new OA\Property(property: 'rebind_sentinel', type: 'boolean', default: true),
new OA\Property(property: 'preserve_uuids', type: 'boolean', default: true),
new OA\Property(property: 'adopt_mode', type: 'boolean', default: true),
]
@@ -85,12 +83,10 @@ class ServerTransferController extends Controller
$validator = customApiValidator($request->all(), [
'target_url' => 'required|string|url',
'target_token' => 'required|string',
- 'write_remote' => 'boolean|nullable',
- 'rebind_sentinel' => 'boolean|nullable',
'preserve_uuids' => 'boolean|nullable',
'adopt_mode' => 'boolean|nullable',
]);
- $allowedFields = ['target_url', 'target_token', 'write_remote', 'rebind_sentinel', 'preserve_uuids', 'adopt_mode'];
+ $allowedFields = ['target_url', 'target_token', 'preserve_uuids', 'adopt_mode'];
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || $extraFields !== []) {
$errors = $validator->errors();
@@ -109,8 +105,6 @@ class ServerTransferController extends Controller
server: $server,
targetUrl: $request->string('target_url')->toString(),
targetToken: $request->string('target_token')->toString(),
- writeRemote: $request->boolean('write_remote', false),
- rebindSentinel: $request->boolean('rebind_sentinel', true),
preserveUuids: $request->boolean('preserve_uuids', true),
adoptMode: $request->boolean('adopt_mode', true),
);
@@ -190,7 +184,7 @@ class ServerTransferController extends Controller
#[OA\Post(
summary: 'Import server transfer bundle',
- description: 'Import a server transfer bundle into this Coolify instance (adopt mode by default).',
+ 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.',
path: '/servers/import',
operationId: 'import-server-transfer-bundle',
security: [['bearerAuth' => []]],
@@ -204,9 +198,6 @@ class ServerTransferController extends Controller
new OA\Property(property: 'dry_run', type: 'boolean', default: false),
new OA\Property(property: 'preserve_uuids', type: 'boolean', default: true),
new OA\Property(property: 'adopt_mode', type: 'boolean', default: true, description: 'Import without forcing redeploy; keep statuses for adoption'),
- new OA\Property(property: 'claim', type: 'boolean', default: true, description: 'Automatically claim the host for this instance after import'),
- new OA\Property(property: 'write_remote', type: 'boolean', default: false, description: 'When claiming, write ownership file on the host via SSH'),
- new OA\Property(property: 'rebind_sentinel', type: 'boolean', default: true, description: 'When claiming, rebind Sentinel to this instance'),
]
)
),
@@ -237,11 +228,8 @@ class ServerTransferController extends Controller
'dry_run' => 'boolean|nullable',
'preserve_uuids' => 'boolean|nullable',
'adopt_mode' => 'boolean|nullable',
- 'claim' => 'boolean|nullable',
- 'write_remote' => 'boolean|nullable',
- 'rebind_sentinel' => 'boolean|nullable',
]);
- $allowedFields = ['bundle', 'passphrase', 'dry_run', 'preserve_uuids', 'adopt_mode', 'claim', 'write_remote', 'rebind_sentinel'];
+ $allowedFields = ['bundle', 'passphrase', 'dry_run', 'preserve_uuids', 'adopt_mode'];
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || $extraFields !== []) {
$errors = $validator->errors();
@@ -274,9 +262,6 @@ class ServerTransferController extends Controller
dryRun: $request->boolean('dry_run', false),
preserveUuids: $request->boolean('preserve_uuids', true),
adoptMode: $request->boolean('adopt_mode', true),
- claim: $request->boolean('claim', true),
- writeRemote: $request->boolean('write_remote', false),
- rebindSentinel: $request->boolean('rebind_sentinel', true),
);
} catch (Throwable $e) {
$status = $e instanceof ValidationException ? 422 : 422;
@@ -298,73 +283,6 @@ class ServerTransferController extends Controller
return response()->json($result, $result['dry_run'] ? 200 : 201);
}
- #[OA\Post(
- summary: 'Claim imported server',
- description: 'Claim a managed host for this instance: write ownership file and rebind Sentinel.',
- path: '/servers/{uuid}/claim',
- operationId: 'claim-server',
- security: [['bearerAuth' => []]],
- tags: ['Servers'],
- parameters: [
- new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
- ],
- requestBody: new OA\RequestBody(
- content: new OA\JsonContent(
- properties: [
- new OA\Property(property: 'write_remote', type: 'boolean', default: true),
- new OA\Property(property: 'rebind_sentinel', type: 'boolean', default: true),
- ]
- )
- ),
- responses: [
- new OA\Response(response: 200, description: 'Claim result'),
- new OA\Response(response: 404, ref: '#/components/responses/404'),
- ]
- )]
- public function claim(Request $request, string $uuid): JsonResponse
- {
- $teamId = getTeamIdFromToken();
- if (is_null($teamId)) {
- return invalidTokenResponse();
- }
-
- $server = Server::whereTeamId($teamId)->whereUuid($uuid)->first();
- if (! $server) {
- return response()->json(['message' => 'Server not found.'], 404);
- }
-
- $this->authorize('update', $server);
-
- $validator = customApiValidator($request->all(), [
- 'write_remote' => 'boolean|nullable',
- 'rebind_sentinel' => 'boolean|nullable',
- ]);
- if ($validator->fails()) {
- return response()->json([
- 'message' => 'Validation failed.',
- 'errors' => $validator->errors(),
- ], 422);
- }
-
- try {
- $result = $this->claimer->claim(
- $server,
- writeRemote: $request->boolean('write_remote', true),
- rebindSentinel: $request->boolean('rebind_sentinel', true),
- );
- } catch (Throwable $e) {
- return response()->json(['message' => $e->getMessage()], 422);
- }
-
- auditLog('api.server.claim', [
- 'team_id' => $teamId,
- 'server_uuid' => $server->uuid,
- 'claim_written' => $result['claim_written'],
- ]);
-
- return response()->json($result);
- }
-
#[OA\Post(
summary: 'Mark server transferred',
description: 'Source-instance step: disable automations after a successful export/import handoff.',
diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php
index 61339a1c23..d7e3852e30 100644
--- a/app/Livewire/Server/Show.php
+++ b/app/Livewire/Server/Show.php
@@ -371,11 +371,11 @@ class Show extends Component
}
if ($this->server->isTransferredAway()) {
- $claimer->claim($this->server, writeRemote: false, rebindSentinel: true);
+ $claimer->claim($this->server);
$event = 'ui.server.management_enabled';
$message = 'This Coolify instance now manages the server.';
} else {
- $claimer->markTransferred($this->server);
+ $claimer->markTransferred($this->server, managementDisabled: true);
$event = 'ui.server.management_disabled';
$message = 'Server automations are disabled on this Coolify instance.';
}
diff --git a/app/Livewire/Server/Transfer.php b/app/Livewire/Server/Transfer.php
index 0573aba0f7..8ae349ec3f 100644
--- a/app/Livewire/Server/Transfer.php
+++ b/app/Livewire/Server/Transfer.php
@@ -4,7 +4,6 @@ namespace App\Livewire\Server;
use App\Models\Server;
use App\Services\ServerTransfer\ServerTransferBundle;
-use App\Services\ServerTransfer\ServerTransferClaimer;
use App\Services\ServerTransfer\ServerTransferExporter;
use App\Services\ServerTransfer\ServerTransferMigrator;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
@@ -22,18 +21,9 @@ class Transfer extends Component
public string $targetToken = '';
- public bool $writeRemote = false;
-
- /** Advanced */
- public bool $showAdvanced = false;
-
- public string $passphrase = '';
-
- public bool $encryptBundle = false;
-
- public bool $writeRemoteOnClaim = false;
-
- public bool $rebindSentinelOnClaim = true;
+ /** Manual transfer */
+ /** Encrypts the downloaded file when filled; empty or whitespace means no encryption. */
+ public ?string $passphrase = null;
public ?string $exportId = null;
@@ -56,11 +46,6 @@ class Transfer extends Component
}
}
- public function getTransferStatusProperty(): ?string
- {
- return data_get($this->server->fresh()->server_metadata, 'transfer.status');
- }
-
public function getIsLocalhostProperty(): bool
{
return (int) $this->server->id === 0;
@@ -80,7 +65,6 @@ class Transfer extends Component
server: $this->server,
targetUrl: $this->targetUrl,
targetToken: $this->targetToken,
- writeRemote: $this->writeRemote,
);
$this->server->refresh();
@@ -114,10 +98,7 @@ class Transfer extends Component
$payload = $bundle;
$fileName = 'server-transfer-'.$this->server->uuid.'.json';
- if ($this->encryptBundle) {
- if (blank($this->passphrase)) {
- throw new \RuntimeException('Passphrase is required to encrypt the bundle.');
- }
+ if (filled($this->passphrase)) {
$payload = ServerTransferBundle::encryptWithPassphrase($bundle, $this->passphrase);
$fileName = 'server-transfer-'.$this->server->uuid.'.encrypted.json';
}
@@ -139,53 +120,6 @@ class Transfer extends Component
}
}
- public function completeTransfer(ServerTransferClaimer $claimer): void
- {
- $this->ensureDevelopmentAvailability();
-
- try {
- $this->authorize('update', $this->server);
- if ($this->isLocalhost) {
- throw new \RuntimeException('The Coolify host cannot be marked as transferred.');
- }
-
- $result = $claimer->markTransferred(
- $this->server,
- exportId: $this->exportId ?: data_get($this->server->server_metadata, 'transfer.export_id'),
- targetInstanceUrl: filled($this->targetUrl) ? rtrim($this->targetUrl, '/') : null,
- );
- $this->server->refresh();
- $this->lastResultJson = json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
- $this->dispatch('success', $result['message'] ?? 'Server marked as transferred.');
- } catch (Throwable $e) {
- handleError($e, $this);
- }
- }
-
- public function claimServer(ServerTransferClaimer $claimer): void
- {
- $this->ensureDevelopmentAvailability();
-
- try {
- $this->authorize('update', $this->server);
- if ($this->isLocalhost) {
- throw new \RuntimeException('The Coolify host cannot be claimed.');
- }
-
- $result = $claimer->claim(
- $this->server,
- writeRemote: $this->writeRemoteOnClaim,
- rebindSentinel: $this->rebindSentinelOnClaim,
- );
- $this->server->refresh();
- $this->lastResultJson = json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
- $this->lastWarnings = [];
- $this->dispatch('success', $result['message'] ?? 'Server claimed.');
- } catch (Throwable $e) {
- handleError($e, $this);
- }
- }
-
public function render()
{
return view('livewire.server.transfer');
diff --git a/app/Livewire/Server/TransferImport.php b/app/Livewire/Server/TransferImport.php
index 9fe37c10ca..97bb6c5c7d 100644
--- a/app/Livewire/Server/TransferImport.php
+++ b/app/Livewire/Server/TransferImport.php
@@ -24,9 +24,6 @@ class TransferImport extends Component
public bool $adoptMode = true;
- /** Write ownership file on the host via SSH when claiming after import. */
- public bool $writeRemote = false;
-
/** @var TemporaryUploadedFile|null */
public $bundleFile = null;
@@ -114,9 +111,6 @@ class TransferImport extends Component
dryRun: $dryRun,
preserveUuids: $this->preserveUuids,
adoptMode: $this->adoptMode,
- claim: ! $dryRun,
- writeRemote: $this->writeRemote,
- rebindSentinel: true,
);
$this->lastResult = $result;
@@ -135,9 +129,9 @@ class TransferImport extends Component
if ($dryRun) {
$this->dispatch('success', 'Dry run completed — nothing was written.');
} elseif (data_get($result, 'claimed')) {
- $this->dispatch('success', 'Server imported and claimed for this instance.');
+ $this->dispatch('success', 'Server imported. This instance now manages it.');
} else {
- $this->dispatch('success', 'Server imported. Claim did not complete — check warnings or re-claim from the server Transfer page.');
+ $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);
diff --git a/app/Models/Server.php b/app/Models/Server.php
index c5bb8814fb..00eaad8839 100644
--- a/app/Models/Server.php
+++ b/app/Models/Server.php
@@ -1013,6 +1013,15 @@ $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.
*/
diff --git a/app/Services/ServerTransfer/ServerTransferBundle.php b/app/Services/ServerTransfer/ServerTransferBundle.php
index cbc1594034..692842d085 100644
--- a/app/Services/ServerTransfer/ServerTransferBundle.php
+++ b/app/Services/ServerTransfer/ServerTransferBundle.php
@@ -10,8 +10,6 @@ class ServerTransferBundle
{
public const SCHEMA_VERSION = 1;
- public const CLAIM_PATH = '/data/coolify/instance-claim.json';
-
public const MAILBOX_DIR = '/data/coolify/exports';
/**
diff --git a/app/Services/ServerTransfer/ServerTransferClaimer.php b/app/Services/ServerTransfer/ServerTransferClaimer.php
index 164fac444d..dcdfb01bb4 100644
--- a/app/Services/ServerTransfer/ServerTransferClaimer.php
+++ b/app/Services/ServerTransfer/ServerTransferClaimer.php
@@ -11,21 +11,18 @@ use Throwable;
class ServerTransferClaimer
{
/**
- * Claim a managed host for this Coolify instance.
+ * 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. Remote SSH claim-file writes happen after commit and are
- * best-effort — they cannot participate in the DB transaction.
+ * mid-flight failure rolls back.
*
* @return array{
* server_uuid: string,
* claim: array,
- * sentinel_rebound: bool,
- * claim_written: bool,
* message: string
* }
*/
- public function claim(Server $server, bool $writeRemote = true, bool $rebindSentinel = true): array
+ public function claim(Server $server): array
{
if ($server->id === 0) {
throw new RuntimeException('Cannot claim the Coolify host itself.');
@@ -47,18 +44,13 @@ class ServerTransferClaimer
'schema_version' => ServerTransferBundle::SCHEMA_VERSION,
];
- $result = DB::transaction(function () use ($server, $claim, $rebindSentinel, $instanceUrl) {
+ return DB::transaction(function () use ($server, $claim, $instanceUrl) {
$server = Server::query()->with('settings')->lockForUpdate()->findOrFail($server->id);
- $sentinelRebound = false;
- if ($rebindSentinel && $server->settings) {
+ if ($server->settings) {
$server->settings->sentinel_custom_url = $instanceUrl;
$server->settings->ensureValidSentinelToken();
$server->settings->is_sentinel_enabled = true;
- $sentinelRebound = true;
- }
-
- if ($server->settings) {
if (! $server->team->serverOverflow()) {
$server->settings->force_disabled = false;
}
@@ -70,8 +62,6 @@ class ServerTransferClaimer
'status' => 'claimed',
'claimed_at' => $claim['claimed_at'],
'claim' => $claim,
- 'claim_written' => false,
- 'sentinel_rebound' => $sentinelRebound,
]);
$server->server_metadata = $metadata;
$server->save();
@@ -79,26 +69,9 @@ class ServerTransferClaimer
return [
'server_uuid' => $server->uuid,
'claim' => $claim,
- 'sentinel_rebound' => $sentinelRebound,
- 'claim_written' => false,
+ 'message' => 'This Coolify instance now manages the server.',
];
});
-
- // Remote host I/O is outside the transaction (cannot be rolled back with DB rows).
- $claimWritten = false;
- if ($writeRemote) {
- $claimWritten = $this->writeClaimFile($server, $claim);
- if ($claimWritten) {
- $this->persistClaimWritten($server);
- }
- }
-
- $result['claim_written'] = $claimWritten;
- $result['message'] = $claimWritten
- ? 'Server claimed. Ownership file written and Sentinel rebound to this instance.'
- : 'Server claimed in Coolify. Remote ownership file was not written (SSH unavailable or skipped).';
-
- return $result;
}
/**
@@ -108,15 +81,18 @@ 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): array
+ public function markTransferred(Server $server, ?string $exportId = null, ?string $targetInstanceUrl = null, bool $managementDisabled = false): array
{
if ($server->id === 0) {
throw new RuntimeException('Cannot transfer the Coolify host itself.');
}
- $result = DB::transaction(function () use ($server, $exportId, $targetInstanceUrl) {
+ $result = DB::transaction(function () use ($server, $exportId, $targetInstanceUrl, $managementDisabled) {
$server = Server::query()->with('settings')->lockForUpdate()->findOrFail($server->id);
if ($server->settings) {
@@ -131,6 +107,7 @@ class ServerTransferClaimer
'export_id' => $exportId ?? data_get($metadata, 'transfer.export_id'),
'target_instance_url' => $targetInstanceUrl,
'transferred_at' => now()->toIso8601String(),
+ 'management_disabled' => $managementDisabled,
]);
$server->server_metadata = $metadata;
$server->save();
@@ -146,21 +123,6 @@ class ServerTransferClaimer
return $result;
}
- private function persistClaimWritten(Server $server): void
- {
- DB::transaction(function () use ($server) {
- $server = Server::query()->lockForUpdate()->find($server->id);
- if (! $server) {
- return;
- }
-
- $metadata = $server->server_metadata ?? [];
- data_set($metadata, 'transfer.claim_written', true);
- $server->server_metadata = $metadata;
- $server->save();
- });
- }
-
/**
* Best-effort local SSH mux/key cleanup after transfer (filesystem; not part of DB rollback).
* forceDisableServer is idempotent for force_disabled and also clears cached SSH key material.
@@ -223,32 +185,4 @@ class ServerTransferClaimer
'written' => $written,
];
}
-
- /**
- * @param array $claim
- */
- private function writeClaimFile(Server $server, array $claim): bool
- {
- $json = json_encode($claim, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
- $b64 = base64_encode($json);
- $path = ServerTransferBundle::CLAIM_PATH;
-
- try {
- instant_remote_process([
- 'mkdir -p /data/coolify',
- 'echo '.escapeshellarg($b64).' | base64 -d > '.escapeshellarg($path),
- 'chmod 600 '.escapeshellarg($path),
- 'chown 9999:root '.escapeshellarg($path).' || true',
- ], $server, true);
-
- return true;
- } catch (Throwable $e) {
- Log::warning('Failed to write instance claim file', [
- 'server_uuid' => $server->uuid,
- 'error' => $e->getMessage(),
- ]);
-
- return false;
- }
- }
}
diff --git a/app/Services/ServerTransfer/ServerTransferImporter.php b/app/Services/ServerTransfer/ServerTransferImporter.php
index 4642cf8e53..c3c59443d0 100644
--- a/app/Services/ServerTransfer/ServerTransferImporter.php
+++ b/app/Services/ServerTransfer/ServerTransferImporter.php
@@ -108,9 +108,6 @@ class ServerTransferImporter
bool $dryRun = false,
bool $preserveUuids = true,
bool $adoptMode = true,
- bool $claim = true,
- bool $writeRemote = false,
- bool $rebindSentinel = true,
): array {
ServerTransferBundle::assertValid($bundle);
@@ -344,21 +341,13 @@ class ServerTransferImporter
];
});
- // Claim after the import transaction commits so host/SSH work cannot roll back DB rows.
- if ($claim && filled(data_get($result, 'server_uuid'))) {
+ // Claim after the import transaction commits so a claim failure cannot roll back DB rows.
+ if (filled(data_get($result, 'server_uuid'))) {
$server = Server::where('uuid', $result['server_uuid'])->where('team_id', $teamId)->first();
if ($server) {
try {
- $claimResult = app(ServerTransferClaimer::class)->claim(
- $server,
- writeRemote: $writeRemote,
- rebindSentinel: $rebindSentinel,
- );
+ $result['claim'] = app(ServerTransferClaimer::class)->claim($server);
$result['claimed'] = true;
- $result['claim'] = $claimResult;
- if (! data_get($claimResult, 'claim_written') && $writeRemote) {
- $result['warnings'][] = 'Server imported and claimed in Coolify, but the remote ownership file was not written (SSH unavailable).';
- }
} catch (Throwable $e) {
$result['claimed'] = false;
$result['claim'] = null;
diff --git a/app/Services/ServerTransfer/ServerTransferMigrator.php b/app/Services/ServerTransfer/ServerTransferMigrator.php
index d65bd7da00..2ec3b6c63d 100644
--- a/app/Services/ServerTransfer/ServerTransferMigrator.php
+++ b/app/Services/ServerTransfer/ServerTransferMigrator.php
@@ -34,8 +34,6 @@ class ServerTransferMigrator
Server $server,
string $targetUrl,
string $targetToken,
- bool $writeRemote = false,
- bool $rebindSentinel = true,
bool $preserveUuids = true,
bool $adoptMode = true,
): array {
@@ -56,8 +54,6 @@ class ServerTransferMigrator
targetUrl: $targetUrl,
token: $token,
bundle: $bundle,
- writeRemote: $writeRemote,
- rebindSentinel: $rebindSentinel,
preserveUuids: $preserveUuids,
adoptMode: $adoptMode,
);
@@ -66,8 +62,17 @@ class ServerTransferMigrator
$warnings = array_values(array_unique(array_merge($warnings, $importBody['warnings'])));
}
+ // Keep managing the server here until the target really manages it, so it is never left unmanaged.
+ 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. '.
+ 'Target warnings: '.(implode(' ', (array) data_get($importBody, 'warnings', [])) ?: 'none')
+ );
+ }
+
// Cross-instance 2PC is impossible: if complete fails after a successful import, the target
- // already owns the server. Surface that clearly so the operator can retry complete only.
+ // already owns the server. Surface that clearly so the operator can disable management here.
try {
$complete = $this->claimer->markTransferred(
$server,
@@ -77,7 +82,7 @@ class ServerTransferMigrator
} catch (Throwable $e) {
throw new RuntimeException(
"Server was imported on {$targetUrl}, but this instance could not mark it as transferred: {$e->getMessage()}. ".
- 'Retry complete (API: POST /api/v1/servers/{uuid}/complete) so automations stay disabled here. Do not re-import on the target.',
+ '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.',
previous: $e
);
}
@@ -147,8 +152,6 @@ class ServerTransferMigrator
string $targetUrl,
string $token,
array $bundle,
- bool $writeRemote,
- bool $rebindSentinel,
bool $preserveUuids,
bool $adoptMode,
): array {
@@ -167,9 +170,6 @@ class ServerTransferMigrator
'dry_run' => false,
'preserve_uuids' => $preserveUuids,
'adopt_mode' => $adoptMode,
- 'claim' => true,
- 'write_remote' => $writeRemote,
- 'rebind_sentinel' => $rebindSentinel,
]);
} catch (ConnectionException $e) {
throw new RuntimeException("Could not reach target instance at {$importUrl}: {$e->getMessage()}", previous: $e);
diff --git a/openapi.json b/openapi.json
index ec644582c4..512f94b9e6 100644
--- a/openapi.json
+++ b/openapi.json
@@ -16407,382 +16407,6 @@
]
}
},
- "\/servers\/{uuid}\/migrate": {
- "post": {
- "tags": [
- "Servers"
- ],
- "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.",
- "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"
- },
- "write_remote": {
- "type": "boolean",
- "default": false
- },
- "rebind_sentinel": {
- "type": "boolean",
- "default": true
- },
- "preserve_uuids": {
- "type": "boolean",
- "default": true
- },
- "adopt_mode": {
- "type": "boolean",
- "default": true
- }
- },
- "type": "object"
- }
- }
- }
- },
- "responses": {
- "200": {
- "description": "Migrated"
- },
- "403": {
- "description": "Missing sensitive permission"
- },
- "404": {
- "$ref": "#\/components\/responses\/404"
- },
- "422": {
- "description": "Validation or remote import failed"
- }
- },
- "security": [
- {
- "bearerAuth": []
- }
- ]
- }
- },
- "\/servers\/{uuid}\/export": {
- "get": {
- "tags": [
- "Servers"
- ],
- "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.",
- "operationId": "export-server-transfer-bundle",
- "parameters": [
- {
- "name": "uuid",
- "in": "path",
- "required": true,
- "schema": {
- "type": "string"
- }
- },
- {
- "name": "encrypt",
- "in": "query",
- "description": "If true and passphrase is provided, return an encrypted envelope.",
- "required": false,
- "schema": {
- "type": "boolean"
- }
- },
- {
- "name": "passphrase",
- "in": "query",
- "description": "Passphrase used when encrypt=true.",
- "required": false,
- "schema": {
- "type": "string"
- }
- }
- ],
- "responses": {
- "200": {
- "description": "Transfer bundle"
- },
- "401": {
- "$ref": "#\/components\/responses\/401"
- },
- "403": {
- "description": "Missing sensitive permission"
- },
- "404": {
- "$ref": "#\/components\/responses\/404"
- }
- },
- "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).",
- "operationId": "import-server-transfer-bundle",
- "requestBody": {
- "required": true,
- "content": {
- "application\/json": {
- "schema": {
- "properties": {
- "bundle": {
- "description": "Plain or encrypted transfer bundle",
- "type": "object"
- },
- "passphrase": {
- "type": [
- "string",
- "null"
- ]
- },
- "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
- },
- "claim": {
- "description": "Automatically claim the host for this instance after import",
- "type": "boolean",
- "default": true
- },
- "write_remote": {
- "description": "When claiming, write ownership file on the host via SSH",
- "type": "boolean",
- "default": false
- },
- "rebind_sentinel": {
- "description": "When claiming, rebind Sentinel to this instance",
- "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}\/claim": {
- "post": {
- "tags": [
- "Servers"
- ],
- "summary": "Claim imported server",
- "description": "Claim a managed host for this instance: write ownership file and rebind Sentinel.",
- "operationId": "claim-server",
- "parameters": [
- {
- "name": "uuid",
- "in": "path",
- "required": true,
- "schema": {
- "type": "string"
- }
- }
- ],
- "requestBody": {
- "content": {
- "application\/json": {
- "schema": {
- "properties": {
- "write_remote": {
- "type": "boolean",
- "default": true
- },
- "rebind_sentinel": {
- "type": "boolean",
- "default": true
- }
- },
- "type": "object"
- }
- }
- }
- },
- "responses": {
- "200": {
- "description": "Claim result"
- },
- "404": {
- "$ref": "#\/components\/responses\/404"
- }
- },
- "security": [
- {
- "bearerAuth": []
- }
- ]
- }
- },
- "\/servers\/{uuid}\/transfer\/complete": {
- "post": {
- "tags": [
- "Servers"
- ],
- "summary": "Mark server transferred",
- "description": "Source-instance step: disable automations after a successful export\/import handoff.",
- "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\/{uuid}\/export\/mailbox": {
- "post": {
- "tags": [
- "Servers"
- ],
- "summary": "Write transfer bundle to server mailbox",
- "description": "Write an export bundle to \/data\/coolify\/exports on the managed host for air-gapped import.",
- "operationId": "export-server-transfer-mailbox",
- "parameters": [
- {
- "name": "uuid",
- "in": "path",
- "required": true,
- "schema": {
- "type": "string"
- }
- }
- ],
- "requestBody": {
- "content": {
- "application\/json": {
- "schema": {
- "properties": {
- "passphrase": {
- "type": [
- "string",
- "null"
- ]
- }
- },
- "type": "object"
- }
- }
- }
- },
- "responses": {
- "200": {
- "description": "Mailbox write result"
- },
- "403": {
- "description": "Missing sensitive permission"
- },
- "404": {
- "$ref": "#\/components\/responses\/404"
- }
- },
- "security": [
- {
- "bearerAuth": []
- }
- ]
- }
- },
"\/servers": {
"get": {
"tags": [
diff --git a/openapi.yaml b/openapi.yaml
index dd507d853f..e6261818b1 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -10406,251 +10406,6 @@ paths:
security:
-
bearerAuth: []
- '/servers/{uuid}/migrate':
- post:
- tags:
- - Servers
- 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.'
- 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
- write_remote:
- type: boolean
- default: false
- rebind_sentinel:
- type: boolean
- default: true
- preserve_uuids:
- type: boolean
- default: true
- adopt_mode:
- type: boolean
- default: true
- type: object
- responses:
- '200':
- description: Migrated
- '403':
- description: 'Missing sensitive permission'
- '404':
- $ref: '#/components/responses/404'
- '422':
- description: 'Validation or remote import failed'
- security:
- -
- bearerAuth: []
- '/servers/{uuid}/export':
- get:
- tags:
- - Servers
- 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.'
- operationId: export-server-transfer-bundle
- parameters:
- -
- name: uuid
- in: path
- required: true
- schema:
- type: string
- -
- name: encrypt
- in: query
- description: 'If true and passphrase is provided, return an encrypted envelope.'
- required: false
- schema:
- type: boolean
- -
- name: passphrase
- in: query
- description: 'Passphrase used when encrypt=true.'
- required: false
- schema:
- type: string
- responses:
- '200':
- description: 'Transfer bundle'
- '401':
- $ref: '#/components/responses/401'
- '403':
- description: 'Missing sensitive permission'
- '404':
- $ref: '#/components/responses/404'
- 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).'
- operationId: import-server-transfer-bundle
- requestBody:
- required: true
- content:
- application/json:
- schema:
- properties:
- bundle:
- description: 'Plain or encrypted transfer bundle'
- type: object
- passphrase:
- type: [string, 'null']
- 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
- claim:
- description: 'Automatically claim the host for this instance after import'
- type: boolean
- default: true
- write_remote:
- description: 'When claiming, write ownership file on the host via SSH'
- type: boolean
- default: false
- rebind_sentinel:
- description: 'When claiming, rebind Sentinel to this instance'
- 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}/claim':
- post:
- tags:
- - Servers
- summary: 'Claim imported server'
- description: 'Claim a managed host for this instance: write ownership file and rebind Sentinel.'
- operationId: claim-server
- parameters:
- -
- name: uuid
- in: path
- required: true
- schema:
- type: string
- requestBody:
- content:
- application/json:
- schema:
- properties:
- write_remote:
- type: boolean
- default: true
- rebind_sentinel:
- type: boolean
- default: true
- type: object
- responses:
- '200':
- description: 'Claim result'
- '404':
- $ref: '#/components/responses/404'
- security:
- -
- bearerAuth: []
- '/servers/{uuid}/transfer/complete':
- post:
- tags:
- - Servers
- summary: 'Mark server transferred'
- description: 'Source-instance step: disable automations after a successful export/import handoff.'
- 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/{uuid}/export/mailbox':
- post:
- tags:
- - Servers
- summary: 'Write transfer bundle to server mailbox'
- description: 'Write an export bundle to /data/coolify/exports on the managed host for air-gapped import.'
- operationId: export-server-transfer-mailbox
- parameters:
- -
- name: uuid
- in: path
- required: true
- schema:
- type: string
- requestBody:
- content:
- application/json:
- schema:
- properties:
- passphrase:
- type: [string, 'null']
- type: object
- responses:
- '200':
- description: 'Mailbox write result'
- '403':
- description: 'Missing sensitive permission'
- '404':
- $ref: '#/components/responses/404'
- security:
- -
- bearerAuth: []
/servers:
get:
tags:
diff --git a/resources/views/livewire/server/index.blade.php b/resources/views/livewire/server/index.blade.php
index dbfd825322..5258673700 100644
--- a/resources/views/livewire/server/index.blade.php
+++ b/resources/views/livewire/server/index.blade.php
@@ -11,7 +11,7 @@
- Import transfer
+ Import server
@endcan
@@ -28,7 +28,7 @@
@php
$serverRows = $servers->map(function ($server) {
- $isTransferredAway = $server->isTransferredAway();
+ $isTransferredAway = isDev() && $server->isTransferredAway();
$isReady = $server->settings->is_reachable
&& $server->settings->is_usable
&& ! $server->settings->force_disabled
@@ -38,6 +38,7 @@
$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',
diff --git a/resources/views/livewire/server/show.blade.php b/resources/views/livewire/server/show.blade.php
index 533a33858a..8f4bdea7d5 100644
--- a/resources/views/livewire/server/show.blade.php
+++ b/resources/views/livewire/server/show.blade.php
@@ -111,18 +111,20 @@
@endif
-
- Validate and configure
-
-
-
-
-
- {{ $server->isFunctional() ? 'Revalidate connection' : 'Validate connection' }}
-
-
+ @if ($server->canBeValidated())
+
+ Validate and configure
+
+
+
+
+
+ {{ $server->isFunctional() ? 'Revalidate connection' : 'Validate connection' }}
+
+
+ @endif
@if (isDev())
@if ($server->isTransferredAway())
@@ -156,15 +158,24 @@
@endif
@endif
- @if ($server->isTransferredAway())
-
+ @if (isDev() && $server->isTransferredAway())
+ @unless ($server->isManagementDisabled())
+
+ @endunless
@else
@endif
- @if ($server->isTransferredAway())
+ @if (isDev() && $server->isManagementDisabled())
+
+ Management of this server is disabled on this Coolify instance. You can now
+ transfer it to another instance , or enable
+ management again to manage it here.
+
+ @elseif (isDev() && $server->isTransferredAway())
This server was migrated away from this Coolify instance. It cannot be revalidated or
managed here. Use the target instance, or delete this server when you no longer need the
@@ -299,7 +310,9 @@
{{ $server->name }}
- @if ($server->isTransferredAway())
+ @if (isDev() && $server->isManagementDisabled())
+ Management is disabled on this Coolify instance. You can transfer this server to another instance.
+ @elseif (isDev() && $server->isTransferredAway())
This server was migrated away from this Coolify instance and cannot be managed here.
@elseif ($server->isFunctional())
The server is reachable, validated, and ready to host resources.
diff --git a/resources/views/livewire/server/transfer-import.blade.php b/resources/views/livewire/server/transfer-import.blade.php
index 43a6661caf..d55a4d320b 100644
--- a/resources/views/livewire/server/transfer-import.blade.php
+++ b/resources/views/livewire/server/transfer-import.blade.php
@@ -1,79 +1,128 @@
-
+
- Import server transfer | Coolify
+ Import server | Coolify
-
-
-
- Paste or upload a transfer bundle exported from another Coolify instance. This creates the server and its
- resources under the current team and claims the host for this instance
- (control-plane only — host data stays on the machine).
-
-
-
-
-
-
-
-
-
+
+
+
+
Import server
+
-
+
+ Import a server that you downloaded from another Coolify instance. This instance then manages it.
+
+
+
+ Back to servers
+
+
+
+
+
+
+
+
+
+
+
+
@if (count($lastWarnings) > 0)
-
-
Warnings
-
+
+
@foreach ($lastWarnings as $warning)
{{ $warning }}
@endforeach
-
+
@endif
@if ($lastResult)
-
-
- {{ data_get($lastResult, 'dry_run') ? 'Dry-run result' : 'Import result' }}
+ @php
+ $createdCounts = collect(data_get($lastResult, 'created', []))
+ ->filter(fn ($count) => (int) $count > 0);
+ @endphp
+
+
+ @if (data_get($lastResult, 'dry_run'))
+
+ @elseif (data_get($lastResult, 'claimed'))
+
+ @else
+
+ @endif
+ @if ($importedServerUuid)
+
+ Open server
+
+
+ @endif
+
+
+
+ @if (filled(data_get($lastResult, 'server_uuid')))
+
+ Server UUID
+ {{ data_get($lastResult, 'server_uuid') }}
+
+ @endif
+
+ @if ($createdCounts->isNotEmpty())
+
+ @foreach ($createdCounts as $type => $count)
+
+
+ {{ $count }}
+
+ {{ str($type)->replace('_', ' ')->ucfirst() }}
+
+ @endforeach
+
+ @else
+
No resources in this file.
+ @endif
- @if ($importedServerUuid)
-
-
Server UUID: {{ $importedServerUuid }}
- @if (data_get($lastResult, 'claimed'))
-
Claimed
- @endif
-
- Open server
-
-
- Transfer details
-
-
- @endif
- {{ json_encode($lastResult, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}
-
+
@endif
diff --git a/resources/views/livewire/server/transfer.blade.php b/resources/views/livewire/server/transfer.blade.php
index 901e65493c..0b322fc6ca 100644
--- a/resources/views/livewire/server/transfer.blade.php
+++ b/resources/views/livewire/server/transfer.blade.php
@@ -21,9 +21,13 @@
-
+ @if ($server->isManagementDisabled())
+
+ @elseif ($server->isTransferredAway())
+
+ @else
+
+ @endif
@if ($exportId)
export {{ $exportId }}
@endif
@@ -35,8 +39,8 @@
Enter the target Coolify URL and an API token from that instance (root recommended).
- This exports the server, imports and claims it on the target, then disables automations
- here.
+ This exports the server and imports it on the target, which then manages it. Management
+ is then disabled here.
@@ -46,8 +50,6 @@
-
-
-
-
- Show advanced options
-
-
-
-
-
-
Download bundle
-
- Manual / air-gapped transfer. Import on the target via Servers → Import transfer.
-
-
-
-
-
-
-
-
-
-
Complete only
-
- Disable automations after a manual import on the target.
-
-
-
-
- Mark transferred & disable automations
-
-
-
-
-
-
-
Re-claim only
-
- Retry claim on this instance (after a local import).
-
-
-
-
-
-
- Re-claim
-
-
+
+
+
+ Import the file on the target via Servers → Import server, then click Disable management
+ on this server.
+
+
+
+
+
diff --git a/routes/api.php b/routes/api.php
index a17edb3528..ca756b828c 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -200,7 +200,6 @@ Route::group([
Route::post('/servers/import', [ServerTransferController::class, 'import'])->middleware(['api.ability:write']);
Route::get('/servers/{uuid}/export', [ServerTransferController::class, 'export'])->middleware(['api.ability:read']);
Route::post('/servers/{uuid}/export/mailbox', [ServerTransferController::class, 'writeMailbox'])->middleware(['api.ability:write']);
- Route::post('/servers/{uuid}/claim', [ServerTransferController::class, 'claim'])->middleware(['api.ability:write']);
Route::post('/servers/{uuid}/transfer/complete', [ServerTransferController::class, 'complete'])->middleware(['api.ability:write']);
Route::post('/servers/{uuid}/migrate', [ServerTransferController::class, 'migrate'])->middleware(['api.ability:write']);
Route::patch('/servers/{uuid}', [ServersController::class, 'update_server'])->middleware(['api.ability:write']);
diff --git a/tests/Feature/Api/ServerTransferApiTest.php b/tests/Feature/Api/ServerTransferApiTest.php
index 6efaf3a038..ad1f6d4387 100644
--- a/tests/Feature/Api/ServerTransferApiTest.php
+++ b/tests/Feature/Api/ServerTransferApiTest.php
@@ -88,7 +88,6 @@ test('server transfer API is unavailable outside development mode', function (st
['POST', '/api/v1/servers/import'],
['GET', '/api/v1/servers/{uuid}/export'],
['POST', '/api/v1/servers/{uuid}/export/mailbox'],
- ['POST', '/api/v1/servers/{uuid}/claim'],
['POST', '/api/v1/servers/{uuid}/transfer/complete'],
['POST', '/api/v1/servers/{uuid}/migrate'],
]);
@@ -250,7 +249,7 @@ describe('POST /api/v1/servers/import', function () {
]);
$this->withHeaders(transferHeaders($this->sensitiveToken))
- ->postJson('/api/v1/servers/import', ['bundle' => $export, 'claim' => false])
+ ->postJson('/api/v1/servers/import', ['bundle' => $export])
->assertStatus(422)
->assertJsonPath('errors.server.0', 'Server limit reached for your subscription.');
@@ -258,8 +257,8 @@ describe('POST /api/v1/servers/import', function () {
});
});
-describe('POST /api/v1/servers/{uuid}/claim', function () {
- test('claims imported server without remote write', 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();
@@ -268,27 +267,31 @@ describe('POST /api/v1/servers/{uuid}/claim', function () {
$this->server->forceDelete();
$this->privateKey->delete();
- $importedUuid = $this->withHeaders(transferHeaders($this->sensitiveToken))
+ $this->withHeaders(transferHeaders($this->sensitiveToken))
->postJson('/api/v1/servers/import', ['bundle' => $export])
- ->json('server_uuid');
+ ->assertCreated()
+ ->assertJsonPath('claimed', true)
+ ->assertJsonPath('claim.claim.instance_url', 'https://coolify-a.test');
- $response = $this->withHeaders(transferHeaders($this->sensitiveToken))
- ->postJson("/api/v1/servers/{$importedUuid}/claim", [
- 'write_remote' => false,
- 'rebind_sentinel' => true,
- ]);
-
- $response->assertOk()
- ->assertJsonPath('server_uuid', $importedUuid)
- ->assertJsonPath('claim_written', false)
- ->assertJsonPath('sentinel_rebound', true)
- ->assertJsonPath('claim.instance_url', 'https://coolify-a.test');
-
- $server = Server::where('uuid', $importedUuid)->first();
+ $server = Server::where('uuid', $export['server']['uuid'])->first();
expect(data_get($server->server_metadata, 'transfer.status'))->toBe('claimed')
+ ->and((bool) $server->settings->is_sentinel_enabled)->toBeTrue()
->and($server->settings->sentinel_custom_url)->toBe('https://coolify-a.test')
->and($server->settings->sentinel_token)->not->toBeEmpty();
});
+
+ test('claim endpoint is removed', function () {
+ $this->withHeaders(transferHeaders($this->sensitiveToken))
+ ->postJson("/api/v1/servers/{$this->server->uuid}/claim")
+ ->assertNotFound();
+ });
+
+ test('import rejects the removed claim options', function () {
+ $this->withHeaders(transferHeaders($this->sensitiveToken))
+ ->postJson('/api/v1/servers/import', ['bundle' => [], 'write_remote' => true])
+ ->assertStatus(422)
+ ->assertJsonValidationErrors(['write_remote']);
+ });
});
describe('POST /api/v1/servers/{uuid}/transfer/complete', function () {
@@ -311,7 +314,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 claim sequence', function () {
+ test('export complete import sequence', function () {
$export = $this->withHeaders(transferHeaders($this->sensitiveToken))
->getJson("/api/v1/servers/{$this->server->uuid}/export")
->assertOk()
@@ -338,22 +341,14 @@ describe('full transfer flow A to B on same process', function () {
Once::flush();
}
- $import = $this->withHeaders(transferHeaders($this->sensitiveToken))
+ $this->withHeaders(transferHeaders($this->sensitiveToken))
->postJson('/api/v1/servers/import', [
'bundle' => $export,
'preserve_uuids' => true,
'adopt_mode' => true,
])
->assertCreated()
- ->json();
-
- $this->withHeaders(transferHeaders($this->sensitiveToken))
- ->postJson("/api/v1/servers/{$import['server_uuid']}/claim", [
- 'write_remote' => false,
- 'rebind_sentinel' => true,
- ])
- ->assertOk()
- ->assertJsonPath('claim.instance_url', 'https://coolify-b.test');
+ ->assertJsonPath('claimed', true);
$server = Server::where('uuid', $export['server']['uuid'])->first();
expect($server)->not->toBeNull()
diff --git a/tests/Feature/Livewire/ServerTransferUiTest.php b/tests/Feature/Livewire/ServerTransferUiTest.php
index c3904e1dd6..e77cefc76e 100644
--- a/tests/Feature/Livewire/ServerTransferUiTest.php
+++ b/tests/Feature/Livewire/ServerTransferUiTest.php
@@ -7,6 +7,7 @@ use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
+use App\Services\ServerTransfer\ServerTransferClaimer;
use App\Services\ServerTransfer\ServerTransferExporter;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
@@ -47,13 +48,34 @@ test('server transfer links are hidden outside development mode', function () {
$this->get('/servers')
->assertOk()
- ->assertDontSee('Import transfer');
+ ->assertDontSee('/servers/import');
$this->get('/server/'.$this->server->uuid)
->assertOk()
->assertDontSee('Transfer', escape: false);
});
+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')
+ ->assertOk()
+ ->assertDontSee('Transferable')
+ ->assertDontSee('Transferred away');
+
+ $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],
+]);
+
test('transfer page renders for owned server', function () {
Livewire::test(Transfer::class, ['server_uuid' => $this->server->uuid])
->assertOk()
@@ -61,13 +83,13 @@ test('transfer page renders for owned server', function () {
->assertSee('Target instance URL')
->assertSee('Target API token')
->assertSee('Transfer server')
- ->assertSee('Advanced');
+ ->assertSee('Manual transfer');
});
test('transfer import page renders for admin', function () {
Livewire::test(TransferImport::class)
->assertOk()
- ->assertSee('Import server transfer')
+ ->assertSee('Transfer file')
->assertSee('Dry run')
->assertSee('Import server');
});
@@ -78,6 +100,35 @@ test('export bundle streams download from livewire', function () {
->assertFileDownloaded('server-transfer-'.$this->server->uuid.'.json');
});
+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'),
+ };
+
+ Livewire::test(Transfer::class, ['server_uuid' => $this->server->fresh()->uuid])
+ ->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']],
+]);
+
+test('export bundle is encrypted only when a passphrase is set', function (?string $passphrase, string $fileName) {
+ Livewire::test(Transfer::class, ['server_uuid' => $this->server->uuid])
+ ->set('passphrase', $passphrase)
+ ->call('exportBundle')
+ ->assertFileDownloaded('server-transfer-'.$this->server->uuid.$fileName);
+})->with([
+ 'null' => [null, '.json'],
+ 'empty string' => ['', '.json'],
+ 'only spaces' => [' ', '.json'],
+ 'set' => ['secret-pass', '.encrypted.json'],
+]);
+
test('import dry run from pasted json', function () {
$bundle = app(ServerTransferExporter::class)->export($this->server);
@@ -86,7 +137,9 @@ test('import dry run from pasted json', function () {
->set('bundleJson', json_encode($bundle))
->call('dryRun')
->assertSet('lastResult.dry_run', true)
- ->assertSet('lastResult.server_uuid', $this->server->uuid);
+ ->assertSet('lastResult.server_uuid', $this->server->uuid)
+ ->assertSee('Dry run result')
+ ->assertSee('Nothing written');
});
test('import creates server after source removal', function () {
@@ -100,7 +153,6 @@ test('import creates server after source removal', function () {
->set('bundleJson', json_encode($bundle))
->set('preserveUuids', true)
->set('adoptMode', true)
- ->set('writeRemote', false)
->call('importBundle')
->assertSet('importedServerUuid', $uuid)
->assertSet('lastResult.dry_run', false)
diff --git a/tests/Feature/ServerManagementOwnershipTest.php b/tests/Feature/ServerManagementOwnershipTest.php
index 7d908097eb..66e34538f3 100644
--- a/tests/Feature/ServerManagementOwnershipTest.php
+++ b/tests/Feature/ServerManagementOwnershipTest.php
@@ -40,10 +40,34 @@ test('owner can stop managing a server from this instance', function () {
$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 () {
+ 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');
+});
+
test('owner can take management of a transferred server', function () {
$this->server->server_metadata = [
'transfer' => ['status' => 'transferred'],
@@ -122,7 +146,7 @@ test('claiming a server keeps it force disabled when the cloud team is over its
]);
markServerTransferredAway($this->server);
- app(ServerTransferClaimer::class)->claim($this->server, writeRemote: false);
+ app(ServerTransferClaimer::class)->claim($this->server);
$this->server->refresh();
@@ -154,6 +178,18 @@ test('server overview shows a management button that matches the ownership state
->assertDontSee('Disable management');
});
+test('server validation button is hidden while management is disabled or transferred away', function () {
+ Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
+ ->assertSeeHtml('wire:click.prevent="validateServer"')
+ ->call('toggleManagement')
+ ->assertDontSeeHtml('wire:click.prevent="validateServer"');
+
+ app(ServerTransferClaimer::class)->markTransferred($this->server->fresh(), targetInstanceUrl: 'https://coolify-b.test');
+
+ Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
+ ->assertDontSeeHtml('wire:click.prevent="validateServer"');
+});
+
test('management ownership is not available outside development', function () {
config(['app.env' => 'production']);
diff --git a/tests/Unit/ServerTransfer/ServerTransferClaimerTest.php b/tests/Unit/ServerTransfer/ServerTransferClaimerTest.php
index 8f3873ce1d..11ef8e4369 100644
--- a/tests/Unit/ServerTransfer/ServerTransferClaimerTest.php
+++ b/tests/Unit/ServerTransfer/ServerTransferClaimerTest.php
@@ -46,21 +46,16 @@ test('markTransferred writes force_disabled and transfer status together', funct
->and((bool) $this->server->settings->is_sentinel_enabled)->toBeFalse();
});
-test('claim persists ownership metadata transactionally without remote write', function () {
- $result = app(ServerTransferClaimer::class)->claim(
- $this->server,
- writeRemote: false,
- rebindSentinel: true,
- );
+test('claim persists ownership metadata and always rebinds Sentinel', function () {
+ $result = app(ServerTransferClaimer::class)->claim($this->server);
expect($result['server_uuid'])->toBe($this->server->uuid)
- ->and($result['claim_written'])->toBeFalse()
- ->and($result['sentinel_rebound'])->toBeTrue()
+ ->and($result)->not->toHaveKey('claim_written')
->and(data_get($result, 'claim.instance_url'))->toBe('https://coolify-a.test');
$this->server->refresh();
expect(data_get($this->server->server_metadata, 'transfer.status'))->toBe('claimed')
- ->and(data_get($this->server->server_metadata, 'transfer.claim_written'))->toBeFalse()
->and(data_get($this->server->server_metadata, 'transfer.export_id'))->toBe('export-xyz')
+ ->and((bool) $this->server->settings->is_sentinel_enabled)->toBeTrue()
->and((string) $this->server->settings->sentinel_custom_url)->toBe('https://coolify-a.test');
});
diff --git a/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php b/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php
index 85dc538ad4..41d5cc7813 100644
--- a/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php
+++ b/tests/Unit/ServerTransfer/ServerTransferExporterImporterTest.php
@@ -352,7 +352,6 @@ test('import rolls back all created rows when a later resource fails', function
dryRun: false,
preserveUuids: true,
adoptMode: true,
- claim: false,
))->toThrow(RuntimeException::class, 'Unsupported database type');
expect(Server::where('uuid', $originalServerUuid)->exists())->toBeFalse()
diff --git a/tests/Unit/ServerTransfer/ServerTransferMigratorTest.php b/tests/Unit/ServerTransfer/ServerTransferMigratorTest.php
index 15244fea02..5599fc763b 100644
--- a/tests/Unit/ServerTransfer/ServerTransferMigratorTest.php
+++ b/tests/Unit/ServerTransfer/ServerTransferMigratorTest.php
@@ -57,7 +57,6 @@ test('migrate exports imports via http and completes locally', function () {
server: $this->server,
targetUrl: 'http://8.8.8.8',
targetToken: 'target-token-xyz',
- writeRemote: false,
);
expect($result['server_uuid'])->toBe($this->server->uuid)
@@ -72,7 +71,7 @@ test('migrate exports imports via http and completes locally', function () {
Http::assertSent(function ($request) {
return $request->url() === 'http://8.8.8.8/api/v1/servers/import'
&& $request->hasHeader('Authorization', 'Bearer target-token-xyz')
- && data_get($request->data(), 'claim') === true
+ && ! array_key_exists('write_remote', $request->data())
&& data_get($request->data(), 'bundle.server.uuid') === $this->server->uuid;
});
});
@@ -175,5 +174,26 @@ test('migrate surfaces recovery guidance when complete fails after successful re
$this->server,
'http://8.8.8.8',
'token',
- ))->toThrow(RuntimeException::class, 'Retry complete');
+ ))->toThrow(RuntimeException::class, 'Disable management of this server here');
+});
+
+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([
+ 'dry_run' => false,
+ 'server_uuid' => $this->server->uuid,
+ 'claimed' => false,
+ 'warnings' => ['Server imported, but automatic claim failed: boom'],
+ ], 201),
+ ]);
+
+ expect(fn () => app(ServerTransferMigrator::class)->migrate(
+ $this->server,
+ 'http://8.8.8.8',
+ 'token',
+ ))->toThrow(RuntimeException::class, 'could not take management');
+
+ $this->server->refresh();
+ expect($this->server->isTransferredAway())->toBeFalse()
+ ->and((bool) $this->server->settings->force_disabled)->toBeFalse();
});