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 -
-
-

Import server transfer

- - Back to servers - -
-
- Paste or upload a transfer bundle exported from another Coolify instance. This creates the server and its - resources under the current team and claims the host for this instance - (control-plane only — host data stays on the machine). -
-
- - - -
- - - +
+
+
+

Import server

+
-
+

+ Import a server that you downloaded from another Coolify instance. This instance then manages it. +

+
+ + Back to servers + +
+ +
+ + + Dry run Checking… + :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.', + ]"> - + + Import server Importing… + + +
+ + +
+ +
-
+ + + +
+ + +
+
@if (count($lastWarnings) > 0) -
-
Warnings
-
    + +
      @foreach ($lastWarnings as $warning)
    • {{ $warning }}
    • @endforeach
    -
+ @endif @if ($lastResult) -
-
- {{ data_get($lastResult, 'dry_run') ? 'Dry-run result' : 'Import result' }} + @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 @@ -
- -
- - -
-
-

Download bundle

-

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

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

Complete only

-

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

- - - - Mark transferred & disable automations - - - -
- -
-

Re-claim only

-

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

-
- - -
- - Re-claim - -
+ +
+

+ Import the file on the target via Servers → Import server, then click Disable management + on this server. +

+
+ +
+
+ + Download JSON + Exporting… + + + Import page (this instance) +
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(); });