Files
coolify/app/Services/ServerTransfer/ServerTransferMigrator.php
T
Andras BacsaiandClaude Opus 5.5 3d763c1577 refactor(server-transfer): always claim on import and drop remote claim file
Import now always claims the server and rebinds Sentinel. Remove the
SSH-written ownership file, the write_remote/rebind_sentinel/claim
options, and the POST /servers/{uuid}/claim endpoint. The migrator now
fails when the target cannot take management. Disabling management
records transfer.management_disabled so the server shows as
transferable, and the transfer and import pages are simplified.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 10:56:11 +02:00

198 lines
7.3 KiB
PHP

<?php
namespace App\Services\ServerTransfer;
use App\Models\Server;
use App\Rules\SafeWebhookUrl;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Validator;
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 $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,
preserveUuids: $preserveUuids,
adoptMode: $adoptMode,
);
if (is_array(data_get($importBody, 'warnings'))) {
$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 disable management here.
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()}. ".
'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
);
}
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. https://coolify.example.com).');
}
if ($this->isLocalDevelopmentTarget($targetUrl)) {
if (file_exists('/.dockerenv') || is_file('/run/.containerenv')) {
return (string) preg_replace(
'#^(https?://)(localhost|127\.0\.0\.1)(?=[:/]|$)#i',
'$1host.docker.internal',
$targetUrl,
);
}
return $targetUrl;
}
Validator::make(['target_url' => $targetUrl], [
'target_url' => ['required', new SafeWebhookUrl],
])->validate();
return $targetUrl;
}
private function isLocalDevelopmentTarget(string $url): bool
{
return isDev()
&& in_array(strtolower((string) parse_url($url, PHP_URL_SCHEME)), ['http', 'https'], true)
&& in_array(strtolower((string) parse_url($url, PHP_URL_HOST)), ['localhost', '127.0.0.1', 'host.docker.internal'], true);
}
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 $preserveUuids,
bool $adoptMode,
): array {
$importUrl = $targetUrl.'/api/v1/servers/import';
try {
$response = Http::timeout(120)
->withOptions($this->isLocalDevelopmentTarget($importUrl)
? ['allow_redirects' => false]
: SafeWebhookUrl::httpClientOptions($importUrl))
->acceptJson()
->withToken($token)
->asJson()
->post($importUrl, [
'bundle' => $bundle,
'dry_run' => false,
'preserve_uuids' => $preserveUuids,
'adopt_mode' => $adoptMode,
]);
} 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;
}
}