mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-13 20:14:45 +00:00
feat(servers): add cross-instance server migration (#11075)
This commit is contained in:
1 parent
c15e3b35fd
commit
f9caa5250d
36 files changed
+7675
-15
No files matched your search
@@ -25,6 +25,15 @@ class ValidateServer
|
||||
|
||||
public function handle(Server $server)
|
||||
{
|
||||
if (! $server->canBeValidated()) {
|
||||
$this->error = 'This server was transferred to another Coolify instance and cannot be revalidated here.';
|
||||
$server->update([
|
||||
'validation_logs' => $this->error,
|
||||
'is_validating' => false,
|
||||
]);
|
||||
throw new \Exception($this->error);
|
||||
}
|
||||
|
||||
$server->update([
|
||||
'validation_logs' => null,
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Server;
|
||||
use App\Services\ServerTransfer\ServerTransferBundle;
|
||||
use App\Services\ServerTransfer\ServerTransferClaimer;
|
||||
use App\Services\ServerTransfer\ServerTransferExporter;
|
||||
use App\Services\ServerTransfer\ServerTransferImporter;
|
||||
use App\Services\ServerTransfer\ServerTransferMigrator;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use OpenApi\Attributes as OA;
|
||||
use Throwable;
|
||||
|
||||
class ServerTransferController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private ServerTransferExporter $exporter,
|
||||
private ServerTransferImporter $importer,
|
||||
private ServerTransferClaimer $claimer,
|
||||
private ServerTransferMigrator $migrator,
|
||||
) {
|
||||
abort_unless(isDev(), 404);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Migrate server to another Coolify instance',
|
||||
description: 'One-shot handoff: export this server, import+claim on the target instance (using the provided token), then disable automations here. Requires read:sensitive and write.',
|
||||
path: '/servers/{uuid}/migrate',
|
||||
operationId: 'migrate-server-between-instances',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Servers'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['target_url', 'target_token'],
|
||||
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),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Migrated'),
|
||||
new OA\Response(response: 403, description: 'Missing sensitive permission'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, description: 'Validation or remote import failed'),
|
||||
]
|
||||
)]
|
||||
public function migrate(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
if (! $this->canReadSensitive($request)) {
|
||||
return response()->json([
|
||||
'message' => 'Migrating a server requires a token with read:sensitive (or root) ability and an admin/owner team role.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$server = Server::whereTeamId($teamId)->whereUuid($uuid)->first();
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $server);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$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'];
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
if ($validator->fails() || $extraFields !== []) {
|
||||
$errors = $validator->errors();
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->migrator->migrate(
|
||||
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),
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
auditLog('api.server.migrate', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'export_id' => $result['export_id'],
|
||||
'target_url' => $result['target_url'],
|
||||
]);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
#[OA\Get(
|
||||
summary: 'Export server transfer bundle',
|
||||
description: 'Export a server and all resources hosted on it as a versioned transfer bundle for moving between Coolify instances. Requires read:sensitive.',
|
||||
path: '/servers/{uuid}/export',
|
||||
operationId: 'export-server-transfer-bundle',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Servers'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
new OA\Parameter(name: 'encrypt', in: 'query', required: false, description: 'If true and passphrase is provided, return an encrypted envelope.', schema: new OA\Schema(type: 'boolean')),
|
||||
new OA\Parameter(name: 'passphrase', in: 'query', required: false, description: 'Passphrase used when encrypt=true.', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Transfer bundle'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 403, description: 'Missing sensitive permission'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
public function export(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
if (! $this->canReadSensitive($request)) {
|
||||
return response()->json([
|
||||
'message' => 'Exporting a server requires a token with read:sensitive (or root) ability and an admin/owner team role.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$server = Server::whereTeamId($teamId)->whereUuid($uuid)->first();
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('view', $server);
|
||||
|
||||
try {
|
||||
$bundle = $this->exporter->export($server, includeSensitive: true);
|
||||
} catch (Throwable $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
auditLog('api.server.export', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'export_id' => data_get($bundle, 'export_id'),
|
||||
]);
|
||||
|
||||
if ($request->boolean('encrypt') && $request->filled('passphrase')) {
|
||||
return response()->json(
|
||||
ServerTransferBundle::encryptWithPassphrase($bundle, $request->string('passphrase')->toString())
|
||||
);
|
||||
}
|
||||
|
||||
return response()->json($bundle);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Import server transfer bundle',
|
||||
description: 'Import a server transfer bundle into this Coolify instance (adopt mode by default).',
|
||||
path: '/servers/import',
|
||||
operationId: 'import-server-transfer-bundle',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Servers'],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(property: 'bundle', type: 'object', description: 'Plain or encrypted transfer bundle'),
|
||||
new OA\Property(property: 'passphrase', type: 'string', nullable: true),
|
||||
new OA\Property(property: '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'),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Dry-run result'),
|
||||
new OA\Response(response: 201, description: 'Imported'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 422, description: 'Validation failed'),
|
||||
]
|
||||
)]
|
||||
public function import(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$this->authorize('create', Server::class);
|
||||
|
||||
$return = validateIncomingRequest($request);
|
||||
if ($return instanceof JsonResponse) {
|
||||
return $return;
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'bundle' => 'required|array',
|
||||
'passphrase' => 'string|nullable',
|
||||
'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'];
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
if ($validator->fails() || $extraFields !== []) {
|
||||
$errors = $validator->errors();
|
||||
foreach ($extraFields as $field) {
|
||||
$errors->add($field, 'This field is not allowed.');
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $errors,
|
||||
], 422);
|
||||
}
|
||||
|
||||
$bundle = $request->input('bundle', []);
|
||||
if (data_get($bundle, 'encrypted')) {
|
||||
if (! $request->filled('passphrase')) {
|
||||
return response()->json(['message' => 'Passphrase is required for encrypted bundles.'], 422);
|
||||
}
|
||||
try {
|
||||
$bundle = ServerTransferBundle::decryptWithPassphrase($bundle, $request->string('passphrase')->toString());
|
||||
} catch (Throwable $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->importer->import(
|
||||
bundle: $bundle,
|
||||
teamId: $teamId,
|
||||
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;
|
||||
$payload = ['message' => $e->getMessage()];
|
||||
if ($e instanceof ValidationException) {
|
||||
$payload['errors'] = $e->errors();
|
||||
}
|
||||
|
||||
return response()->json($payload, $status);
|
||||
}
|
||||
|
||||
auditLog('api.server.import', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $result['server_uuid'],
|
||||
'export_id' => $result['export_id'],
|
||||
'dry_run' => $result['dry_run'],
|
||||
]);
|
||||
|
||||
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.',
|
||||
path: '/servers/{uuid}/transfer/complete',
|
||||
operationId: 'complete-server-transfer',
|
||||
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: 'export_id', type: 'string', nullable: true),
|
||||
new OA\Property(property: 'target_instance_url', type: 'string', nullable: true),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Marked transferred'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
public function complete(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(), [
|
||||
'export_id' => 'string|nullable',
|
||||
'target_instance_url' => 'string|nullable',
|
||||
]);
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->claimer->markTransferred(
|
||||
$server,
|
||||
exportId: $request->input('export_id'),
|
||||
targetInstanceUrl: $request->input('target_instance_url'),
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
auditLog('api.server.transfer_complete', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'export_id' => $request->input('export_id'),
|
||||
]);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Write transfer bundle to server mailbox',
|
||||
description: 'Write an export bundle to /data/coolify/exports on the managed host for air-gapped import.',
|
||||
path: '/servers/{uuid}/export/mailbox',
|
||||
operationId: 'export-server-transfer-mailbox',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Servers'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(property: 'passphrase', type: 'string', nullable: true),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Mailbox write result'),
|
||||
new OA\Response(response: 403, description: 'Missing sensitive permission'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
public function writeMailbox(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
if (! $this->canReadSensitive($request)) {
|
||||
return response()->json([
|
||||
'message' => 'Writing a transfer mailbox requires read:sensitive (or root) ability and an admin/owner team role.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
$server = Server::whereTeamId($teamId)->whereUuid($uuid)->first();
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('view', $server);
|
||||
|
||||
try {
|
||||
$bundle = $this->exporter->export($server, includeSensitive: true);
|
||||
$result = $this->claimer->writeMailbox(
|
||||
$server,
|
||||
$bundle,
|
||||
$request->filled('passphrase') ? $request->string('passphrase')->toString() : null,
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
auditLog('api.server.export_mailbox', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'export_id' => data_get($bundle, 'export_id'),
|
||||
'path' => $result['path'],
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'export_id' => data_get($bundle, 'export_id'),
|
||||
'path' => $result['path'],
|
||||
'written' => $result['written'],
|
||||
'message' => $result['written']
|
||||
? 'Transfer bundle written to server mailbox.'
|
||||
: 'Failed to write mailbox on remote host.',
|
||||
], $result['written'] ? 200 : 422);
|
||||
}
|
||||
|
||||
private function canReadSensitive(Request $request): bool
|
||||
{
|
||||
return (bool) $request->attributes->get('can_read_sensitive', false);
|
||||
}
|
||||
}
|
||||
@@ -970,6 +970,12 @@ class ServersController extends Controller
|
||||
}
|
||||
$this->authorize('update', $server);
|
||||
|
||||
if (! $server->canBeValidated()) {
|
||||
return response()->json([
|
||||
'message' => 'This server was transferred to another Coolify instance and cannot be revalidated here.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'install' => 'boolean',
|
||||
]);
|
||||
|
||||
@@ -33,6 +33,19 @@ class ValidateAndInstallServerJob implements ShouldBeEncrypted, ShouldQueue
|
||||
public function handle(): void
|
||||
{
|
||||
try {
|
||||
if (! $this->server->canBeValidated()) {
|
||||
$message = 'This server was transferred to another Coolify instance and cannot be revalidated here.';
|
||||
$this->server->update([
|
||||
'validation_logs' => $message,
|
||||
'is_validating' => false,
|
||||
]);
|
||||
Log::warning('ValidateAndInstallServer: blocked for transferred server', [
|
||||
'server_id' => $this->server->id,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark validation as in progress
|
||||
$this->server->update(['is_validating' => true]);
|
||||
|
||||
|
||||
@@ -327,6 +327,15 @@ class Show extends Component
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
if (! $this->server->canBeValidated()) {
|
||||
$this->dispatch(
|
||||
'error',
|
||||
'Cannot revalidate',
|
||||
'This server was transferred to another Coolify instance. Manage it from the target instance instead.'
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
if ($this->server->vultr_instance_id) {
|
||||
$status = $this->server->refreshVultrState();
|
||||
$this->server->refresh();
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
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;
|
||||
use Livewire\Component;
|
||||
use Throwable;
|
||||
|
||||
class Transfer extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public Server $server;
|
||||
|
||||
/** Primary one-click migrate fields */
|
||||
public string $targetUrl = '';
|
||||
|
||||
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;
|
||||
|
||||
public ?string $exportId = null;
|
||||
|
||||
/** @var list<string> */
|
||||
public array $lastWarnings = [];
|
||||
|
||||
public ?string $lastResultJson = null;
|
||||
|
||||
public function mount(string $server_uuid): void
|
||||
{
|
||||
$this->ensureDevelopmentAvailability();
|
||||
|
||||
try {
|
||||
$this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
|
||||
$this->authorize('view', $this->server);
|
||||
$this->exportId = data_get($this->server->server_metadata, 'transfer.export_id');
|
||||
} catch (Throwable $e) {
|
||||
handleError($e, $this);
|
||||
$this->redirect(route('server.index'), navigate: true);
|
||||
}
|
||||
}
|
||||
|
||||
public function getTransferStatusProperty(): ?string
|
||||
{
|
||||
return data_get($this->server->fresh()->server_metadata, 'transfer.status');
|
||||
}
|
||||
|
||||
public function getIsLocalhostProperty(): bool
|
||||
{
|
||||
return (int) $this->server->id === 0;
|
||||
}
|
||||
|
||||
public function migrateServer(ServerTransferMigrator $migrator): void
|
||||
{
|
||||
$this->ensureDevelopmentAvailability();
|
||||
|
||||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
if ($this->isLocalhost) {
|
||||
throw new \RuntimeException('The Coolify host (localhost) cannot be transferred.');
|
||||
}
|
||||
|
||||
$result = $migrator->migrate(
|
||||
server: $this->server,
|
||||
targetUrl: $this->targetUrl,
|
||||
targetToken: $this->targetToken,
|
||||
writeRemote: $this->writeRemote,
|
||||
);
|
||||
|
||||
$this->server->refresh();
|
||||
$this->exportId = $result['export_id'] ?? $this->exportId;
|
||||
$this->lastWarnings = array_values((array) data_get($result, 'warnings', []));
|
||||
// Never echo the target token in the result dump.
|
||||
$safe = $result;
|
||||
unset($safe['target_token']);
|
||||
$this->lastResultJson = json_encode($safe, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
$this->targetToken = '';
|
||||
$this->dispatch('success', $result['message'] ?? 'Server transferred.');
|
||||
} catch (Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function exportBundle(ServerTransferExporter $exporter)
|
||||
{
|
||||
$this->ensureDevelopmentAvailability();
|
||||
|
||||
try {
|
||||
$this->authorize('view', $this->server);
|
||||
if ($this->isLocalhost) {
|
||||
throw new \RuntimeException('The Coolify host (localhost) cannot be transferred.');
|
||||
}
|
||||
|
||||
$bundle = $exporter->export($this->server, includeSensitive: true);
|
||||
$this->exportId = data_get($bundle, 'export_id');
|
||||
$this->lastWarnings = array_values((array) data_get($bundle, 'warnings', []));
|
||||
$this->lastResultJson = null;
|
||||
|
||||
$payload = $bundle;
|
||||
$fileName = 'server-transfer-'.$this->server->uuid.'.json';
|
||||
if ($this->encryptBundle) {
|
||||
if (blank($this->passphrase)) {
|
||||
throw new \RuntimeException('Passphrase is required to encrypt the bundle.');
|
||||
}
|
||||
$payload = ServerTransferBundle::encryptWithPassphrase($bundle, $this->passphrase);
|
||||
$fileName = 'server-transfer-'.$this->server->uuid.'.encrypted.json';
|
||||
}
|
||||
|
||||
$json = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
if ($json === false) {
|
||||
throw new \RuntimeException('Failed to encode transfer bundle.');
|
||||
}
|
||||
|
||||
$this->dispatch('success', 'Transfer bundle ready for download.');
|
||||
|
||||
return response()->streamDownload(function () use ($json) {
|
||||
echo $json;
|
||||
}, $fileName, [
|
||||
'Content-Type' => 'application/json',
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
private function ensureDevelopmentAvailability(): void
|
||||
{
|
||||
abort_unless(isDev(), 404);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Server;
|
||||
|
||||
use App\Models\Server;
|
||||
use App\Services\ServerTransfer\ServerTransferBundle;
|
||||
use App\Services\ServerTransfer\ServerTransferImporter;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||
use Livewire\WithFileUploads;
|
||||
use Throwable;
|
||||
|
||||
class TransferImport extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
use WithFileUploads;
|
||||
|
||||
public string $bundleJson = '';
|
||||
|
||||
public string $passphrase = '';
|
||||
|
||||
public bool $preserveUuids = true;
|
||||
|
||||
public bool $adoptMode = true;
|
||||
|
||||
/** Write ownership file on the host via SSH when claiming after import. */
|
||||
public bool $writeRemote = false;
|
||||
|
||||
/** @var TemporaryUploadedFile|null */
|
||||
public $bundleFile = null;
|
||||
|
||||
/** @var array<string, mixed>|null */
|
||||
public ?array $lastResult = null;
|
||||
|
||||
/** @var list<string> */
|
||||
public array $lastWarnings = [];
|
||||
|
||||
public ?string $importedServerUuid = null;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->ensureDevelopmentAvailability();
|
||||
$this->authorize('create', Server::class);
|
||||
}
|
||||
|
||||
public function updatedBundleFile(): void
|
||||
{
|
||||
$this->ensureDevelopmentAvailability();
|
||||
|
||||
if (! $this->bundleFile) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$contents = $this->bundleFile->get();
|
||||
if (! is_string($contents) || blank($contents)) {
|
||||
throw new \RuntimeException('Uploaded file is empty.');
|
||||
}
|
||||
$this->bundleJson = $contents;
|
||||
$this->dispatch('success', 'Bundle file loaded into the form.');
|
||||
} catch (Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function resolveBundle(): array
|
||||
{
|
||||
$raw = trim($this->bundleJson);
|
||||
if ($raw === '') {
|
||||
throw new \RuntimeException('Paste a transfer bundle JSON or upload a file.');
|
||||
}
|
||||
|
||||
$decoded = json_decode($raw, true);
|
||||
if (! is_array($decoded)) {
|
||||
throw new \RuntimeException('Bundle is not valid JSON.');
|
||||
}
|
||||
|
||||
if (data_get($decoded, 'encrypted')) {
|
||||
if (blank($this->passphrase)) {
|
||||
throw new \RuntimeException('Passphrase is required for encrypted bundles.');
|
||||
}
|
||||
|
||||
return ServerTransferBundle::decryptWithPassphrase($decoded, $this->passphrase);
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
public function dryRun(ServerTransferImporter $importer): void
|
||||
{
|
||||
$this->ensureDevelopmentAvailability();
|
||||
$this->runImport($importer, dryRun: true);
|
||||
}
|
||||
|
||||
public function importBundle(ServerTransferImporter $importer): void
|
||||
{
|
||||
$this->ensureDevelopmentAvailability();
|
||||
$this->runImport($importer, dryRun: false);
|
||||
}
|
||||
|
||||
private function runImport(ServerTransferImporter $importer, bool $dryRun): void
|
||||
{
|
||||
try {
|
||||
$this->authorize('create', Server::class);
|
||||
$teamId = currentTeam()->id;
|
||||
$bundle = $this->resolveBundle();
|
||||
|
||||
$result = $importer->import(
|
||||
bundle: $bundle,
|
||||
teamId: $teamId,
|
||||
dryRun: $dryRun,
|
||||
preserveUuids: $this->preserveUuids,
|
||||
adoptMode: $this->adoptMode,
|
||||
claim: ! $dryRun,
|
||||
writeRemote: $this->writeRemote,
|
||||
rebindSentinel: true,
|
||||
);
|
||||
|
||||
$this->lastResult = $result;
|
||||
$this->lastWarnings = array_values((array) data_get($result, 'warnings', []));
|
||||
$this->importedServerUuid = $dryRun ? null : data_get($result, 'server_uuid');
|
||||
|
||||
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.');
|
||||
} else {
|
||||
$this->dispatch('success', 'Server imported. Claim did not complete — check warnings or re-claim from the server Transfer page.');
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.server.transfer-import');
|
||||
}
|
||||
|
||||
private function ensureDevelopmentAvailability(): void
|
||||
{
|
||||
abort_unless(isDev(), 404);
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,21 @@ class ValidateAndInstall extends Component
|
||||
|
||||
public function init(int $data = 0)
|
||||
{
|
||||
if (! $this->server->canBeValidated()) {
|
||||
$this->error = 'This server was transferred to another Coolify instance and cannot be revalidated here.';
|
||||
$this->server->update([
|
||||
'validation_logs' => $this->error,
|
||||
'is_validating' => false,
|
||||
]);
|
||||
$this->dispatch(
|
||||
'error',
|
||||
'Cannot revalidate',
|
||||
$this->error
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->isInstalling = false;
|
||||
$this->uptime = null;
|
||||
$this->supported_os_type = null;
|
||||
|
||||
@@ -862,8 +862,29 @@ $schema://$host {
|
||||
return $this->settings->force_disabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server was migrated away from this Coolify instance (source side).
|
||||
* Must not be revalidated or re-enabled as a live managed host.
|
||||
*/
|
||||
public function isTransferredAway(): bool
|
||||
{
|
||||
return data_get($this->server_metadata, 'transfer.status') === 'transferred';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this server may be validated / installed against from this instance.
|
||||
*/
|
||||
public function canBeValidated(): bool
|
||||
{
|
||||
return ! $this->isTransferredAway();
|
||||
}
|
||||
|
||||
public function forceEnableServer()
|
||||
{
|
||||
if ($this->isTransferredAway()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->settings->force_disabled = false;
|
||||
$this->settings->save();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\ServerTransfer;
|
||||
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use RuntimeException;
|
||||
|
||||
class ServerTransferBundle
|
||||
{
|
||||
public const SCHEMA_VERSION = 1;
|
||||
|
||||
public const CLAIM_PATH = '/data/coolify/instance-claim.json';
|
||||
|
||||
public const MAILBOX_DIR = '/data/coolify/exports';
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public static function wrap(array $payload): array
|
||||
{
|
||||
return array_merge([
|
||||
'schema_version' => self::SCHEMA_VERSION,
|
||||
'exported_at' => now()->toIso8601String(),
|
||||
'export_id' => new_public_id(),
|
||||
], $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $bundle
|
||||
* @return array{valid: bool, errors: list<string>, warnings: list<string>}
|
||||
*/
|
||||
public static function validate(array $bundle): array
|
||||
{
|
||||
$errors = [];
|
||||
$warnings = [];
|
||||
|
||||
if (! isset($bundle['schema_version'])) {
|
||||
$errors[] = 'Missing schema_version.';
|
||||
} elseif ((int) $bundle['schema_version'] !== self::SCHEMA_VERSION) {
|
||||
$errors[] = 'Unsupported schema_version '.$bundle['schema_version'].'. Expected '.self::SCHEMA_VERSION.'.';
|
||||
}
|
||||
|
||||
if (! isset($bundle['export_id']) || ! is_string($bundle['export_id']) || $bundle['export_id'] === '') {
|
||||
$errors[] = 'Missing export_id.';
|
||||
}
|
||||
|
||||
if (! isset($bundle['server']) || ! is_array($bundle['server'])) {
|
||||
$errors[] = 'Missing server payload.';
|
||||
} else {
|
||||
foreach (['uuid', 'name', 'ip', 'port', 'user'] as $field) {
|
||||
if (! array_key_exists($field, $bundle['server'])) {
|
||||
$errors[] = "Missing server.{$field}.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! isset($bundle['private_key']) || ! is_array($bundle['private_key'])) {
|
||||
$errors[] = 'Missing private_key payload.';
|
||||
} else {
|
||||
if (blank(data_get($bundle, 'private_key.private_key'))) {
|
||||
$errors[] = 'Missing private_key.private_key material.';
|
||||
}
|
||||
}
|
||||
|
||||
if (! isset($bundle['destinations']) || ! is_array($bundle['destinations'])) {
|
||||
$errors[] = 'Missing destinations array.';
|
||||
} elseif (count($bundle['destinations']) === 0) {
|
||||
$warnings[] = 'Bundle has no destinations; a default destination will be used.';
|
||||
}
|
||||
|
||||
if (! isset($bundle['projects']) || ! is_array($bundle['projects'])) {
|
||||
$errors[] = 'Missing projects array.';
|
||||
}
|
||||
|
||||
return [
|
||||
'valid' => $errors === [],
|
||||
'errors' => $errors,
|
||||
'warnings' => $warnings,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $bundle
|
||||
*/
|
||||
public static function assertValid(array $bundle): void
|
||||
{
|
||||
$result = self::validate($bundle);
|
||||
if (! $result['valid']) {
|
||||
throw ValidationException::withMessages([
|
||||
'bundle' => $result['errors'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt an entire bundle for mailbox/API transport with a user passphrase.
|
||||
*
|
||||
* @param array<string, mixed> $bundle
|
||||
* @return array{encrypted: true, schema_version: int, payload: string}
|
||||
*/
|
||||
public static function encryptWithPassphrase(array $bundle, string $passphrase): array
|
||||
{
|
||||
if ($passphrase === '') {
|
||||
throw new RuntimeException('Passphrase must not be empty.');
|
||||
}
|
||||
|
||||
$json = json_encode($bundle, JSON_THROW_ON_ERROR);
|
||||
$key = hash('sha256', $passphrase, true);
|
||||
$iv = random_bytes(16);
|
||||
$cipher = openssl_encrypt($json, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
|
||||
if ($cipher === false) {
|
||||
throw new RuntimeException('Failed to encrypt transfer bundle.');
|
||||
}
|
||||
|
||||
$mac = hash_hmac('sha256', $iv.$cipher, $key);
|
||||
|
||||
return [
|
||||
'encrypted' => true,
|
||||
'schema_version' => self::SCHEMA_VERSION,
|
||||
'payload' => base64_encode($iv.$mac.$cipher),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $encrypted
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function decryptWithPassphrase(array $encrypted, string $passphrase): array
|
||||
{
|
||||
if (! data_get($encrypted, 'encrypted')) {
|
||||
throw new RuntimeException('Bundle is not encrypted.');
|
||||
}
|
||||
|
||||
$raw = base64_decode((string) data_get($encrypted, 'payload'), true);
|
||||
if ($raw === false || strlen($raw) < 48) {
|
||||
throw new RuntimeException('Invalid encrypted payload.');
|
||||
}
|
||||
|
||||
$key = hash('sha256', $passphrase, true);
|
||||
$iv = substr($raw, 0, 16);
|
||||
$mac = substr($raw, 16, 64);
|
||||
$cipher = substr($raw, 80);
|
||||
$expectedMac = hash_hmac('sha256', $iv.$cipher, $key);
|
||||
if (! hash_equals($expectedMac, $mac)) {
|
||||
throw new RuntimeException('Invalid passphrase or corrupted bundle.');
|
||||
}
|
||||
|
||||
$json = openssl_decrypt($cipher, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv);
|
||||
if ($json === false) {
|
||||
throw new RuntimeException('Failed to decrypt transfer bundle.');
|
||||
}
|
||||
|
||||
/** @var array<string, mixed> $bundle */
|
||||
$bundle = json_decode($json, true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
return $bundle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optionally re-seal secrets with Laravel's app key for intermediate storage.
|
||||
* Prefer passphrase encryption for cross-instance transfers.
|
||||
*
|
||||
* @param array<string, mixed> $bundle
|
||||
*/
|
||||
public static function sealWithAppKey(array $bundle): string
|
||||
{
|
||||
return Crypt::encryptString(json_encode($bundle, JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function unsealWithAppKey(string $sealed): array
|
||||
{
|
||||
/** @var array<string, mixed> $bundle */
|
||||
$bundle = json_decode(Crypt::decryptString($sealed), true, 512, JSON_THROW_ON_ERROR);
|
||||
|
||||
return $bundle;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\ServerTransfer;
|
||||
|
||||
use App\Models\Server;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class ServerTransferClaimer
|
||||
{
|
||||
/**
|
||||
* Claim a managed host for this Coolify instance.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* @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
|
||||
{
|
||||
if ($server->id === 0) {
|
||||
throw new RuntimeException('Cannot claim the Coolify host itself.');
|
||||
}
|
||||
|
||||
$instanceUrl = rtrim((string) (instanceSettings()->fqdn ?: config('app.url')), '/');
|
||||
if ($instanceUrl === '') {
|
||||
throw new RuntimeException('Instance URL (FQDN or APP_URL) must be set before claiming a server.');
|
||||
}
|
||||
|
||||
$exportId = data_get($server->server_metadata, 'transfer.export_id');
|
||||
|
||||
$claim = [
|
||||
'instance_url' => $instanceUrl,
|
||||
'server_uuid' => $server->uuid,
|
||||
'team_id' => $server->team_id,
|
||||
'claimed_at' => now()->toIso8601String(),
|
||||
'export_id' => $exportId,
|
||||
'schema_version' => ServerTransferBundle::SCHEMA_VERSION,
|
||||
];
|
||||
|
||||
$result = DB::transaction(function () use ($server, $claim, $rebindSentinel, $instanceUrl) {
|
||||
$server = Server::query()->with('settings')->lockForUpdate()->findOrFail($server->id);
|
||||
|
||||
$sentinelRebound = false;
|
||||
if ($rebindSentinel && $server->settings) {
|
||||
$server->settings->sentinel_custom_url = $instanceUrl;
|
||||
$server->settings->ensureValidSentinelToken();
|
||||
// Leave sentinel disabled until operator enables metrics; endpoint is ready.
|
||||
$server->settings->save();
|
||||
$sentinelRebound = true;
|
||||
}
|
||||
|
||||
$metadata = $server->server_metadata ?? [];
|
||||
$metadata['transfer'] = array_merge((array) data_get($metadata, 'transfer', []), [
|
||||
'status' => 'claimed',
|
||||
'claimed_at' => $claim['claimed_at'],
|
||||
'claim' => $claim,
|
||||
'claim_written' => false,
|
||||
'sentinel_rebound' => $sentinelRebound,
|
||||
]);
|
||||
$server->server_metadata = $metadata;
|
||||
$server->save();
|
||||
|
||||
return [
|
||||
'server_uuid' => $server->uuid,
|
||||
'claim' => $claim,
|
||||
'sentinel_rebound' => $sentinelRebound,
|
||||
'claim_written' => false,
|
||||
];
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a server as transferred away from this instance (source side).
|
||||
* Disables automations to prevent dual management.
|
||||
*
|
||||
* 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).
|
||||
*
|
||||
* @return array{server_uuid: string, message: string}
|
||||
*/
|
||||
public function markTransferred(Server $server, ?string $exportId = null, ?string $targetInstanceUrl = null): array
|
||||
{
|
||||
if ($server->id === 0) {
|
||||
throw new RuntimeException('Cannot transfer the Coolify host itself.');
|
||||
}
|
||||
|
||||
$result = DB::transaction(function () use ($server, $exportId, $targetInstanceUrl) {
|
||||
$server = Server::query()->with('settings')->lockForUpdate()->findOrFail($server->id);
|
||||
|
||||
if ($server->settings) {
|
||||
$server->settings->force_disabled = true;
|
||||
$server->settings->is_sentinel_enabled = false;
|
||||
$server->settings->save();
|
||||
}
|
||||
|
||||
$metadata = $server->server_metadata ?? [];
|
||||
$metadata['transfer'] = array_merge((array) data_get($metadata, 'transfer', []), [
|
||||
'status' => 'transferred',
|
||||
'export_id' => $exportId ?? data_get($metadata, 'transfer.export_id'),
|
||||
'target_instance_url' => $targetInstanceUrl,
|
||||
'transferred_at' => now()->toIso8601String(),
|
||||
]);
|
||||
$server->server_metadata = $metadata;
|
||||
$server->save();
|
||||
|
||||
return [
|
||||
'server_uuid' => $server->uuid,
|
||||
'message' => 'Server marked as transferred. Automations disabled on this instance.',
|
||||
];
|
||||
});
|
||||
|
||||
$this->clearLocalSshArtifacts($server);
|
||||
|
||||
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.
|
||||
*/
|
||||
private function clearLocalSshArtifacts(Server $server): void
|
||||
{
|
||||
try {
|
||||
$server->refresh();
|
||||
$server->forceDisableServer();
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('Failed to clear local SSH artifacts after transfer', [
|
||||
'server_uuid' => $server->uuid,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write export bundle to the managed host mailbox (air-gapped transfer).
|
||||
*
|
||||
* @param array<string, mixed> $bundle
|
||||
* @return array{path: string, written: bool}
|
||||
*/
|
||||
public function writeMailbox(Server $server, array $bundle, ?string $passphrase = null): array
|
||||
{
|
||||
ServerTransferBundle::assertValid($bundle);
|
||||
|
||||
$exportId = (string) data_get($bundle, 'export_id', new_public_id());
|
||||
$filename = "server-transfer-{$exportId}.coolify.json";
|
||||
$path = ServerTransferBundle::MAILBOX_DIR.'/'.$filename;
|
||||
|
||||
$payload = $passphrase
|
||||
? ServerTransferBundle::encryptWithPassphrase($bundle, $passphrase)
|
||||
: $bundle;
|
||||
|
||||
$json = json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
|
||||
$b64 = base64_encode($json);
|
||||
|
||||
$written = false;
|
||||
try {
|
||||
instant_remote_process([
|
||||
'mkdir -p '.escapeshellarg(ServerTransferBundle::MAILBOX_DIR),
|
||||
'echo '.escapeshellarg($b64).' | base64 -d > '.escapeshellarg($path),
|
||||
'chmod 600 '.escapeshellarg($path),
|
||||
'chown 9999:root '.escapeshellarg($path).' || true',
|
||||
], $server, true);
|
||||
$written = true;
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('Failed to write server transfer mailbox', [
|
||||
'server_uuid' => $server->uuid,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
if (! app()->runningUnitTests()) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'path' => $path,
|
||||
'written' => $written,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\ServerTransfer;
|
||||
|
||||
use App\Models\Server;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
class ServerTransferMigrator
|
||||
{
|
||||
public function __construct(
|
||||
private ServerTransferExporter $exporter,
|
||||
private ServerTransferClaimer $claimer,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* One-shot migrate: export on this instance → import+claim on target → complete on this instance.
|
||||
*
|
||||
* @return array{
|
||||
* server_uuid: string,
|
||||
* export_id: string|null,
|
||||
* target_url: string,
|
||||
* import: array<string, mixed>,
|
||||
* complete: array<string, mixed>,
|
||||
* warnings: list<string>,
|
||||
* message: string
|
||||
* }
|
||||
*/
|
||||
public function migrate(
|
||||
Server $server,
|
||||
string $targetUrl,
|
||||
string $targetToken,
|
||||
bool $writeRemote = false,
|
||||
bool $rebindSentinel = true,
|
||||
bool $preserveUuids = true,
|
||||
bool $adoptMode = true,
|
||||
): array {
|
||||
if ($server->id === 0) {
|
||||
throw new RuntimeException('The Coolify host (localhost) cannot be transferred between instances.');
|
||||
}
|
||||
|
||||
$targetUrl = $this->normalizeTargetUrl($targetUrl);
|
||||
$token = $this->normalizeToken($targetToken);
|
||||
|
||||
$bundle = $this->exporter->export($server, includeSensitive: true);
|
||||
$exportId = data_get($bundle, 'export_id');
|
||||
$warnings = array_values((array) data_get($bundle, 'warnings', []));
|
||||
|
||||
// Target import+claim runs in its own DB transaction on the remote instance.
|
||||
// Source DB is unchanged until markTransferred below — so a failed remote import leaves source intact.
|
||||
$importBody = $this->postImportToTarget(
|
||||
targetUrl: $targetUrl,
|
||||
token: $token,
|
||||
bundle: $bundle,
|
||||
writeRemote: $writeRemote,
|
||||
rebindSentinel: $rebindSentinel,
|
||||
preserveUuids: $preserveUuids,
|
||||
adoptMode: $adoptMode,
|
||||
);
|
||||
|
||||
if (is_array(data_get($importBody, 'warnings'))) {
|
||||
$warnings = array_values(array_unique(array_merge($warnings, $importBody['warnings'])));
|
||||
}
|
||||
|
||||
// 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.
|
||||
try {
|
||||
$complete = $this->claimer->markTransferred(
|
||||
$server,
|
||||
exportId: is_string($exportId) ? $exportId : null,
|
||||
targetInstanceUrl: $targetUrl,
|
||||
);
|
||||
} 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.',
|
||||
previous: $e
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'server_uuid' => $server->uuid,
|
||||
'export_id' => is_string($exportId) ? $exportId : null,
|
||||
'target_url' => $targetUrl,
|
||||
'import' => $importBody,
|
||||
'complete' => $complete,
|
||||
'warnings' => $warnings,
|
||||
'message' => 'Server migrated to '.$targetUrl.'. Automations disabled on this instance.',
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeTargetUrl(string $targetUrl): string
|
||||
{
|
||||
$targetUrl = rtrim(trim($targetUrl), '/');
|
||||
if ($targetUrl === '' || ! filter_var($targetUrl, FILTER_VALIDATE_URL)) {
|
||||
throw new RuntimeException('A valid target instance URL is required (e.g. http://localhost:8001).');
|
||||
}
|
||||
|
||||
// From inside Docker, localhost is this container — use the host gateway for peer instances.
|
||||
if (file_exists('/.dockerenv') || is_file('/run/.containerenv')) {
|
||||
$targetUrl = (string) preg_replace(
|
||||
'#^(https?://)(localhost|127\.0\.0\.1)(?=[:/]|$)#i',
|
||||
'$1host.docker.internal',
|
||||
$targetUrl
|
||||
);
|
||||
}
|
||||
|
||||
return $targetUrl;
|
||||
}
|
||||
|
||||
private function normalizeToken(string $targetToken): string
|
||||
{
|
||||
$token = trim($targetToken);
|
||||
if (str_starts_with(strtolower($token), 'bearer ')) {
|
||||
$token = trim(substr($token, 7));
|
||||
}
|
||||
if ($token === '') {
|
||||
throw new RuntimeException('A target instance API token is required (root or write + create servers).');
|
||||
}
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $bundle
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function postImportToTarget(
|
||||
string $targetUrl,
|
||||
string $token,
|
||||
array $bundle,
|
||||
bool $writeRemote,
|
||||
bool $rebindSentinel,
|
||||
bool $preserveUuids,
|
||||
bool $adoptMode,
|
||||
): array {
|
||||
$importUrl = $targetUrl.'/api/v1/servers/import';
|
||||
|
||||
try {
|
||||
$response = Http::timeout(120)
|
||||
->acceptJson()
|
||||
->withToken($token)
|
||||
->asJson()
|
||||
->post($importUrl, [
|
||||
'bundle' => $bundle,
|
||||
'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);
|
||||
} catch (Throwable $e) {
|
||||
throw new RuntimeException("Transfer to target failed: {$e->getMessage()}", previous: $e);
|
||||
}
|
||||
|
||||
$importBody = $response->json();
|
||||
if (! is_array($importBody)) {
|
||||
$importBody = ['raw' => $response->body()];
|
||||
}
|
||||
|
||||
if (! $response->successful()) {
|
||||
$message = data_get($importBody, 'message')
|
||||
?? data_get($importBody, 'errors')
|
||||
?? $response->body();
|
||||
if (is_array($message)) {
|
||||
$message = json_encode($message);
|
||||
}
|
||||
throw new RuntimeException("Target import failed (HTTP {$response->status()}): {$message}");
|
||||
}
|
||||
|
||||
return $importBody;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user