mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
refactor(server-transfer): always claim on import and drop remote claim file
Import now always claims the server and rebinds Sentinel. Remove the
SSH-written ownership file, the write_remote/rebind_sentinel/claim
options, and the POST /servers/{uuid}/claim endpoint. The migrator now
fails when the target cannot take management. Disabling management
records transfer.management_disabled so the server shows as
transferable, and the transfer and import pages are simplified.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
1e9add3b42
commit
3d763c1577
@@ -20,6 +20,9 @@ class OpenApi extends Command
|
||||
$process = Process::run([
|
||||
'./vendor/bin/openapi',
|
||||
'app',
|
||||
// Server transfer endpoints are development-only (isDev()), so they stay out of the public docs.
|
||||
'--exclude',
|
||||
'Http/Controllers/Api/ServerTransferController.php',
|
||||
'-o',
|
||||
'openapi.yaml',
|
||||
'--version',
|
||||
@@ -32,7 +35,7 @@ class OpenApi extends Command
|
||||
echo $process->output();
|
||||
|
||||
$yaml = file_get_contents('openapi.yaml');
|
||||
|
||||
|
||||
$json = json_encode(Yaml::parse($yaml), JSON_PRETTY_PRINT)."\n";
|
||||
file_put_contents('openapi.json', $json);
|
||||
echo "Converted OpenAPI YAML to JSON.\n";
|
||||
|
||||
@@ -43,8 +43,6 @@ class ServerTransferController extends Controller
|
||||
properties: [
|
||||
new OA\Property(property: 'target_url', type: 'string', example: 'https://coolify-b.example.com'),
|
||||
new OA\Property(property: 'target_token', type: 'string', description: 'API token on the target instance (root or write)'),
|
||||
new OA\Property(property: 'write_remote', type: 'boolean', default: false),
|
||||
new OA\Property(property: 'rebind_sentinel', type: 'boolean', default: true),
|
||||
new OA\Property(property: 'preserve_uuids', type: 'boolean', default: true),
|
||||
new OA\Property(property: 'adopt_mode', type: 'boolean', default: true),
|
||||
]
|
||||
@@ -85,12 +83,10 @@ class ServerTransferController extends Controller
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'target_url' => 'required|string|url',
|
||||
'target_token' => 'required|string',
|
||||
'write_remote' => 'boolean|nullable',
|
||||
'rebind_sentinel' => 'boolean|nullable',
|
||||
'preserve_uuids' => 'boolean|nullable',
|
||||
'adopt_mode' => 'boolean|nullable',
|
||||
]);
|
||||
$allowedFields = ['target_url', 'target_token', 'write_remote', 'rebind_sentinel', 'preserve_uuids', 'adopt_mode'];
|
||||
$allowedFields = ['target_url', 'target_token', 'preserve_uuids', 'adopt_mode'];
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
if ($validator->fails() || $extraFields !== []) {
|
||||
$errors = $validator->errors();
|
||||
@@ -109,8 +105,6 @@ class ServerTransferController extends Controller
|
||||
server: $server,
|
||||
targetUrl: $request->string('target_url')->toString(),
|
||||
targetToken: $request->string('target_token')->toString(),
|
||||
writeRemote: $request->boolean('write_remote', false),
|
||||
rebindSentinel: $request->boolean('rebind_sentinel', true),
|
||||
preserveUuids: $request->boolean('preserve_uuids', true),
|
||||
adoptMode: $request->boolean('adopt_mode', true),
|
||||
);
|
||||
@@ -190,7 +184,7 @@ class ServerTransferController extends Controller
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Import server transfer bundle',
|
||||
description: 'Import a server transfer bundle into this Coolify instance (adopt mode by default).',
|
||||
description: 'Import a server transfer bundle into this Coolify instance (adopt mode by default). This instance then manages the server, and Sentinel reports to it.',
|
||||
path: '/servers/import',
|
||||
operationId: 'import-server-transfer-bundle',
|
||||
security: [['bearerAuth' => []]],
|
||||
@@ -204,9 +198,6 @@ class ServerTransferController extends Controller
|
||||
new OA\Property(property: 'dry_run', type: 'boolean', default: false),
|
||||
new OA\Property(property: 'preserve_uuids', type: 'boolean', default: true),
|
||||
new OA\Property(property: 'adopt_mode', type: 'boolean', default: true, description: 'Import without forcing redeploy; keep statuses for adoption'),
|
||||
new OA\Property(property: 'claim', type: 'boolean', default: true, description: 'Automatically claim the host for this instance after import'),
|
||||
new OA\Property(property: 'write_remote', type: 'boolean', default: false, description: 'When claiming, write ownership file on the host via SSH'),
|
||||
new OA\Property(property: 'rebind_sentinel', type: 'boolean', default: true, description: 'When claiming, rebind Sentinel to this instance'),
|
||||
]
|
||||
)
|
||||
),
|
||||
@@ -237,11 +228,8 @@ class ServerTransferController extends Controller
|
||||
'dry_run' => 'boolean|nullable',
|
||||
'preserve_uuids' => 'boolean|nullable',
|
||||
'adopt_mode' => 'boolean|nullable',
|
||||
'claim' => 'boolean|nullable',
|
||||
'write_remote' => 'boolean|nullable',
|
||||
'rebind_sentinel' => 'boolean|nullable',
|
||||
]);
|
||||
$allowedFields = ['bundle', 'passphrase', 'dry_run', 'preserve_uuids', 'adopt_mode', 'claim', 'write_remote', 'rebind_sentinel'];
|
||||
$allowedFields = ['bundle', 'passphrase', 'dry_run', 'preserve_uuids', 'adopt_mode'];
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
if ($validator->fails() || $extraFields !== []) {
|
||||
$errors = $validator->errors();
|
||||
@@ -274,9 +262,6 @@ class ServerTransferController extends Controller
|
||||
dryRun: $request->boolean('dry_run', false),
|
||||
preserveUuids: $request->boolean('preserve_uuids', true),
|
||||
adoptMode: $request->boolean('adopt_mode', true),
|
||||
claim: $request->boolean('claim', true),
|
||||
writeRemote: $request->boolean('write_remote', false),
|
||||
rebindSentinel: $request->boolean('rebind_sentinel', true),
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
$status = $e instanceof ValidationException ? 422 : 422;
|
||||
@@ -298,73 +283,6 @@ class ServerTransferController extends Controller
|
||||
return response()->json($result, $result['dry_run'] ? 200 : 201);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Claim imported server',
|
||||
description: 'Claim a managed host for this instance: write ownership file and rebind Sentinel.',
|
||||
path: '/servers/{uuid}/claim',
|
||||
operationId: 'claim-server',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Servers'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
content: new OA\JsonContent(
|
||||
properties: [
|
||||
new OA\Property(property: 'write_remote', type: 'boolean', default: true),
|
||||
new OA\Property(property: 'rebind_sentinel', type: 'boolean', default: true),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Claim result'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
]
|
||||
)]
|
||||
public function claim(Request $request, string $uuid): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
|
||||
$server = Server::whereTeamId($teamId)->whereUuid($uuid)->first();
|
||||
if (! $server) {
|
||||
return response()->json(['message' => 'Server not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $server);
|
||||
|
||||
$validator = customApiValidator($request->all(), [
|
||||
'write_remote' => 'boolean|nullable',
|
||||
'rebind_sentinel' => 'boolean|nullable',
|
||||
]);
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'message' => 'Validation failed.',
|
||||
'errors' => $validator->errors(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->claimer->claim(
|
||||
$server,
|
||||
writeRemote: $request->boolean('write_remote', true),
|
||||
rebindSentinel: $request->boolean('rebind_sentinel', true),
|
||||
);
|
||||
} catch (Throwable $e) {
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
auditLog('api.server.claim', [
|
||||
'team_id' => $teamId,
|
||||
'server_uuid' => $server->uuid,
|
||||
'claim_written' => $result['claim_written'],
|
||||
]);
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Mark server transferred',
|
||||
description: 'Source-instance step: disable automations after a successful export/import handoff.',
|
||||
|
||||
@@ -371,11 +371,11 @@ class Show extends Component
|
||||
}
|
||||
|
||||
if ($this->server->isTransferredAway()) {
|
||||
$claimer->claim($this->server, writeRemote: false, rebindSentinel: true);
|
||||
$claimer->claim($this->server);
|
||||
$event = 'ui.server.management_enabled';
|
||||
$message = 'This Coolify instance now manages the server.';
|
||||
} else {
|
||||
$claimer->markTransferred($this->server);
|
||||
$claimer->markTransferred($this->server, managementDisabled: true);
|
||||
$event = 'ui.server.management_disabled';
|
||||
$message = 'Server automations are disabled on this Coolify instance.';
|
||||
}
|
||||
|
||||
@@ -4,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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1013,6 +1013,15 @@ $siteAddress {
|
||||
return data_get($this->server_metadata, 'transfer.status') === 'transferred';
|
||||
}
|
||||
|
||||
/**
|
||||
* Management was disabled manually on this instance; the server is ready to be transferred.
|
||||
*/
|
||||
public function isManagementDisabled(): bool
|
||||
{
|
||||
return $this->isTransferredAway()
|
||||
&& (bool) data_get($this->server_metadata, 'transfer.management_disabled', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this server may be validated / installed against from this instance.
|
||||
*/
|
||||
|
||||
@@ -10,8 +10,6 @@ class ServerTransferBundle
|
||||
{
|
||||
public const SCHEMA_VERSION = 1;
|
||||
|
||||
public const CLAIM_PATH = '/data/coolify/instance-claim.json';
|
||||
|
||||
public const MAILBOX_DIR = '/data/coolify/exports';
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,21 +11,18 @@ use Throwable;
|
||||
class ServerTransferClaimer
|
||||
{
|
||||
/**
|
||||
* Claim a managed host for this Coolify instance.
|
||||
* Take management of a server on this Coolify instance and bind Sentinel to it.
|
||||
*
|
||||
* Database ownership (metadata + Sentinel settings) is committed in one transaction so a
|
||||
* mid-flight failure rolls back. Remote SSH claim-file writes happen after commit and are
|
||||
* best-effort — they cannot participate in the DB transaction.
|
||||
* mid-flight failure rolls back.
|
||||
*
|
||||
* @return array{
|
||||
* server_uuid: string,
|
||||
* claim: array<string, mixed>,
|
||||
* sentinel_rebound: bool,
|
||||
* claim_written: bool,
|
||||
* message: string
|
||||
* }
|
||||
*/
|
||||
public function claim(Server $server, bool $writeRemote = true, bool $rebindSentinel = true): array
|
||||
public function claim(Server $server): array
|
||||
{
|
||||
if ($server->id === 0) {
|
||||
throw new RuntimeException('Cannot claim the Coolify host itself.');
|
||||
@@ -47,18 +44,13 @@ class ServerTransferClaimer
|
||||
'schema_version' => ServerTransferBundle::SCHEMA_VERSION,
|
||||
];
|
||||
|
||||
$result = DB::transaction(function () use ($server, $claim, $rebindSentinel, $instanceUrl) {
|
||||
return DB::transaction(function () use ($server, $claim, $instanceUrl) {
|
||||
$server = Server::query()->with('settings')->lockForUpdate()->findOrFail($server->id);
|
||||
|
||||
$sentinelRebound = false;
|
||||
if ($rebindSentinel && $server->settings) {
|
||||
if ($server->settings) {
|
||||
$server->settings->sentinel_custom_url = $instanceUrl;
|
||||
$server->settings->ensureValidSentinelToken();
|
||||
$server->settings->is_sentinel_enabled = true;
|
||||
$sentinelRebound = true;
|
||||
}
|
||||
|
||||
if ($server->settings) {
|
||||
if (! $server->team->serverOverflow()) {
|
||||
$server->settings->force_disabled = false;
|
||||
}
|
||||
@@ -70,8 +62,6 @@ class ServerTransferClaimer
|
||||
'status' => 'claimed',
|
||||
'claimed_at' => $claim['claimed_at'],
|
||||
'claim' => $claim,
|
||||
'claim_written' => false,
|
||||
'sentinel_rebound' => $sentinelRebound,
|
||||
]);
|
||||
$server->server_metadata = $metadata;
|
||||
$server->save();
|
||||
@@ -79,26 +69,9 @@ class ServerTransferClaimer
|
||||
return [
|
||||
'server_uuid' => $server->uuid,
|
||||
'claim' => $claim,
|
||||
'sentinel_rebound' => $sentinelRebound,
|
||||
'claim_written' => false,
|
||||
'message' => 'This Coolify instance now manages the server.',
|
||||
];
|
||||
});
|
||||
|
||||
// Remote host I/O is outside the transaction (cannot be rolled back with DB rows).
|
||||
$claimWritten = false;
|
||||
if ($writeRemote) {
|
||||
$claimWritten = $this->writeClaimFile($server, $claim);
|
||||
if ($claimWritten) {
|
||||
$this->persistClaimWritten($server);
|
||||
}
|
||||
}
|
||||
|
||||
$result['claim_written'] = $claimWritten;
|
||||
$result['message'] = $claimWritten
|
||||
? 'Server claimed. Ownership file written and Sentinel rebound to this instance.'
|
||||
: 'Server claimed in Coolify. Remote ownership file was not written (SSH unavailable or skipped).';
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,15 +81,18 @@ class ServerTransferClaimer
|
||||
* All database writes run in one transaction so partial disable state cannot stick on failure.
|
||||
* Local SSH key cache cleanup runs after commit (filesystem; not transactional).
|
||||
*
|
||||
* $managementDisabled marks a manual "Disable management" action: the server is not moved yet,
|
||||
* only ready to be transferred to another instance.
|
||||
*
|
||||
* @return array{server_uuid: string, message: string}
|
||||
*/
|
||||
public function markTransferred(Server $server, ?string $exportId = null, ?string $targetInstanceUrl = null): array
|
||||
public function markTransferred(Server $server, ?string $exportId = null, ?string $targetInstanceUrl = null, bool $managementDisabled = false): array
|
||||
{
|
||||
if ($server->id === 0) {
|
||||
throw new RuntimeException('Cannot transfer the Coolify host itself.');
|
||||
}
|
||||
|
||||
$result = DB::transaction(function () use ($server, $exportId, $targetInstanceUrl) {
|
||||
$result = DB::transaction(function () use ($server, $exportId, $targetInstanceUrl, $managementDisabled) {
|
||||
$server = Server::query()->with('settings')->lockForUpdate()->findOrFail($server->id);
|
||||
|
||||
if ($server->settings) {
|
||||
@@ -131,6 +107,7 @@ class ServerTransferClaimer
|
||||
'export_id' => $exportId ?? data_get($metadata, 'transfer.export_id'),
|
||||
'target_instance_url' => $targetInstanceUrl,
|
||||
'transferred_at' => now()->toIso8601String(),
|
||||
'management_disabled' => $managementDisabled,
|
||||
]);
|
||||
$server->server_metadata = $metadata;
|
||||
$server->save();
|
||||
@@ -146,21 +123,6 @@ class ServerTransferClaimer
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function persistClaimWritten(Server $server): void
|
||||
{
|
||||
DB::transaction(function () use ($server) {
|
||||
$server = Server::query()->lockForUpdate()->find($server->id);
|
||||
if (! $server) {
|
||||
return;
|
||||
}
|
||||
|
||||
$metadata = $server->server_metadata ?? [];
|
||||
data_set($metadata, 'transfer.claim_written', true);
|
||||
$server->server_metadata = $metadata;
|
||||
$server->save();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort local SSH mux/key cleanup after transfer (filesystem; not part of DB rollback).
|
||||
* forceDisableServer is idempotent for force_disabled and also clears cached SSH key material.
|
||||
@@ -223,32 +185,4 @@ class ServerTransferClaimer
|
||||
'written' => $written,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $claim
|
||||
*/
|
||||
private function writeClaimFile(Server $server, array $claim): bool
|
||||
{
|
||||
$json = json_encode($claim, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
|
||||
$b64 = base64_encode($json);
|
||||
$path = ServerTransferBundle::CLAIM_PATH;
|
||||
|
||||
try {
|
||||
instant_remote_process([
|
||||
'mkdir -p /data/coolify',
|
||||
'echo '.escapeshellarg($b64).' | base64 -d > '.escapeshellarg($path),
|
||||
'chmod 600 '.escapeshellarg($path),
|
||||
'chown 9999:root '.escapeshellarg($path).' || true',
|
||||
], $server, true);
|
||||
|
||||
return true;
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('Failed to write instance claim file', [
|
||||
'server_uuid' => $server->uuid,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,9 +108,6 @@ class ServerTransferImporter
|
||||
bool $dryRun = false,
|
||||
bool $preserveUuids = true,
|
||||
bool $adoptMode = true,
|
||||
bool $claim = true,
|
||||
bool $writeRemote = false,
|
||||
bool $rebindSentinel = true,
|
||||
): array {
|
||||
ServerTransferBundle::assertValid($bundle);
|
||||
|
||||
@@ -344,21 +341,13 @@ class ServerTransferImporter
|
||||
];
|
||||
});
|
||||
|
||||
// Claim after the import transaction commits so host/SSH work cannot roll back DB rows.
|
||||
if ($claim && filled(data_get($result, 'server_uuid'))) {
|
||||
// Claim after the import transaction commits so a claim failure cannot roll back DB rows.
|
||||
if (filled(data_get($result, 'server_uuid'))) {
|
||||
$server = Server::where('uuid', $result['server_uuid'])->where('team_id', $teamId)->first();
|
||||
if ($server) {
|
||||
try {
|
||||
$claimResult = app(ServerTransferClaimer::class)->claim(
|
||||
$server,
|
||||
writeRemote: $writeRemote,
|
||||
rebindSentinel: $rebindSentinel,
|
||||
);
|
||||
$result['claim'] = app(ServerTransferClaimer::class)->claim($server);
|
||||
$result['claimed'] = true;
|
||||
$result['claim'] = $claimResult;
|
||||
if (! data_get($claimResult, 'claim_written') && $writeRemote) {
|
||||
$result['warnings'][] = 'Server imported and claimed in Coolify, but the remote ownership file was not written (SSH unavailable).';
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$result['claimed'] = false;
|
||||
$result['claim'] = null;
|
||||
|
||||
@@ -34,8 +34,6 @@ class ServerTransferMigrator
|
||||
Server $server,
|
||||
string $targetUrl,
|
||||
string $targetToken,
|
||||
bool $writeRemote = false,
|
||||
bool $rebindSentinel = true,
|
||||
bool $preserveUuids = true,
|
||||
bool $adoptMode = true,
|
||||
): array {
|
||||
@@ -56,8 +54,6 @@ class ServerTransferMigrator
|
||||
targetUrl: $targetUrl,
|
||||
token: $token,
|
||||
bundle: $bundle,
|
||||
writeRemote: $writeRemote,
|
||||
rebindSentinel: $rebindSentinel,
|
||||
preserveUuids: $preserveUuids,
|
||||
adoptMode: $adoptMode,
|
||||
);
|
||||
@@ -66,8 +62,17 @@ class ServerTransferMigrator
|
||||
$warnings = array_values(array_unique(array_merge($warnings, $importBody['warnings'])));
|
||||
}
|
||||
|
||||
// Keep managing the server here until the target really manages it, so it is never left unmanaged.
|
||||
if (data_get($importBody, 'claimed') !== true) {
|
||||
throw new RuntimeException(
|
||||
"Server was imported on {$targetUrl}, but the target could not take management of it. ".
|
||||
'This instance still manages the server. Fix the problem on the target, click Enable management there, then click Disable management here. '.
|
||||
'Target warnings: '.(implode(' ', (array) data_get($importBody, 'warnings', [])) ?: 'none')
|
||||
);
|
||||
}
|
||||
|
||||
// Cross-instance 2PC is impossible: if complete fails after a successful import, the target
|
||||
// already owns the server. Surface that clearly so the operator can retry complete only.
|
||||
// already owns the server. Surface that clearly so the operator can disable management here.
|
||||
try {
|
||||
$complete = $this->claimer->markTransferred(
|
||||
$server,
|
||||
@@ -77,7 +82,7 @@ class ServerTransferMigrator
|
||||
} catch (Throwable $e) {
|
||||
throw new RuntimeException(
|
||||
"Server was imported on {$targetUrl}, but this instance could not mark it as transferred: {$e->getMessage()}. ".
|
||||
'Retry complete (API: POST /api/v1/servers/{uuid}/complete) so automations stay disabled here. Do not re-import on the target.',
|
||||
'Disable management of this server here (or API: POST /api/v1/servers/{uuid}/transfer/complete) so automations stay disabled. Do not re-import on the target.',
|
||||
previous: $e
|
||||
);
|
||||
}
|
||||
@@ -147,8 +152,6 @@ class ServerTransferMigrator
|
||||
string $targetUrl,
|
||||
string $token,
|
||||
array $bundle,
|
||||
bool $writeRemote,
|
||||
bool $rebindSentinel,
|
||||
bool $preserveUuids,
|
||||
bool $adoptMode,
|
||||
): array {
|
||||
@@ -167,9 +170,6 @@ class ServerTransferMigrator
|
||||
'dry_run' => false,
|
||||
'preserve_uuids' => $preserveUuids,
|
||||
'adopt_mode' => $adoptMode,
|
||||
'claim' => true,
|
||||
'write_remote' => $writeRemote,
|
||||
'rebind_sentinel' => $rebindSentinel,
|
||||
]);
|
||||
} catch (ConnectionException $e) {
|
||||
throw new RuntimeException("Could not reach target instance at {$importUrl}: {$e->getMessage()}", previous: $e);
|
||||
|
||||
-376
@@ -16407,382 +16407,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/servers\/{uuid}\/migrate": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Servers"
|
||||
],
|
||||
"summary": "Migrate server to another Coolify instance",
|
||||
"description": "One-shot handoff: export this server, import+claim on the target instance (using the provided token), then disable automations here. Requires read:sensitive and write.",
|
||||
"operationId": "migrate-server-between-instances",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "uuid",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"required": [
|
||||
"target_url",
|
||||
"target_token"
|
||||
],
|
||||
"properties": {
|
||||
"target_url": {
|
||||
"type": "string",
|
||||
"example": "https:\/\/coolify-b.example.com"
|
||||
},
|
||||
"target_token": {
|
||||
"description": "API token on the target instance (root or write)",
|
||||
"type": "string"
|
||||
},
|
||||
"write_remote": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"rebind_sentinel": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"preserve_uuids": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"adopt_mode": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Migrated"
|
||||
},
|
||||
"403": {
|
||||
"description": "Missing sensitive permission"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation or remote import failed"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/servers\/{uuid}\/export": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Servers"
|
||||
],
|
||||
"summary": "Export server transfer bundle",
|
||||
"description": "Export a server and all resources hosted on it as a versioned transfer bundle for moving between Coolify instances. Requires read:sensitive.",
|
||||
"operationId": "export-server-transfer-bundle",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "uuid",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "encrypt",
|
||||
"in": "query",
|
||||
"description": "If true and passphrase is provided, return an encrypted envelope.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "passphrase",
|
||||
"in": "query",
|
||||
"description": "Passphrase used when encrypt=true.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Transfer bundle"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"403": {
|
||||
"description": "Missing sensitive permission"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/servers\/import": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Servers"
|
||||
],
|
||||
"summary": "Import server transfer bundle",
|
||||
"description": "Import a server transfer bundle into this Coolify instance (adopt mode by default).",
|
||||
"operationId": "import-server-transfer-bundle",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"bundle": {
|
||||
"description": "Plain or encrypted transfer bundle",
|
||||
"type": "object"
|
||||
},
|
||||
"passphrase": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"dry_run": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"preserve_uuids": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"adopt_mode": {
|
||||
"description": "Import without forcing redeploy; keep statuses for adoption",
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"claim": {
|
||||
"description": "Automatically claim the host for this instance after import",
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"write_remote": {
|
||||
"description": "When claiming, write ownership file on the host via SSH",
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"rebind_sentinel": {
|
||||
"description": "When claiming, rebind Sentinel to this instance",
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Dry-run result"
|
||||
},
|
||||
"201": {
|
||||
"description": "Imported"
|
||||
},
|
||||
"401": {
|
||||
"$ref": "#\/components\/responses\/401"
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation failed"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/servers\/{uuid}\/claim": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Servers"
|
||||
],
|
||||
"summary": "Claim imported server",
|
||||
"description": "Claim a managed host for this instance: write ownership file and rebind Sentinel.",
|
||||
"operationId": "claim-server",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "uuid",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"write_remote": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"rebind_sentinel": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Claim result"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/servers\/{uuid}\/transfer\/complete": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Servers"
|
||||
],
|
||||
"summary": "Mark server transferred",
|
||||
"description": "Source-instance step: disable automations after a successful export\/import handoff.",
|
||||
"operationId": "complete-server-transfer",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "uuid",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"export_id": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"target_instance_url": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Marked transferred"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/servers\/{uuid}\/export\/mailbox": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Servers"
|
||||
],
|
||||
"summary": "Write transfer bundle to server mailbox",
|
||||
"description": "Write an export bundle to \/data\/coolify\/exports on the managed host for air-gapped import.",
|
||||
"operationId": "export-server-transfer-mailbox",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "uuid",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application\/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"passphrase": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Mailbox write result"
|
||||
},
|
||||
"403": {
|
||||
"description": "Missing sensitive permission"
|
||||
},
|
||||
"404": {
|
||||
"$ref": "#\/components\/responses\/404"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"\/servers": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
||||
-245
@@ -10406,251 +10406,6 @@ paths:
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
'/servers/{uuid}/migrate':
|
||||
post:
|
||||
tags:
|
||||
- Servers
|
||||
summary: 'Migrate server to another Coolify instance'
|
||||
description: 'One-shot handoff: export this server, import+claim on the target instance (using the provided token), then disable automations here. Requires read:sensitive and write.'
|
||||
operationId: migrate-server-between-instances
|
||||
parameters:
|
||||
-
|
||||
name: uuid
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
required:
|
||||
- target_url
|
||||
- target_token
|
||||
properties:
|
||||
target_url:
|
||||
type: string
|
||||
example: 'https://coolify-b.example.com'
|
||||
target_token:
|
||||
description: 'API token on the target instance (root or write)'
|
||||
type: string
|
||||
write_remote:
|
||||
type: boolean
|
||||
default: false
|
||||
rebind_sentinel:
|
||||
type: boolean
|
||||
default: true
|
||||
preserve_uuids:
|
||||
type: boolean
|
||||
default: true
|
||||
adopt_mode:
|
||||
type: boolean
|
||||
default: true
|
||||
type: object
|
||||
responses:
|
||||
'200':
|
||||
description: Migrated
|
||||
'403':
|
||||
description: 'Missing sensitive permission'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
'422':
|
||||
description: 'Validation or remote import failed'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
'/servers/{uuid}/export':
|
||||
get:
|
||||
tags:
|
||||
- Servers
|
||||
summary: 'Export server transfer bundle'
|
||||
description: 'Export a server and all resources hosted on it as a versioned transfer bundle for moving between Coolify instances. Requires read:sensitive.'
|
||||
operationId: export-server-transfer-bundle
|
||||
parameters:
|
||||
-
|
||||
name: uuid
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
-
|
||||
name: encrypt
|
||||
in: query
|
||||
description: 'If true and passphrase is provided, return an encrypted envelope.'
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
-
|
||||
name: passphrase
|
||||
in: query
|
||||
description: 'Passphrase used when encrypt=true.'
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: 'Transfer bundle'
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'403':
|
||||
description: 'Missing sensitive permission'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
/servers/import:
|
||||
post:
|
||||
tags:
|
||||
- Servers
|
||||
summary: 'Import server transfer bundle'
|
||||
description: 'Import a server transfer bundle into this Coolify instance (adopt mode by default).'
|
||||
operationId: import-server-transfer-bundle
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
bundle:
|
||||
description: 'Plain or encrypted transfer bundle'
|
||||
type: object
|
||||
passphrase:
|
||||
type: [string, 'null']
|
||||
dry_run:
|
||||
type: boolean
|
||||
default: false
|
||||
preserve_uuids:
|
||||
type: boolean
|
||||
default: true
|
||||
adopt_mode:
|
||||
description: 'Import without forcing redeploy; keep statuses for adoption'
|
||||
type: boolean
|
||||
default: true
|
||||
claim:
|
||||
description: 'Automatically claim the host for this instance after import'
|
||||
type: boolean
|
||||
default: true
|
||||
write_remote:
|
||||
description: 'When claiming, write ownership file on the host via SSH'
|
||||
type: boolean
|
||||
default: false
|
||||
rebind_sentinel:
|
||||
description: 'When claiming, rebind Sentinel to this instance'
|
||||
type: boolean
|
||||
default: true
|
||||
type: object
|
||||
responses:
|
||||
'200':
|
||||
description: 'Dry-run result'
|
||||
'201':
|
||||
description: Imported
|
||||
'401':
|
||||
$ref: '#/components/responses/401'
|
||||
'422':
|
||||
description: 'Validation failed'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
'/servers/{uuid}/claim':
|
||||
post:
|
||||
tags:
|
||||
- Servers
|
||||
summary: 'Claim imported server'
|
||||
description: 'Claim a managed host for this instance: write ownership file and rebind Sentinel.'
|
||||
operationId: claim-server
|
||||
parameters:
|
||||
-
|
||||
name: uuid
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
write_remote:
|
||||
type: boolean
|
||||
default: true
|
||||
rebind_sentinel:
|
||||
type: boolean
|
||||
default: true
|
||||
type: object
|
||||
responses:
|
||||
'200':
|
||||
description: 'Claim result'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
'/servers/{uuid}/transfer/complete':
|
||||
post:
|
||||
tags:
|
||||
- Servers
|
||||
summary: 'Mark server transferred'
|
||||
description: 'Source-instance step: disable automations after a successful export/import handoff.'
|
||||
operationId: complete-server-transfer
|
||||
parameters:
|
||||
-
|
||||
name: uuid
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
export_id:
|
||||
type: [string, 'null']
|
||||
target_instance_url:
|
||||
type: [string, 'null']
|
||||
type: object
|
||||
responses:
|
||||
'200':
|
||||
description: 'Marked transferred'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
'/servers/{uuid}/export/mailbox':
|
||||
post:
|
||||
tags:
|
||||
- Servers
|
||||
summary: 'Write transfer bundle to server mailbox'
|
||||
description: 'Write an export bundle to /data/coolify/exports on the managed host for air-gapped import.'
|
||||
operationId: export-server-transfer-mailbox
|
||||
parameters:
|
||||
-
|
||||
name: uuid
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
passphrase:
|
||||
type: [string, 'null']
|
||||
type: object
|
||||
responses:
|
||||
'200':
|
||||
description: 'Mailbox write result'
|
||||
'403':
|
||||
description: 'Missing sensitive permission'
|
||||
'404':
|
||||
$ref: '#/components/responses/404'
|
||||
security:
|
||||
-
|
||||
bearerAuth: []
|
||||
/servers:
|
||||
get:
|
||||
tags:
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<a href="{{ route('server.transfer.import') }}" {{ wireNavigate() }}
|
||||
class="button w-fit shrink-0 whitespace-nowrap">
|
||||
<x-reicon name="upload" class="size-3.5" />
|
||||
Import transfer
|
||||
Import server
|
||||
<x-status-badge label="Dev" />
|
||||
</a>
|
||||
@endcan
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
@php
|
||||
$serverRows = $servers->map(function ($server) {
|
||||
$isTransferredAway = $server->isTransferredAway();
|
||||
$isTransferredAway = isDev() && $server->isTransferredAway();
|
||||
$isReady = $server->settings->is_reachable
|
||||
&& $server->settings->is_usable
|
||||
&& ! $server->settings->force_disabled
|
||||
@@ -38,6 +38,7 @@
|
||||
$sentinelNeedsAttention = $isReady && $server->isSentinelEnabled() && $server->sentinelStatus() === 'out_of_sync';
|
||||
|
||||
$status = match (true) {
|
||||
$isTransferredAway && $server->isManagementDisabled() => 'Transferable',
|
||||
$isTransferredAway => 'Transferred away',
|
||||
$server->settings->force_disabled => 'Disabled',
|
||||
$proxyNeedsAttention || $sentinelNeedsAttention => 'Attention required',
|
||||
|
||||
@@ -111,18 +111,20 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<x-process-dialog closeWithX mobileFullscreen size="xl" :open="$isValidating">
|
||||
<x-slot:title>Validate and configure</x-slot:title>
|
||||
<x-slot:content>
|
||||
<livewire:server.validate-and-install :server="$server"
|
||||
:ask="$server->isFunctional() && ! $isValidating" />
|
||||
</x-slot:content>
|
||||
<x-forms.button type="button" :isHighlighted="! $server->isFunctional()"
|
||||
@click="processDialogOpen = true" wire:click.prevent="validateServer">
|
||||
<x-reicon :name="$server->isFunctional() ? 'refresh' : 'alert-circle'" class="size-3.5" />
|
||||
{{ $server->isFunctional() ? 'Revalidate connection' : 'Validate connection' }}
|
||||
</x-forms.button>
|
||||
</x-process-dialog>
|
||||
@if ($server->canBeValidated())
|
||||
<x-process-dialog closeWithX mobileFullscreen size="xl" :open="$isValidating">
|
||||
<x-slot:title>Validate and configure</x-slot:title>
|
||||
<x-slot:content>
|
||||
<livewire:server.validate-and-install :server="$server"
|
||||
:ask="$server->isFunctional() && ! $isValidating" />
|
||||
</x-slot:content>
|
||||
<x-forms.button type="button" :isHighlighted="! $server->isFunctional()"
|
||||
@click="processDialogOpen = true" wire:click.prevent="validateServer">
|
||||
<x-reicon :name="$server->isFunctional() ? 'refresh' : 'alert-circle'" class="size-3.5" />
|
||||
{{ $server->isFunctional() ? 'Revalidate connection' : 'Validate connection' }}
|
||||
</x-forms.button>
|
||||
</x-process-dialog>
|
||||
@endif
|
||||
@if (isDev())
|
||||
<div wire:key="server-management-{{ $server->isTransferredAway() ? 'enable' : 'disable' }}">
|
||||
@if ($server->isTransferredAway())
|
||||
@@ -156,15 +158,24 @@
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@if ($server->isTransferredAway())
|
||||
<x-status-badge label="Transferred away" type="warning" />
|
||||
@if (isDev() && $server->isTransferredAway())
|
||||
@unless ($server->isManagementDisabled())
|
||||
<x-status-badge label="Transferred away" type="warning" />
|
||||
@endunless
|
||||
@else
|
||||
<x-status-badge :label="$server->isFunctional() ? 'Ready' : 'Validation required'"
|
||||
:type="$server->isFunctional() ? 'success' : 'warning'" />
|
||||
@endif
|
||||
</x-slot:actions>
|
||||
|
||||
@if ($server->isTransferredAway())
|
||||
@if (isDev() && $server->isManagementDisabled())
|
||||
<x-callout type="warning" title="Transferable to another instance" class="mb-4">
|
||||
Management of this server is disabled on this Coolify instance. You can now
|
||||
<a href="{{ route('server.transfer', ['server_uuid' => $server->uuid]) }}"
|
||||
{{ wireNavigate() }} class="underline">transfer it to another instance</a>, or enable
|
||||
management again to manage it here.
|
||||
</x-callout>
|
||||
@elseif (isDev() && $server->isTransferredAway())
|
||||
<x-callout type="warning" title="Transferred to another instance" class="mb-4">
|
||||
This server was migrated away from this Coolify instance. It cannot be revalidated or
|
||||
managed here. Use the target instance, or delete this server when you no longer need the
|
||||
@@ -299,7 +310,9 @@
|
||||
{{ $server->name }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs leading-5 text-neutral-500 dark:text-fg-dim">
|
||||
@if ($server->isTransferredAway())
|
||||
@if (isDev() && $server->isManagementDisabled())
|
||||
Management is disabled on this Coolify instance. You can transfer this server to another instance.
|
||||
@elseif (isDev() && $server->isTransferredAway())
|
||||
This server was migrated away from this Coolify instance and cannot be managed here.
|
||||
@elseif ($server->isFunctional())
|
||||
The server is reachable, validated, and ready to host resources.
|
||||
|
||||
@@ -1,79 +1,128 @@
|
||||
<div>
|
||||
<div class="w-full">
|
||||
<x-slot:title>
|
||||
Import server transfer | Coolify
|
||||
Import server | Coolify
|
||||
</x-slot>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h1>Import server transfer <x-status-badge label="Dev" /></h1>
|
||||
<a href="{{ route('server.index') }}" {{ wireNavigate() }}>
|
||||
<x-forms.button>Back to servers</x-forms.button>
|
||||
</a>
|
||||
</div>
|
||||
<div class="subtitle">
|
||||
Paste or upload a transfer bundle exported from another Coolify instance. This creates the server and its
|
||||
resources under the current team and <strong>claims</strong> the host for this instance
|
||||
(control-plane only — host data stays on the machine).
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 rounded-lg border border-neutral-200 p-4 dark:border-coolgray-200">
|
||||
<x-forms.input type="file" id="bundleFile" label="Bundle file (.json)" accept=".json,application/json" />
|
||||
<x-forms.textarea id="bundleJson" label="Or paste bundle JSON" rows="14" placeholder='{"schema_version":1,...}' />
|
||||
<x-forms.input id="passphrase" type="password" label="Passphrase (if encrypted)" placeholder="Optional" />
|
||||
<div class="flex flex-col gap-2">
|
||||
<x-forms.checkbox id="preserveUuids" label="Preserve UUIDs from the source instance" />
|
||||
<x-forms.checkbox id="adoptMode" label="Adopt mode (keep statuses; do not force exited redeploy)" />
|
||||
<x-forms.checkbox id="writeRemote" label="Also write ownership file on the host via SSH (optional)" />
|
||||
<div class="mb-5 flex min-h-9 flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h1 class="min-w-0 text-[24px]! leading-7! font-semibold! tracking-tight!">Import server</h1>
|
||||
<x-status-badge label="Dev" />
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<p class="mt-1 text-[13px] text-neutral-500 dark:text-fg-dim">
|
||||
Import a server that you downloaded from another Coolify instance. This instance then manages it.
|
||||
</p>
|
||||
</div>
|
||||
<a href="{{ route('server.index') }}" class="button w-fit shrink-0" {{ wireNavigate() }}>
|
||||
Back to servers
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="application-settings-form flex flex-col gap-6">
|
||||
<x-application.settings-section id="server-import-file-section" title="Transfer file"
|
||||
helper="On the source instance, open the server, go to Transfer, and click Download JSON in Manual transfer. The server data on the machine does not change.">
|
||||
<x-slot:actions>
|
||||
<x-forms.button wire:click="dryRun" wire:loading.attr="disabled" wire:target="dryRun,importBundle">
|
||||
<x-reicon name="search" class="size-3.5" />
|
||||
<span wire:loading.remove wire:target="dryRun">Dry run</span>
|
||||
<span wire:loading wire:target="dryRun">Checking…</span>
|
||||
</x-forms.button>
|
||||
<x-modal-confirmation title="Import this server?" submitAction="importBundle"
|
||||
:confirmWithText="false" :confirmWithPassword="false" step2ButtonText="Import server"
|
||||
:actions="['The server from the bundle will be added to the current team.']">
|
||||
:actions="[
|
||||
'The server and its resources will be added to the current team.',
|
||||
'This instance will manage the server. Disable management of it on the source instance.',
|
||||
]">
|
||||
<x-slot:trigger>
|
||||
<x-forms.button wire:loading.attr="disabled" wire:target="dryRun,importBundle">
|
||||
<x-forms.button isHighlighted wire:loading.attr="disabled" wire:target="dryRun,importBundle">
|
||||
<x-reicon name="upload" class="size-3.5" />
|
||||
<span wire:loading.remove wire:target="importBundle">Import server</span>
|
||||
<span wire:loading wire:target="importBundle">Importing…</span>
|
||||
</x-forms.button>
|
||||
</x-slot:trigger>
|
||||
</x-modal-confirmation>
|
||||
</x-slot:actions>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<x-forms.input type="file" id="bundleFile" label="Upload file"
|
||||
accept=".json,application/json" />
|
||||
<x-forms.textarea id="bundleJson" label="Or paste the file content" rows="10"
|
||||
placeholder='{"schema_version":1,...}' />
|
||||
<div class="md:max-w-xl">
|
||||
<x-forms.input id="passphrase" type="password" label="Passphrase"
|
||||
placeholder="Only for encrypted files" autocomplete="off" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
|
||||
<x-application.settings-section id="server-import-options-section" title="Options">
|
||||
<div class="flex flex-col gap-1 md:max-w-xl">
|
||||
<x-forms.checkbox id="preserveUuids" label="Keep UUIDs from the source instance"
|
||||
helper="Resources keep the same UUIDs, so their names, containers, and webhook URLs stay the same." />
|
||||
<x-forms.checkbox id="adoptMode" label="Adopt running resources"
|
||||
helper="Keep the current status of each resource and do not redeploy stopped resources." />
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
|
||||
@if (count($lastWarnings) > 0)
|
||||
<div class="rounded-lg border border-warning/40 bg-warning/10 p-3 text-sm">
|
||||
<div class="mb-1 font-semibold text-warning">Warnings</div>
|
||||
<ul class="list-disc space-y-1 pl-5">
|
||||
<x-callout type="warning" title="Warnings">
|
||||
<ul class="mt-1 list-disc space-y-1 pl-5">
|
||||
@foreach ($lastWarnings as $warning)
|
||||
<li>{{ $warning }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
</x-callout>
|
||||
@endif
|
||||
|
||||
@if ($lastResult)
|
||||
<div class="rounded-lg border border-neutral-200 p-4 dark:border-coolgray-200">
|
||||
<div class="mb-2 font-semibold">
|
||||
{{ data_get($lastResult, 'dry_run') ? 'Dry-run result' : 'Import result' }}
|
||||
@php
|
||||
$createdCounts = collect(data_get($lastResult, 'created', []))
|
||||
->filter(fn ($count) => (int) $count > 0);
|
||||
@endphp
|
||||
<x-application.settings-section id="server-import-result-section"
|
||||
:title="data_get($lastResult, 'dry_run') ? 'Dry run result' : 'Import result'">
|
||||
<x-slot:actions>
|
||||
@if (data_get($lastResult, 'dry_run'))
|
||||
<x-status-badge label="Nothing written" type="neutral" />
|
||||
@elseif (data_get($lastResult, 'claimed'))
|
||||
<x-status-badge label="Managed here" type="success" />
|
||||
@else
|
||||
<x-status-badge label="Management not enabled" type="warning" />
|
||||
@endif
|
||||
@if ($importedServerUuid)
|
||||
<a href="{{ route('server.show', ['server_uuid' => $importedServerUuid]) }}" class="button"
|
||||
{{ wireNavigate() }}>
|
||||
Open server
|
||||
<x-reicon name="arrow-right" class="size-3.5" />
|
||||
</a>
|
||||
@endif
|
||||
</x-slot:actions>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
@if (filled(data_get($lastResult, 'server_uuid')))
|
||||
<p class="text-xs text-neutral-500 dark:text-fg-dim">
|
||||
Server UUID
|
||||
<code class="ml-1 font-mono text-neutral-700 dark:text-fg">{{ data_get($lastResult, 'server_uuid') }}</code>
|
||||
</p>
|
||||
@endif
|
||||
|
||||
@if ($createdCounts->isNotEmpty())
|
||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4">
|
||||
@foreach ($createdCounts as $type => $count)
|
||||
<div
|
||||
class="rounded-lg border border-neutral-200 px-3 py-2 dark:border-white/[0.08]">
|
||||
<div class="text-[15px] font-semibold text-neutral-950 dark:text-fg">
|
||||
{{ $count }}</div>
|
||||
<div class="text-[11px] text-neutral-500 dark:text-fg-faint">
|
||||
{{ str($type)->replace('_', ' ')->ucfirst() }}</div>
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
@else
|
||||
<p class="text-xs text-neutral-500 dark:text-fg-dim">No resources in this file.</p>
|
||||
@endif
|
||||
</div>
|
||||
@if ($importedServerUuid)
|
||||
<div class="mb-3 flex flex-wrap items-center gap-2 text-sm">
|
||||
<span>Server UUID: <code class="font-mono">{{ $importedServerUuid }}</code></span>
|
||||
@if (data_get($lastResult, 'claimed'))
|
||||
<span class="text-success">Claimed</span>
|
||||
@endif
|
||||
<a href="{{ route('server.show', ['server_uuid' => $importedServerUuid]) }}" {{ wireNavigate() }}>
|
||||
<x-forms.button>Open server</x-forms.button>
|
||||
</a>
|
||||
<a href="{{ route('server.transfer', ['server_uuid' => $importedServerUuid]) }}" {{ wireNavigate() }}>
|
||||
<x-forms.button>Transfer details</x-forms.button>
|
||||
</a>
|
||||
</div>
|
||||
@endif
|
||||
<pre class="max-h-80 overflow-auto rounded-lg bg-neutral-100 p-3 text-xs dark:bg-coolgray-100">{{ json_encode($lastResult, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}</pre>
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,9 +21,13 @@
|
||||
<x-application.settings-section id="server-transfer-section" title="Transfer server (Dev)"
|
||||
helper="Move this server’s control-plane config to another Coolify instance (same physical host).">
|
||||
<x-slot:actions>
|
||||
<x-status-badge
|
||||
:label="$this->transferStatus ?: 'ready'"
|
||||
type="neutral" />
|
||||
@if ($server->isManagementDisabled())
|
||||
<x-status-badge label="Transferable" type="warning" />
|
||||
@elseif ($server->isTransferredAway())
|
||||
<x-status-badge label="Transferred away" type="warning" />
|
||||
@else
|
||||
<x-status-badge label="Managed here" type="success" />
|
||||
@endif
|
||||
@if ($exportId)
|
||||
<span class="text-[11px] text-neutral-500 dark:text-fg-faint">export {{ $exportId }}</span>
|
||||
@endif
|
||||
@@ -35,8 +39,8 @@
|
||||
</h3>
|
||||
<p class="mt-1 text-xs leading-5 text-neutral-500 dark:text-fg-dim">
|
||||
Enter the target Coolify URL and an API token from that instance (root recommended).
|
||||
This exports the server, imports and claims it on the target, then disables automations
|
||||
here.
|
||||
This exports the server and imports it on the target, which then manages it. Management
|
||||
is then disabled here.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 md:max-w-xl">
|
||||
@@ -46,8 +50,6 @@
|
||||
<x-forms.input id="targetToken" type="password" label="Target API token" required
|
||||
placeholder="Paste token from target instance" autocomplete="off"
|
||||
helper="Create a token on the target with root (or write + create servers). It is only used for this request." />
|
||||
<x-forms.checkbox id="writeRemote"
|
||||
label="Write ownership file on the host via SSH (optional)" />
|
||||
</div>
|
||||
<div>
|
||||
<x-modal-confirmation title="Transfer this server?" submitAction="migrateServer"
|
||||
@@ -84,72 +86,28 @@
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
|
||||
<x-application.settings-section id="server-transfer-advanced-section" title="Advanced"
|
||||
helper="Manual export, complete-only, and re-claim helpers for air-gapped or partial transfers.">
|
||||
<div class="flex flex-col gap-6" x-data="{ open: @entangle('showAdvanced') }">
|
||||
<button type="button"
|
||||
class="flex w-full items-center justify-between rounded-lg border border-neutral-200 px-4 py-3 text-left dark:border-white/[0.08]"
|
||||
@click="open = !open">
|
||||
<span class="text-sm font-semibold">Show advanced options</span>
|
||||
<span class="text-xs text-neutral-500 dark:text-fg-faint"
|
||||
x-text="open ? 'Hide' : 'Show'"></span>
|
||||
</button>
|
||||
|
||||
<div class="flex flex-col gap-6" x-show="open" x-cloak>
|
||||
<div>
|
||||
<h4 class="text-sm font-medium">Download bundle</h4>
|
||||
<p class="mb-3 text-xs leading-5 text-neutral-500 dark:text-fg-dim">
|
||||
Manual / air-gapped transfer. Import on the target via Servers → Import transfer.
|
||||
</p>
|
||||
<div class="mb-3 flex flex-col gap-3 md:max-w-xl">
|
||||
<x-forms.checkbox id="encryptBundle" label="Encrypt with passphrase" />
|
||||
<x-forms.input id="passphrase" type="password" label="Passphrase"
|
||||
placeholder="Used when encrypt is checked" autocomplete="new-password" />
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<x-forms.button canGate="view" :canResource="$server" wire:click="exportBundle"
|
||||
wire:loading.attr="disabled">
|
||||
<span wire:loading.remove wire:target="exportBundle">Download JSON</span>
|
||||
<span wire:loading wire:target="exportBundle">Exporting…</span>
|
||||
</x-forms.button>
|
||||
<a href="{{ route('server.transfer.import') }}" {{ wireNavigate() }}
|
||||
class="button">
|
||||
Import page (this instance)
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="text-sm font-medium">Complete only</h4>
|
||||
<p class="mb-3 text-xs leading-5 text-neutral-500 dark:text-fg-dim">
|
||||
Disable automations after a manual import on the target.
|
||||
</p>
|
||||
<x-modal-confirmation title="Disable automations on this server?"
|
||||
submitAction="completeTransfer" :confirmWithText="false"
|
||||
:confirmWithPassword="false" step2ButtonText="Disable automations"
|
||||
:actions="['The server will be marked as transferred and automations will be disabled on this instance.']">
|
||||
<x-slot:trigger>
|
||||
<x-forms.button canGate="update" :canResource="$server">
|
||||
Mark transferred & disable automations
|
||||
</x-forms.button>
|
||||
</x-slot:trigger>
|
||||
</x-modal-confirmation>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="text-sm font-medium">Re-claim only</h4>
|
||||
<p class="mb-3 text-xs leading-5 text-neutral-500 dark:text-fg-dim">
|
||||
Retry claim on this instance (after a local import).
|
||||
</p>
|
||||
<div class="mb-3 flex flex-col gap-2">
|
||||
<x-forms.checkbox id="writeRemoteOnClaim" label="Write ownership file via SSH" />
|
||||
<x-forms.checkbox id="rebindSentinelOnClaim" label="Rebind Sentinel" />
|
||||
</div>
|
||||
<x-forms.button canGate="update" :canResource="$server" wire:click="claimServer"
|
||||
wire:loading.attr="disabled">
|
||||
Re-claim
|
||||
</x-forms.button>
|
||||
</div>
|
||||
<x-application.settings-section id="server-transfer-manual-section" title="Manual transfer"
|
||||
helper="Download the server as a file, for example when the instances cannot reach each other.">
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-xs leading-5 text-neutral-500 dark:text-fg-dim">
|
||||
Import the file on the target via Servers → Import server, then click Disable management
|
||||
on this server.
|
||||
</p>
|
||||
<div class="flex flex-col gap-3 md:max-w-xl">
|
||||
<x-forms.input id="passphrase" type="password" label="Passphrase"
|
||||
placeholder="Optional"
|
||||
helper="If you set a passphrase, the file is encrypted with it. You need the same passphrase to import the file."
|
||||
autocomplete="new-password" />
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<x-forms.button canGate="view" :canResource="$server" wire:click="exportBundle"
|
||||
wire:loading.attr="disabled">
|
||||
<span wire:loading.remove wire:target="exportBundle">Download JSON</span>
|
||||
<span wire:loading wire:target="exportBundle">Exporting…</span>
|
||||
</x-forms.button>
|
||||
<a href="{{ route('server.transfer.import') }}" {{ wireNavigate() }} class="button">
|
||||
Import page (this instance)
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -88,7 +88,6 @@ test('server transfer API is unavailable outside development mode', function (st
|
||||
['POST', '/api/v1/servers/import'],
|
||||
['GET', '/api/v1/servers/{uuid}/export'],
|
||||
['POST', '/api/v1/servers/{uuid}/export/mailbox'],
|
||||
['POST', '/api/v1/servers/{uuid}/claim'],
|
||||
['POST', '/api/v1/servers/{uuid}/transfer/complete'],
|
||||
['POST', '/api/v1/servers/{uuid}/migrate'],
|
||||
]);
|
||||
@@ -250,7 +249,7 @@ describe('POST /api/v1/servers/import', function () {
|
||||
]);
|
||||
|
||||
$this->withHeaders(transferHeaders($this->sensitiveToken))
|
||||
->postJson('/api/v1/servers/import', ['bundle' => $export, 'claim' => false])
|
||||
->postJson('/api/v1/servers/import', ['bundle' => $export])
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('errors.server.0', 'Server limit reached for your subscription.');
|
||||
|
||||
@@ -258,8 +257,8 @@ describe('POST /api/v1/servers/import', function () {
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/servers/{uuid}/claim', function () {
|
||||
test('claims imported server without remote write', function () {
|
||||
describe('POST /api/v1/servers/import claim', function () {
|
||||
test('import takes management of the server and rebinds Sentinel', function () {
|
||||
$export = $this->withHeaders(transferHeaders($this->sensitiveToken))
|
||||
->getJson("/api/v1/servers/{$this->server->uuid}/export")
|
||||
->json();
|
||||
@@ -268,27 +267,31 @@ describe('POST /api/v1/servers/{uuid}/claim', function () {
|
||||
$this->server->forceDelete();
|
||||
$this->privateKey->delete();
|
||||
|
||||
$importedUuid = $this->withHeaders(transferHeaders($this->sensitiveToken))
|
||||
$this->withHeaders(transferHeaders($this->sensitiveToken))
|
||||
->postJson('/api/v1/servers/import', ['bundle' => $export])
|
||||
->json('server_uuid');
|
||||
->assertCreated()
|
||||
->assertJsonPath('claimed', true)
|
||||
->assertJsonPath('claim.claim.instance_url', 'https://coolify-a.test');
|
||||
|
||||
$response = $this->withHeaders(transferHeaders($this->sensitiveToken))
|
||||
->postJson("/api/v1/servers/{$importedUuid}/claim", [
|
||||
'write_remote' => false,
|
||||
'rebind_sentinel' => true,
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonPath('server_uuid', $importedUuid)
|
||||
->assertJsonPath('claim_written', false)
|
||||
->assertJsonPath('sentinel_rebound', true)
|
||||
->assertJsonPath('claim.instance_url', 'https://coolify-a.test');
|
||||
|
||||
$server = Server::where('uuid', $importedUuid)->first();
|
||||
$server = Server::where('uuid', $export['server']['uuid'])->first();
|
||||
expect(data_get($server->server_metadata, 'transfer.status'))->toBe('claimed')
|
||||
->and((bool) $server->settings->is_sentinel_enabled)->toBeTrue()
|
||||
->and($server->settings->sentinel_custom_url)->toBe('https://coolify-a.test')
|
||||
->and($server->settings->sentinel_token)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
test('claim endpoint is removed', function () {
|
||||
$this->withHeaders(transferHeaders($this->sensitiveToken))
|
||||
->postJson("/api/v1/servers/{$this->server->uuid}/claim")
|
||||
->assertNotFound();
|
||||
});
|
||||
|
||||
test('import rejects the removed claim options', function () {
|
||||
$this->withHeaders(transferHeaders($this->sensitiveToken))
|
||||
->postJson('/api/v1/servers/import', ['bundle' => [], 'write_remote' => true])
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors(['write_remote']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/v1/servers/{uuid}/transfer/complete', function () {
|
||||
@@ -311,7 +314,7 @@ describe('POST /api/v1/servers/{uuid}/transfer/complete', function () {
|
||||
});
|
||||
|
||||
describe('full transfer flow A to B on same process', function () {
|
||||
test('export complete import claim sequence', function () {
|
||||
test('export complete import sequence', function () {
|
||||
$export = $this->withHeaders(transferHeaders($this->sensitiveToken))
|
||||
->getJson("/api/v1/servers/{$this->server->uuid}/export")
|
||||
->assertOk()
|
||||
@@ -338,22 +341,14 @@ describe('full transfer flow A to B on same process', function () {
|
||||
Once::flush();
|
||||
}
|
||||
|
||||
$import = $this->withHeaders(transferHeaders($this->sensitiveToken))
|
||||
$this->withHeaders(transferHeaders($this->sensitiveToken))
|
||||
->postJson('/api/v1/servers/import', [
|
||||
'bundle' => $export,
|
||||
'preserve_uuids' => true,
|
||||
'adopt_mode' => true,
|
||||
])
|
||||
->assertCreated()
|
||||
->json();
|
||||
|
||||
$this->withHeaders(transferHeaders($this->sensitiveToken))
|
||||
->postJson("/api/v1/servers/{$import['server_uuid']}/claim", [
|
||||
'write_remote' => false,
|
||||
'rebind_sentinel' => true,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('claim.instance_url', 'https://coolify-b.test');
|
||||
->assertJsonPath('claimed', true);
|
||||
|
||||
$server = Server::where('uuid', $export['server']['uuid'])->first();
|
||||
expect($server)->not->toBeNull()
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Services\ServerTransfer\ServerTransferClaimer;
|
||||
use App\Services\ServerTransfer\ServerTransferExporter;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
@@ -47,13 +48,34 @@ test('server transfer links are hidden outside development mode', function () {
|
||||
|
||||
$this->get('/servers')
|
||||
->assertOk()
|
||||
->assertDontSee('Import transfer');
|
||||
->assertDontSee('/servers/import');
|
||||
|
||||
$this->get('/server/'.$this->server->uuid)
|
||||
->assertOk()
|
||||
->assertDontSee('Transfer', escape: false);
|
||||
});
|
||||
|
||||
test('transfer state is hidden outside development mode', function (bool $managementDisabled) {
|
||||
app(ServerTransferClaimer::class)->markTransferred($this->server, managementDisabled: $managementDisabled);
|
||||
config(['app.env' => 'production']);
|
||||
|
||||
$this->get('/servers')
|
||||
->assertOk()
|
||||
->assertDontSee('Transferable')
|
||||
->assertDontSee('Transferred away');
|
||||
|
||||
$this->get('/server/'.$this->server->uuid)
|
||||
->assertOk()
|
||||
->assertDontSee('Transfer', escape: false)
|
||||
->assertDontSee('/transfer"', escape: false)
|
||||
->assertDontSee('another instance')
|
||||
->assertDontSee('Enable management')
|
||||
->assertDontSee('Disable management');
|
||||
})->with([
|
||||
'management disabled' => [true],
|
||||
'transferred away' => [false],
|
||||
]);
|
||||
|
||||
test('transfer page renders for owned server', function () {
|
||||
Livewire::test(Transfer::class, ['server_uuid' => $this->server->uuid])
|
||||
->assertOk()
|
||||
@@ -61,13 +83,13 @@ test('transfer page renders for owned server', function () {
|
||||
->assertSee('Target instance URL')
|
||||
->assertSee('Target API token')
|
||||
->assertSee('Transfer server')
|
||||
->assertSee('Advanced');
|
||||
->assertSee('Manual transfer');
|
||||
});
|
||||
|
||||
test('transfer import page renders for admin', function () {
|
||||
Livewire::test(TransferImport::class)
|
||||
->assertOk()
|
||||
->assertSee('Import server transfer')
|
||||
->assertSee('Transfer file')
|
||||
->assertSee('Dry run')
|
||||
->assertSee('Import server');
|
||||
});
|
||||
@@ -78,6 +100,35 @@ test('export bundle streams download from livewire', function () {
|
||||
->assertFileDownloaded('server-transfer-'.$this->server->uuid.'.json');
|
||||
});
|
||||
|
||||
test('transfer page badge shows the management state of the server', function (string $state, string $label, array $hidden) {
|
||||
$claimer = app(ServerTransferClaimer::class);
|
||||
match ($state) {
|
||||
'managed' => null,
|
||||
'disabled' => $claimer->markTransferred($this->server, managementDisabled: true),
|
||||
'transferred' => $claimer->markTransferred($this->server, targetInstanceUrl: 'https://coolify-b.test'),
|
||||
};
|
||||
|
||||
Livewire::test(Transfer::class, ['server_uuid' => $this->server->fresh()->uuid])
|
||||
->assertSee($label)
|
||||
->assertDontSee($hidden);
|
||||
})->with([
|
||||
'managed' => ['managed', 'Managed here', ['Transferable', 'Transferred away']],
|
||||
'management disabled' => ['disabled', 'Transferable', ['Transferred away', 'Managed here']],
|
||||
'transferred' => ['transferred', 'Transferred away', ['Transferable', 'Managed here']],
|
||||
]);
|
||||
|
||||
test('export bundle is encrypted only when a passphrase is set', function (?string $passphrase, string $fileName) {
|
||||
Livewire::test(Transfer::class, ['server_uuid' => $this->server->uuid])
|
||||
->set('passphrase', $passphrase)
|
||||
->call('exportBundle')
|
||||
->assertFileDownloaded('server-transfer-'.$this->server->uuid.$fileName);
|
||||
})->with([
|
||||
'null' => [null, '.json'],
|
||||
'empty string' => ['', '.json'],
|
||||
'only spaces' => [' ', '.json'],
|
||||
'set' => ['secret-pass', '.encrypted.json'],
|
||||
]);
|
||||
|
||||
test('import dry run from pasted json', function () {
|
||||
$bundle = app(ServerTransferExporter::class)->export($this->server);
|
||||
|
||||
@@ -86,7 +137,9 @@ test('import dry run from pasted json', function () {
|
||||
->set('bundleJson', json_encode($bundle))
|
||||
->call('dryRun')
|
||||
->assertSet('lastResult.dry_run', true)
|
||||
->assertSet('lastResult.server_uuid', $this->server->uuid);
|
||||
->assertSet('lastResult.server_uuid', $this->server->uuid)
|
||||
->assertSee('Dry run result')
|
||||
->assertSee('Nothing written');
|
||||
});
|
||||
|
||||
test('import creates server after source removal', function () {
|
||||
@@ -100,7 +153,6 @@ test('import creates server after source removal', function () {
|
||||
->set('bundleJson', json_encode($bundle))
|
||||
->set('preserveUuids', true)
|
||||
->set('adoptMode', true)
|
||||
->set('writeRemote', false)
|
||||
->call('importBundle')
|
||||
->assertSet('importedServerUuid', $uuid)
|
||||
->assertSet('lastResult.dry_run', false)
|
||||
|
||||
@@ -40,10 +40,34 @@ test('owner can stop managing a server from this instance', function () {
|
||||
$this->server->refresh();
|
||||
|
||||
expect($this->server->isTransferredAway())->toBeTrue()
|
||||
->and($this->server->isManagementDisabled())->toBeTrue()
|
||||
->and((bool) $this->server->settings->force_disabled)->toBeTrue()
|
||||
->and((bool) $this->server->settings->is_sentinel_enabled)->toBeFalse();
|
||||
});
|
||||
|
||||
test('server with disabled management shows it is transferable, not transferred away', function () {
|
||||
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
|
||||
->call('toggleManagement')
|
||||
->assertSee('Transferable to another instance')
|
||||
->assertSee(route('server.transfer', ['server_uuid' => $this->server->uuid]))
|
||||
->assertDontSee('Transferred to another instance')
|
||||
->assertDontSee('Transferred away')
|
||||
->assertDontSeeHtml('>Transferable<');
|
||||
});
|
||||
|
||||
test('server transferred to another instance is not reported as management disabled', function () {
|
||||
app(ServerTransferClaimer::class)->markTransferred($this->server, targetInstanceUrl: 'https://coolify-b.test');
|
||||
|
||||
$this->server->refresh();
|
||||
|
||||
expect($this->server->isTransferredAway())->toBeTrue()
|
||||
->and($this->server->isManagementDisabled())->toBeFalse();
|
||||
|
||||
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
|
||||
->assertSee('Transferred to another instance')
|
||||
->assertDontSee('Transferable to another instance');
|
||||
});
|
||||
|
||||
test('owner can take management of a transferred server', function () {
|
||||
$this->server->server_metadata = [
|
||||
'transfer' => ['status' => 'transferred'],
|
||||
@@ -122,7 +146,7 @@ test('claiming a server keeps it force disabled when the cloud team is over its
|
||||
]);
|
||||
markServerTransferredAway($this->server);
|
||||
|
||||
app(ServerTransferClaimer::class)->claim($this->server, writeRemote: false);
|
||||
app(ServerTransferClaimer::class)->claim($this->server);
|
||||
|
||||
$this->server->refresh();
|
||||
|
||||
@@ -154,6 +178,18 @@ test('server overview shows a management button that matches the ownership state
|
||||
->assertDontSee('Disable management');
|
||||
});
|
||||
|
||||
test('server validation button is hidden while management is disabled or transferred away', function () {
|
||||
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
|
||||
->assertSeeHtml('wire:click.prevent="validateServer"')
|
||||
->call('toggleManagement')
|
||||
->assertDontSeeHtml('wire:click.prevent="validateServer"');
|
||||
|
||||
app(ServerTransferClaimer::class)->markTransferred($this->server->fresh(), targetInstanceUrl: 'https://coolify-b.test');
|
||||
|
||||
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
|
||||
->assertDontSeeHtml('wire:click.prevent="validateServer"');
|
||||
});
|
||||
|
||||
test('management ownership is not available outside development', function () {
|
||||
config(['app.env' => 'production']);
|
||||
|
||||
|
||||
@@ -46,21 +46,16 @@ test('markTransferred writes force_disabled and transfer status together', funct
|
||||
->and((bool) $this->server->settings->is_sentinel_enabled)->toBeFalse();
|
||||
});
|
||||
|
||||
test('claim persists ownership metadata transactionally without remote write', function () {
|
||||
$result = app(ServerTransferClaimer::class)->claim(
|
||||
$this->server,
|
||||
writeRemote: false,
|
||||
rebindSentinel: true,
|
||||
);
|
||||
test('claim persists ownership metadata and always rebinds Sentinel', function () {
|
||||
$result = app(ServerTransferClaimer::class)->claim($this->server);
|
||||
|
||||
expect($result['server_uuid'])->toBe($this->server->uuid)
|
||||
->and($result['claim_written'])->toBeFalse()
|
||||
->and($result['sentinel_rebound'])->toBeTrue()
|
||||
->and($result)->not->toHaveKey('claim_written')
|
||||
->and(data_get($result, 'claim.instance_url'))->toBe('https://coolify-a.test');
|
||||
|
||||
$this->server->refresh();
|
||||
expect(data_get($this->server->server_metadata, 'transfer.status'))->toBe('claimed')
|
||||
->and(data_get($this->server->server_metadata, 'transfer.claim_written'))->toBeFalse()
|
||||
->and(data_get($this->server->server_metadata, 'transfer.export_id'))->toBe('export-xyz')
|
||||
->and((bool) $this->server->settings->is_sentinel_enabled)->toBeTrue()
|
||||
->and((string) $this->server->settings->sentinel_custom_url)->toBe('https://coolify-a.test');
|
||||
});
|
||||
|
||||
@@ -352,7 +352,6 @@ test('import rolls back all created rows when a later resource fails', function
|
||||
dryRun: false,
|
||||
preserveUuids: true,
|
||||
adoptMode: true,
|
||||
claim: false,
|
||||
))->toThrow(RuntimeException::class, 'Unsupported database type');
|
||||
|
||||
expect(Server::where('uuid', $originalServerUuid)->exists())->toBeFalse()
|
||||
|
||||
@@ -57,7 +57,6 @@ test('migrate exports imports via http and completes locally', function () {
|
||||
server: $this->server,
|
||||
targetUrl: 'http://8.8.8.8',
|
||||
targetToken: 'target-token-xyz',
|
||||
writeRemote: false,
|
||||
);
|
||||
|
||||
expect($result['server_uuid'])->toBe($this->server->uuid)
|
||||
@@ -72,7 +71,7 @@ test('migrate exports imports via http and completes locally', function () {
|
||||
Http::assertSent(function ($request) {
|
||||
return $request->url() === 'http://8.8.8.8/api/v1/servers/import'
|
||||
&& $request->hasHeader('Authorization', 'Bearer target-token-xyz')
|
||||
&& data_get($request->data(), 'claim') === true
|
||||
&& ! array_key_exists('write_remote', $request->data())
|
||||
&& data_get($request->data(), 'bundle.server.uuid') === $this->server->uuid;
|
||||
});
|
||||
});
|
||||
@@ -175,5 +174,26 @@ test('migrate surfaces recovery guidance when complete fails after successful re
|
||||
$this->server,
|
||||
'http://8.8.8.8',
|
||||
'token',
|
||||
))->toThrow(RuntimeException::class, 'Retry complete');
|
||||
))->toThrow(RuntimeException::class, 'Disable management of this server here');
|
||||
});
|
||||
|
||||
test('migrate keeps managing the server here when the target could not take management', function () {
|
||||
Http::fake([
|
||||
'http://8.8.8.8/api/v1/servers/import' => Http::response([
|
||||
'dry_run' => false,
|
||||
'server_uuid' => $this->server->uuid,
|
||||
'claimed' => false,
|
||||
'warnings' => ['Server imported, but automatic claim failed: boom'],
|
||||
], 201),
|
||||
]);
|
||||
|
||||
expect(fn () => app(ServerTransferMigrator::class)->migrate(
|
||||
$this->server,
|
||||
'http://8.8.8.8',
|
||||
'token',
|
||||
))->toThrow(RuntimeException::class, 'could not take management');
|
||||
|
||||
$this->server->refresh();
|
||||
expect($this->server->isTransferredAway())->toBeFalse()
|
||||
->and((bool) $this->server->settings->force_disabled)->toBeFalse();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user