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 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-09-29 10:56:11 +02:00
co-authored by Claude Opus 5.5
parent 1e9add3b42
commit 3d763c1577
23 changed files with 357 additions and 1082 deletions
+4 -1
View File
@@ -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";
@@ -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.',
+2 -2
View File
@@ -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.';
}
+4 -70
View File
@@ -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');
+2 -8
View File
@@ -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);
+9
View File
@@ -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.
*/
@@ -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';
/**
@@ -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<string, mixed>,
* 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<string, mixed> $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;
}
}
}
@@ -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;
@@ -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);
-376
View File
@@ -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": [
-245
View File
@@ -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:
@@ -11,7 +11,7 @@
<a href="{{ route('server.transfer.import') }}" {{ wireNavigate() }}
class="button w-fit shrink-0 whitespace-nowrap">
<x-reicon name="upload" class="size-3.5" />
Import transfer
Import server
<x-status-badge label="Dev" />
</a>
@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',
+29 -16
View File
@@ -111,18 +111,20 @@
</div>
@endif
<x-process-dialog closeWithX mobileFullscreen size="xl" :open="$isValidating">
<x-slot:title>Validate and configure</x-slot:title>
<x-slot:content>
<livewire:server.validate-and-install :server="$server"
:ask="$server->isFunctional() && ! $isValidating" />
</x-slot:content>
<x-forms.button type="button" :isHighlighted="! $server->isFunctional()"
@click="processDialogOpen = true" wire:click.prevent="validateServer">
<x-reicon :name="$server->isFunctional() ? 'refresh' : 'alert-circle'" class="size-3.5" />
{{ $server->isFunctional() ? 'Revalidate connection' : 'Validate connection' }}
</x-forms.button>
</x-process-dialog>
@if ($server->canBeValidated())
<x-process-dialog closeWithX mobileFullscreen size="xl" :open="$isValidating">
<x-slot:title>Validate and configure</x-slot:title>
<x-slot:content>
<livewire:server.validate-and-install :server="$server"
:ask="$server->isFunctional() && ! $isValidating" />
</x-slot:content>
<x-forms.button type="button" :isHighlighted="! $server->isFunctional()"
@click="processDialogOpen = true" wire:click.prevent="validateServer">
<x-reicon :name="$server->isFunctional() ? 'refresh' : 'alert-circle'" class="size-3.5" />
{{ $server->isFunctional() ? 'Revalidate connection' : 'Validate connection' }}
</x-forms.button>
</x-process-dialog>
@endif
@if (isDev())
<div wire:key="server-management-{{ $server->isTransferredAway() ? 'enable' : 'disable' }}">
@if ($server->isTransferredAway())
@@ -156,15 +158,24 @@
@endif
</div>
@endif
@if ($server->isTransferredAway())
<x-status-badge label="Transferred away" type="warning" />
@if (isDev() && $server->isTransferredAway())
@unless ($server->isManagementDisabled())
<x-status-badge label="Transferred away" type="warning" />
@endunless
@else
<x-status-badge :label="$server->isFunctional() ? 'Ready' : 'Validation required'"
:type="$server->isFunctional() ? 'success' : 'warning'" />
@endif
</x-slot:actions>
@if ($server->isTransferredAway())
@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
@@ -299,7 +310,9 @@
{{ $server->name }}
</p>
<p class="mt-1 text-xs leading-5 text-neutral-500 dark:text-fg-dim">
@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.
@@ -1,79 +1,128 @@
<div>
<div class="w-full">
<x-slot:title>
Import server transfer | Coolify
Import server | Coolify
</x-slot>
<div class="flex flex-col gap-6">
<div class="flex flex-wrap items-center gap-2">
<h1>Import server transfer <x-status-badge label="Dev" /></h1>
<a href="{{ route('server.index') }}" {{ wireNavigate() }}>
<x-forms.button>Back to servers</x-forms.button>
</a>
</div>
<div class="subtitle">
Paste or upload a transfer bundle exported from another Coolify instance. This creates the server and its
resources under the current team and <strong>claims</strong> the host for this instance
(control-plane only — host data stays on the machine).
</div>
<div class="flex flex-col gap-4 rounded-lg border border-neutral-200 p-4 dark:border-coolgray-200">
<x-forms.input type="file" id="bundleFile" label="Bundle file (.json)" accept=".json,application/json" />
<x-forms.textarea id="bundleJson" label="Or paste bundle JSON" rows="14" placeholder='{"schema_version":1,...}' />
<x-forms.input id="passphrase" type="password" label="Passphrase (if encrypted)" placeholder="Optional" />
<div class="flex flex-col gap-2">
<x-forms.checkbox id="preserveUuids" label="Preserve UUIDs from the source instance" />
<x-forms.checkbox id="adoptMode" label="Adopt mode (keep statuses; do not force exited redeploy)" />
<x-forms.checkbox id="writeRemote" label="Also write ownership file on the host via SSH (optional)" />
<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>
<div class="flex flex-wrap gap-2">
<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 from the bundle will be added to the current team.']">
: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 wire:loading.attr="disabled" wire:target="dryRun,importBundle">
<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>
</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)
<div class="rounded-lg border border-warning/40 bg-warning/10 p-3 text-sm">
<div class="mb-1 font-semibold text-warning">Warnings</div>
<ul class="list-disc space-y-1 pl-5">
<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>
</div>
</x-callout>
@endif
@if ($lastResult)
<div class="rounded-lg border border-neutral-200 p-4 dark:border-coolgray-200">
<div class="mb-2 font-semibold">
{{ data_get($lastResult, 'dry_run') ? 'Dry-run result' : 'Import result' }}
@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>
@if ($importedServerUuid)
<div class="mb-3 flex flex-wrap items-center gap-2 text-sm">
<span>Server UUID: <code class="font-mono">{{ $importedServerUuid }}</code></span>
@if (data_get($lastResult, 'claimed'))
<span class="text-success">Claimed</span>
@endif
<a href="{{ route('server.show', ['server_uuid' => $importedServerUuid]) }}" {{ wireNavigate() }}>
<x-forms.button>Open server</x-forms.button>
</a>
<a href="{{ route('server.transfer', ['server_uuid' => $importedServerUuid]) }}" {{ wireNavigate() }}>
<x-forms.button>Transfer details</x-forms.button>
</a>
</div>
@endif
<pre class="max-h-80 overflow-auto rounded-lg bg-neutral-100 p-3 text-xs dark:bg-coolgray-100">{{ json_encode($lastResult, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}</pre>
</div>
</x-application.settings-section>
@endif
</div>
</div>
@@ -21,9 +21,13 @@
<x-application.settings-section id="server-transfer-section" title="Transfer server (Dev)"
helper="Move this server’s control-plane config to another Coolify instance (same physical host).">
<x-slot:actions>
<x-status-badge
:label="$this->transferStatus ?: 'ready'"
type="neutral" />
@if ($server->isManagementDisabled())
<x-status-badge label="Transferable" type="warning" />
@elseif ($server->isTransferredAway())
<x-status-badge label="Transferred away" type="warning" />
@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>
@endif
@@ -35,8 +39,8 @@
</h3>
<p class="mt-1 text-xs leading-5 text-neutral-500 dark:text-fg-dim">
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.
</p>
</div>
<div class="flex flex-col gap-3 md:max-w-xl">
@@ -46,8 +50,6 @@
<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." />
<x-forms.checkbox id="writeRemote"
label="Write ownership file on the host via SSH (optional)" />
</div>
<div>
<x-modal-confirmation title="Transfer this server?" submitAction="migrateServer"
@@ -84,72 +86,28 @@
</div>
</x-application.settings-section>
<x-application.settings-section id="server-transfer-advanced-section" title="Advanced"
helper="Manual export, complete-only, and re-claim helpers for air-gapped or partial transfers.">
<div class="flex flex-col gap-6" x-data="{ open: @entangle('showAdvanced') }">
<button type="button"
class="flex w-full items-center justify-between rounded-lg border border-neutral-200 px-4 py-3 text-left dark:border-white/[0.08]"
@click="open = !open">
<span class="text-sm font-semibold">Show advanced options</span>
<span class="text-xs text-neutral-500 dark:text-fg-faint"
x-text="open ? 'Hide' : 'Show'"></span>
</button>
<div class="flex flex-col gap-6" x-show="open" x-cloak>
<div>
<h4 class="text-sm font-medium">Download bundle</h4>
<p class="mb-3 text-xs leading-5 text-neutral-500 dark:text-fg-dim">
Manual / air-gapped transfer. Import on the target via Servers → Import transfer.
</p>
<div class="mb-3 flex flex-col gap-3 md:max-w-xl">
<x-forms.checkbox id="encryptBundle" label="Encrypt with passphrase" />
<x-forms.input id="passphrase" type="password" label="Passphrase"
placeholder="Used when encrypt is checked" 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>
<div>
<h4 class="text-sm font-medium">Complete only</h4>
<p class="mb-3 text-xs leading-5 text-neutral-500 dark:text-fg-dim">
Disable automations after a manual import on the target.
</p>
<x-modal-confirmation title="Disable automations on this server?"
submitAction="completeTransfer" :confirmWithText="false"
:confirmWithPassword="false" step2ButtonText="Disable automations"
:actions="['The server will be marked as transferred and automations will be disabled on this instance.']">
<x-slot:trigger>
<x-forms.button canGate="update" :canResource="$server">
Mark transferred & disable automations
</x-forms.button>
</x-slot:trigger>
</x-modal-confirmation>
</div>
<div>
<h4 class="text-sm font-medium">Re-claim only</h4>
<p class="mb-3 text-xs leading-5 text-neutral-500 dark:text-fg-dim">
Retry claim on this instance (after a local import).
</p>
<div class="mb-3 flex flex-col gap-2">
<x-forms.checkbox id="writeRemoteOnClaim" label="Write ownership file via SSH" />
<x-forms.checkbox id="rebindSentinelOnClaim" label="Rebind Sentinel" />
</div>
<x-forms.button canGate="update" :canResource="$server" wire:click="claimServer"
wire:loading.attr="disabled">
Re-claim
</x-forms.button>
</div>
<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>
-1
View File
@@ -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']);
+25 -30
View File
@@ -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()
@@ -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)
@@ -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']);
@@ -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');
});
@@ -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()
@@ -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();
});