mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-14 08:18:08 +00:00
feat(resources): add cross-server resource migration (dev-only) (#11165)
This commit is contained in:
1 parent
d83ba627cd
commit
c15e3b35fd
15 files changed
+1354
-7
No files matched your search
@@ -0,0 +1,290 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Shared;
|
||||
|
||||
use App\Actions\Application\StopApplication;
|
||||
use App\Actions\Database\StopDatabase;
|
||||
use App\Actions\Service\StopService;
|
||||
use App\Jobs\FinalizeResourceMigrationJob;
|
||||
use App\Jobs\HostPathCloneJob;
|
||||
use App\Jobs\ServerStorageSaveJob;
|
||||
use App\Jobs\VolumeCloneJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\LocalPersistentVolume;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Models\StandaloneKeydb;
|
||||
use App\Models\StandaloneMariadb;
|
||||
use App\Models\StandaloneMongodb;
|
||||
use App\Models\StandaloneMysql;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Models\SwarmDocker;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
class MigrateResourceToDestination
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
/**
|
||||
* @return array{async: bool, volume_jobs: int, message: string}
|
||||
*/
|
||||
public function handle(
|
||||
Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource,
|
||||
StandaloneDocker|SwarmDocker $destination,
|
||||
bool $migrateVolumes = true,
|
||||
): array {
|
||||
if (! isDev()) {
|
||||
throw ValidationException::withMessages([
|
||||
'destination_id' => 'Resource migration is only available in development mode.',
|
||||
]);
|
||||
}
|
||||
|
||||
$resource->loadMissing(['destination.server']);
|
||||
$sourceDestination = $resource->destination;
|
||||
|
||||
if (! $sourceDestination) {
|
||||
throw ValidationException::withMessages([
|
||||
'destination_id' => 'Resource has no destination to migrate from.',
|
||||
]);
|
||||
}
|
||||
|
||||
if (
|
||||
(int) $sourceDestination->id === (int) $destination->id
|
||||
&& $sourceDestination->getMorphClass() === $destination->getMorphClass()
|
||||
) {
|
||||
throw ValidationException::withMessages([
|
||||
'destination_id' => 'Resource is already on the selected destination.',
|
||||
]);
|
||||
}
|
||||
|
||||
$sourceServer = $sourceDestination->server;
|
||||
$targetServer = $destination->server;
|
||||
|
||||
if (! $targetServer) {
|
||||
throw ValidationException::withMessages([
|
||||
'destination_id' => 'Target destination has no server.',
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $targetServer->canHostResources()) {
|
||||
throw ValidationException::withMessages([
|
||||
'destination_id' => 'The selected server cannot host resources.',
|
||||
]);
|
||||
}
|
||||
|
||||
$targetServer->refresh();
|
||||
if (! $targetServer->isFunctional()) {
|
||||
throw ValidationException::withMessages([
|
||||
'destination_id' => 'Target server is not validated and reachable.',
|
||||
]);
|
||||
}
|
||||
|
||||
$crossServer = $sourceServer && (int) $sourceServer->id !== (int) $targetServer->id;
|
||||
|
||||
if (! $crossServer) {
|
||||
throw ValidationException::withMessages([
|
||||
'destination_id' => 'Migration requires a different server. Choose another server destination.',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($migrateVolumes) {
|
||||
if (! $sourceServer?->isFunctional()) {
|
||||
throw ValidationException::withMessages([
|
||||
'destination_id' => 'Source server is not functional. Cannot migrate volume data.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->stopResource($resource);
|
||||
|
||||
$jobs = [];
|
||||
if ($migrateVolumes) {
|
||||
$jobs = $this->buildVolumeJobs($resource, $sourceServer, $targetServer);
|
||||
}
|
||||
|
||||
if ($jobs !== []) {
|
||||
Bus::chain([
|
||||
...$jobs,
|
||||
new FinalizeResourceMigrationJob($resource, $destination),
|
||||
])->dispatch();
|
||||
|
||||
return [
|
||||
'async' => true,
|
||||
'volume_jobs' => count($jobs),
|
||||
'message' => 'Migration started. The resource was stopped and volume data is being transferred. Destination will update when transfer completes. Redeploy afterwards.',
|
||||
];
|
||||
}
|
||||
|
||||
$this->applyDestination($resource, $destination);
|
||||
|
||||
return [
|
||||
'async' => false,
|
||||
'volume_jobs' => 0,
|
||||
'message' => $migrateVolumes
|
||||
? 'Resource migrated to the new server. Redeploy when ready.'
|
||||
: 'Resource migrated to the new server. Volume data was not transferred. Redeploy when ready.',
|
||||
];
|
||||
}
|
||||
|
||||
public function applyDestination(
|
||||
Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource,
|
||||
StandaloneDocker|SwarmDocker $destination,
|
||||
): void {
|
||||
$payload = [
|
||||
'destination_id' => $destination->id,
|
||||
'destination_type' => $destination->getMorphClass(),
|
||||
];
|
||||
|
||||
if ($resource instanceof Service) {
|
||||
$payload['server_id'] = $destination->server_id;
|
||||
} else {
|
||||
// Service status is computed from child containers, not a DB column.
|
||||
$payload['status'] = 'exited';
|
||||
$payload['started_at'] = null;
|
||||
}
|
||||
|
||||
$resource->fill($payload)->save();
|
||||
|
||||
if ($resource instanceof Application) {
|
||||
$resource->additional_networks()->detach();
|
||||
$this->regenerateApplicationLabels($resource->fresh(['destination.server', 'settings']));
|
||||
}
|
||||
|
||||
if ($resource instanceof Service) {
|
||||
foreach ($resource->applications() as $application) {
|
||||
$application->fill(['status' => 'exited'])->save();
|
||||
}
|
||||
foreach ($resource->databases() as $database) {
|
||||
$database->fill(['status' => 'exited'])->save();
|
||||
}
|
||||
}
|
||||
|
||||
$this->resaveFileStorages($resource->fresh());
|
||||
}
|
||||
|
||||
protected function stopResource(
|
||||
Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource,
|
||||
): void {
|
||||
try {
|
||||
if ($resource instanceof Application) {
|
||||
StopApplication::run($resource, previewDeployments: false, dockerCleanup: false);
|
||||
} elseif ($resource instanceof Service) {
|
||||
StopService::run($resource, deleteConnectedNetworks: false, dockerCleanup: false);
|
||||
} else {
|
||||
StopDatabase::run($resource, dockerCleanup: false);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
\Log::warning('Failed to stop resource during migration: '.$e->getMessage(), [
|
||||
'resource_type' => $resource->getMorphClass(),
|
||||
'resource_uuid' => $resource->uuid ?? null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, VolumeCloneJob|HostPathCloneJob>
|
||||
*/
|
||||
protected function buildVolumeJobs(
|
||||
Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource,
|
||||
$sourceServer,
|
||||
$targetServer,
|
||||
): array {
|
||||
$jobs = [];
|
||||
$seenNamedVolumes = [];
|
||||
$seenHostPaths = [];
|
||||
|
||||
foreach ($this->collectPersistentVolumes($resource) as $volume) {
|
||||
if (! $volume instanceof LocalPersistentVolume) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$hostPath = filled($volume->host_path) ? (string) $volume->host_path : null;
|
||||
|
||||
if ($hostPath) {
|
||||
if (isset($seenHostPaths[$hostPath])) {
|
||||
continue;
|
||||
}
|
||||
$seenHostPaths[$hostPath] = true;
|
||||
$jobs[] = new HostPathCloneJob($hostPath, $hostPath, $sourceServer, $targetServer);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = (string) $volume->name;
|
||||
if ($name === '' || isset($seenNamedVolumes[$name])) {
|
||||
continue;
|
||||
}
|
||||
$seenNamedVolumes[$name] = true;
|
||||
$jobs[] = new VolumeCloneJob($name, $name, $sourceServer, $targetServer, $volume);
|
||||
}
|
||||
|
||||
return $jobs;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, LocalPersistentVolume>
|
||||
*/
|
||||
protected function collectPersistentVolumes(
|
||||
Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource,
|
||||
) {
|
||||
if ($resource instanceof Service) {
|
||||
$volumes = collect();
|
||||
foreach ($resource->applications() as $application) {
|
||||
$volumes = $volumes->merge($application->persistentStorages()->get());
|
||||
}
|
||||
foreach ($resource->databases() as $database) {
|
||||
$volumes = $volumes->merge($database->persistentStorages()->get());
|
||||
}
|
||||
|
||||
return $volumes;
|
||||
}
|
||||
|
||||
return $resource->persistentStorages()->get();
|
||||
}
|
||||
|
||||
protected function regenerateApplicationLabels(Application $application): void
|
||||
{
|
||||
$settings = $application->settings;
|
||||
if (! $settings || ! $settings->is_container_label_readonly_enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($application->destination?->server?->proxyType() === 'NONE') {
|
||||
return;
|
||||
}
|
||||
|
||||
$customLabels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
|
||||
$application->custom_labels = base64_encode($customLabels);
|
||||
$application->save();
|
||||
}
|
||||
|
||||
protected function resaveFileStorages(
|
||||
Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource,
|
||||
): void {
|
||||
$fileStorages = collect();
|
||||
|
||||
if ($resource instanceof Service) {
|
||||
foreach ($resource->applications() as $application) {
|
||||
$fileStorages = $fileStorages->merge($application->fileStorages()->get());
|
||||
}
|
||||
foreach ($resource->databases() as $database) {
|
||||
$fileStorages = $fileStorages->merge($database->fileStorages()->get());
|
||||
}
|
||||
} elseif (method_exists($resource, 'fileStorages')) {
|
||||
$fileStorages = $resource->fileStorages()->get();
|
||||
}
|
||||
|
||||
foreach ($fileStorages as $storage) {
|
||||
if ($storage->is_host_file) {
|
||||
continue;
|
||||
}
|
||||
ServerStorageSaveJob::dispatch($storage);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,7 @@ class SshMultiplexingHelper
|
||||
|
||||
$scpCommand .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'), isScp: true);
|
||||
|
||||
// Upload: local source -> remote dest
|
||||
if ($server->isIpv6()) {
|
||||
return $scpCommand.escapeshellarg($source).' '.escapeshellarg($server->user).'@['.escapeshellarg($server->ip).']:'.escapeshellarg($dest);
|
||||
}
|
||||
@@ -134,6 +135,46 @@ class SshMultiplexingHelper
|
||||
return $scpCommand.escapeshellarg($source).' '.self::escapedUserAtHost($server).':'.escapeshellarg($dest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an SCP command that downloads a remote file onto the Coolify host.
|
||||
*/
|
||||
public static function generateScpDownloadCommand(Server $server, string $remoteSource, string $localDest): string
|
||||
{
|
||||
$sshConfig = self::serverSshConfiguration($server);
|
||||
$sshKeyLocation = $sshConfig['sshKeyLocation'];
|
||||
$scpCommand = 'timeout '.config('constants.ssh.command_timeout').' scp ';
|
||||
|
||||
if ($server->isIpv6()) {
|
||||
$scpCommand .= '-6 ';
|
||||
}
|
||||
|
||||
if (self::isMultiplexingEnabled()) {
|
||||
try {
|
||||
if (self::ensureMultiplexedConnection($server)) {
|
||||
$scpCommand .= self::multiplexingOptions($server);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('SSH multiplexing failed for SCP download, falling back to non-multiplexed connection', [
|
||||
'server' => $server->name ?? $server->ip,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (data_get($server, 'settings.is_cloudflare_tunnel')) {
|
||||
$scpCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" ';
|
||||
}
|
||||
|
||||
$scpCommand .= self::getCommonSshOptions($server, $sshKeyLocation, self::getConnectionTimeout($server), config('constants.ssh.server_interval'), isScp: true);
|
||||
|
||||
// Download: remote source -> local dest
|
||||
if ($server->isIpv6()) {
|
||||
return $scpCommand.escapeshellarg($server->user).'@['.escapeshellarg($server->ip).']:'.escapeshellarg($remoteSource).' '.escapeshellarg($localDest);
|
||||
}
|
||||
|
||||
return $scpCommand.self::escapedUserAtHost($server).':'.escapeshellarg($remoteSource).' '.escapeshellarg($localDest);
|
||||
}
|
||||
|
||||
public static function generateSshCommand(Server $server, string $command, bool $disableMultiplexing = false, ?int $commandTimeout = null): string
|
||||
{
|
||||
if ($server->settings->force_disabled) {
|
||||
|
||||
@@ -4361,6 +4361,54 @@ class ApplicationsController extends Controller
|
||||
return moveResourceToEnvironment($request, $application, 'Application', $teamId);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Migrate to Server',
|
||||
description: 'Migrate an application to another destination/server owned by the authenticated team. Stops the application, optionally transfers persistent volume data when both servers are managed by Coolify, and updates database records. Redeploy after migration completes.',
|
||||
path: '/applications/{uuid}/migrate',
|
||||
operationId: 'migrate-application-by-uuid',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Applications'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'UUID of the application.', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['destination_uuid'],
|
||||
properties: [
|
||||
new OA\Property(property: 'destination_uuid', type: 'string', description: 'UUID of the target destination.'),
|
||||
new OA\Property(property: 'migrate_volumes', type: 'boolean', default: true, description: 'Whether to transfer persistent volume data when migrating across servers.'),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Application migration started or completed.'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
)]
|
||||
public function migrate_by_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$uuid = $request->route('uuid');
|
||||
if (! $uuid) {
|
||||
return response()->json(['message' => 'UUID is required.'], 400);
|
||||
}
|
||||
$application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first();
|
||||
if (! $application) {
|
||||
return response()->json(['message' => 'Application not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $application);
|
||||
|
||||
return migrateResourceToDestination($request, $application, 'Application', $teamId);
|
||||
}
|
||||
|
||||
private function validateDataApplications(Request $request, Server $server)
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
|
||||
@@ -3054,6 +3054,54 @@ class DatabasesController extends Controller
|
||||
return moveResourceToEnvironment($request, $database, 'Database', $teamId);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Migrate to Server',
|
||||
description: 'Migrate a database to another destination/server owned by the authenticated team. Stops the database, optionally transfers persistent volume data when both servers are managed by Coolify, and updates database records. Redeploy after migration completes.',
|
||||
path: '/databases/{uuid}/migrate',
|
||||
operationId: 'migrate-database-by-uuid',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Databases'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'UUID of the database.', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['destination_uuid'],
|
||||
properties: [
|
||||
new OA\Property(property: 'destination_uuid', type: 'string', description: 'UUID of the target destination.'),
|
||||
new OA\Property(property: 'migrate_volumes', type: 'boolean', default: true, description: 'Whether to transfer persistent volume data when migrating across servers.'),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Database migration started or completed.'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
)]
|
||||
public function migrate_by_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$uuid = $request->route('uuid');
|
||||
if (! $uuid) {
|
||||
return response()->json(['message' => 'UUID is required.'], 400);
|
||||
}
|
||||
$database = queryDatabaseByUuidWithinTeam($request->uuid, $teamId);
|
||||
if (! $database) {
|
||||
return response()->json(['message' => 'Database not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $database);
|
||||
|
||||
return migrateResourceToDestination($request, $database, 'Database', $teamId);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Start',
|
||||
description: 'Start database.',
|
||||
|
||||
@@ -1974,6 +1974,54 @@ class ServicesController extends Controller
|
||||
return moveResourceToEnvironment($request, $service, 'Service', $teamId);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Migrate to Server',
|
||||
description: 'Migrate a service to another destination/server owned by the authenticated team. Stops the service, optionally transfers persistent volume data when both servers are managed by Coolify, and updates database records. Redeploy after migration completes.',
|
||||
path: '/services/{uuid}/migrate',
|
||||
operationId: 'migrate-service-by-uuid',
|
||||
security: [['bearerAuth' => []]],
|
||||
tags: ['Services'],
|
||||
parameters: [
|
||||
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'UUID of the service.', schema: new OA\Schema(type: 'string')),
|
||||
],
|
||||
requestBody: new OA\RequestBody(
|
||||
required: true,
|
||||
content: new OA\JsonContent(
|
||||
required: ['destination_uuid'],
|
||||
properties: [
|
||||
new OA\Property(property: 'destination_uuid', type: 'string', description: 'UUID of the target destination.'),
|
||||
new OA\Property(property: 'migrate_volumes', type: 'boolean', default: true, description: 'Whether to transfer persistent volume data when migrating across servers.'),
|
||||
]
|
||||
)
|
||||
),
|
||||
responses: [
|
||||
new OA\Response(response: 200, description: 'Service migration started or completed.'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
)]
|
||||
public function migrate_by_uuid(Request $request): JsonResponse
|
||||
{
|
||||
$teamId = getTeamIdFromToken();
|
||||
if (is_null($teamId)) {
|
||||
return invalidTokenResponse();
|
||||
}
|
||||
$uuid = $request->route('uuid');
|
||||
if (! $uuid) {
|
||||
return response()->json(['message' => 'UUID is required.'], 400);
|
||||
}
|
||||
$service = Service::whereRelation('environment.project.team', 'id', $teamId)->whereUuid($request->uuid)->first();
|
||||
if (! $service) {
|
||||
return response()->json(['message' => 'Service not found.'], 404);
|
||||
}
|
||||
|
||||
$this->authorize('update', $service);
|
||||
|
||||
return migrateResourceToDestination($request, $service, 'Service', $teamId);
|
||||
}
|
||||
|
||||
#[OA\Post(
|
||||
summary: 'Start',
|
||||
description: 'Start service.',
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Actions\Shared\MigrateResourceToDestination;
|
||||
use App\Models\Application;
|
||||
use App\Models\Service;
|
||||
use App\Models\StandaloneClickhouse;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\StandaloneDragonfly;
|
||||
use App\Models\StandaloneKeydb;
|
||||
use App\Models\StandaloneMariadb;
|
||||
use App\Models\StandaloneMongodb;
|
||||
use App\Models\StandaloneMysql;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use App\Models\SwarmDocker;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Final step of a server migration: update destination pointers after volume data is transferred.
|
||||
*/
|
||||
class FinalizeResourceMigrationJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource,
|
||||
public StandaloneDocker|SwarmDocker $destination,
|
||||
) {
|
||||
$this->onQueue('high');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
MigrateResourceToDestination::make()->applyDestination(
|
||||
$this->resource->fresh(),
|
||||
$this->destination
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Server;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Copy a bind-mount host path from one Coolify-managed server to another.
|
||||
*/
|
||||
class HostPathCloneJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
protected string $cloneDir = '/data/coolify/clone';
|
||||
|
||||
public int $timeout = 3600;
|
||||
|
||||
public function __construct(
|
||||
protected string $sourcePath,
|
||||
protected string $targetPath,
|
||||
protected Server $sourceServer,
|
||||
protected Server $targetServer
|
||||
) {
|
||||
$this->onQueue('high');
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
if ($this->sourceServer->id === $this->targetServer->id && $this->sourcePath === $this->targetPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->sourceServer->id === $this->targetServer->id) {
|
||||
$this->cloneLocalPath();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->cloneRemotePath();
|
||||
}
|
||||
|
||||
protected function cloneLocalPath(): void
|
||||
{
|
||||
$src = escapeshellarg($this->sourcePath);
|
||||
$tgt = escapeshellarg($this->targetPath);
|
||||
$tgtParent = escapeshellarg(dirname($this->targetPath));
|
||||
|
||||
instant_remote_process([
|
||||
"mkdir -p {$tgtParent}",
|
||||
"mkdir -p {$tgt}",
|
||||
"docker run --rm -v {$src}:/source:ro -v {$tgt}:/target alpine sh -c 'cp -a /source/. /target/'",
|
||||
], $this->sourceServer);
|
||||
}
|
||||
|
||||
protected function cloneRemotePath(): void
|
||||
{
|
||||
$archiveName = 'hostpath-data.tar.gz';
|
||||
$token = Str::uuid()->toString();
|
||||
$sourceCloneDir = "{$this->cloneDir}/hostpath-{$token}";
|
||||
$targetCloneDir = "{$this->cloneDir}/hostpath-{$token}";
|
||||
$srcDir = escapeshellarg($sourceCloneDir);
|
||||
$tgtDir = escapeshellarg($targetCloneDir);
|
||||
$srcPath = escapeshellarg($this->sourcePath);
|
||||
$tgtPath = escapeshellarg($this->targetPath);
|
||||
$tgtParent = escapeshellarg(dirname($this->targetPath));
|
||||
$localTempDir = storage_path('app/tmp/hostpath-clones/'.$token);
|
||||
$localArchive = $localTempDir.'/'.$archiveName;
|
||||
|
||||
try {
|
||||
File::ensureDirectoryExists($localTempDir, 0755);
|
||||
|
||||
instant_remote_process([
|
||||
"mkdir -p {$srcDir}",
|
||||
"chmod 777 {$srcDir}",
|
||||
"test -e {$srcPath}",
|
||||
"docker run --rm -v {$srcPath}:/source:ro -v {$srcDir}:/clone alpine sh -c 'cd /source && tar czf /clone/{$archiveName} .'",
|
||||
], $this->sourceServer);
|
||||
|
||||
instant_remote_process([
|
||||
"mkdir -p {$tgtDir}",
|
||||
"chmod 777 {$tgtDir}",
|
||||
], $this->targetServer);
|
||||
|
||||
instant_scp_from_server(
|
||||
"{$sourceCloneDir}/{$archiveName}",
|
||||
$localArchive,
|
||||
$this->sourceServer
|
||||
);
|
||||
|
||||
instant_scp(
|
||||
$localArchive,
|
||||
"{$targetCloneDir}/{$archiveName}",
|
||||
$this->targetServer
|
||||
);
|
||||
|
||||
instant_remote_process([
|
||||
"mkdir -p {$tgtParent}",
|
||||
"mkdir -p {$tgtPath}",
|
||||
"docker run --rm -v {$tgtPath}:/target -v {$tgtDir}:/clone alpine sh -c 'cd /target && tar xzf /clone/{$archiveName}'",
|
||||
], $this->targetServer);
|
||||
} catch (\Exception $e) {
|
||||
\Log::error("Failed to clone host path {$this->sourcePath} to {$this->targetPath}: ".$e->getMessage());
|
||||
throw $e;
|
||||
} finally {
|
||||
try {
|
||||
File::deleteDirectory($localTempDir);
|
||||
} catch (\Exception $e) {
|
||||
\Log::warning('Failed to clean up local host-path clone directory: '.$e->getMessage());
|
||||
}
|
||||
|
||||
try {
|
||||
instant_remote_process(["rm -rf {$srcDir}"], $this->sourceServer, false);
|
||||
} catch (\Exception $e) {
|
||||
\Log::warning('Failed to clean up source host-path clone directory: '.$e->getMessage());
|
||||
}
|
||||
|
||||
try {
|
||||
instant_remote_process(["rm -rf {$tgtDir}"], $this->targetServer, false);
|
||||
} catch (\Exception $e) {
|
||||
\Log::warning('Failed to clean up target host-path clone directory: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class VolumeCloneJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
@@ -17,6 +19,8 @@ class VolumeCloneJob implements ShouldBeEncrypted, ShouldQueue
|
||||
|
||||
protected string $cloneDir = '/data/coolify/clone';
|
||||
|
||||
public int $timeout = 3600;
|
||||
|
||||
public function __construct(
|
||||
protected string $sourceVolume,
|
||||
protected string $targetVolume,
|
||||
@@ -27,7 +31,7 @@ class VolumeCloneJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$this->onQueue('high');
|
||||
}
|
||||
|
||||
public function handle()
|
||||
public function handle(): void
|
||||
{
|
||||
try {
|
||||
if (! $this->targetServer || $this->targetServer->id === $this->sourceServer->id) {
|
||||
@@ -41,18 +45,23 @@ class VolumeCloneJob implements ShouldBeEncrypted, ShouldQueue
|
||||
}
|
||||
}
|
||||
|
||||
protected function cloneLocalVolume()
|
||||
protected function cloneLocalVolume(): void
|
||||
{
|
||||
$srcVol = escapeshellarg($this->sourceVolume);
|
||||
$tgtVol = escapeshellarg($this->targetVolume);
|
||||
|
||||
// Same-name local copy is a no-op (used when only the network destination changes).
|
||||
if ($this->sourceVolume === $this->targetVolume) {
|
||||
return;
|
||||
}
|
||||
|
||||
instant_remote_process([
|
||||
"docker volume create {$tgtVol}",
|
||||
"docker run --rm -v {$srcVol}:/source -v {$tgtVol}:/target alpine sh -c 'cp -a /source/. /target/ && chown -R 1000:1000 /target'",
|
||||
], $this->sourceServer);
|
||||
}
|
||||
|
||||
protected function cloneRemoteVolume()
|
||||
protected function cloneRemoteVolume(): void
|
||||
{
|
||||
$srcVol = escapeshellarg($this->sourceVolume);
|
||||
$tgtVol = escapeshellarg($this->targetVolume);
|
||||
@@ -60,8 +69,12 @@ class VolumeCloneJob implements ShouldBeEncrypted, ShouldQueue
|
||||
$targetCloneDir = "{$this->cloneDir}/{$this->targetVolume}";
|
||||
$srcDir = escapeshellarg($sourceCloneDir);
|
||||
$tgtDir = escapeshellarg($targetCloneDir);
|
||||
$localTempDir = storage_path('app/tmp/volume-clones/'.Str::uuid()->toString());
|
||||
$localArchive = $localTempDir.'/volume-data.tar.gz';
|
||||
|
||||
try {
|
||||
File::ensureDirectoryExists($localTempDir, 0755);
|
||||
|
||||
instant_remote_process([
|
||||
"mkdir -p {$srcDir}",
|
||||
"chmod 777 {$srcDir}",
|
||||
@@ -73,10 +86,16 @@ class VolumeCloneJob implements ShouldBeEncrypted, ShouldQueue
|
||||
"chmod 777 {$tgtDir}",
|
||||
], $this->targetServer);
|
||||
|
||||
// Coolify host is the intermediary: download from source, upload to target.
|
||||
instant_scp_from_server(
|
||||
"{$sourceCloneDir}/volume-data.tar.gz",
|
||||
$localArchive,
|
||||
$this->sourceServer
|
||||
);
|
||||
|
||||
instant_scp(
|
||||
"$sourceCloneDir/volume-data.tar.gz",
|
||||
"$targetCloneDir/volume-data.tar.gz",
|
||||
$this->sourceServer,
|
||||
$localArchive,
|
||||
"{$targetCloneDir}/volume-data.tar.gz",
|
||||
$this->targetServer
|
||||
);
|
||||
|
||||
@@ -84,11 +103,16 @@ class VolumeCloneJob implements ShouldBeEncrypted, ShouldQueue
|
||||
"docker volume create {$tgtVol}",
|
||||
"docker run --rm -v {$tgtVol}:/target -v {$tgtDir}:/clone alpine sh -c 'cd /target && tar xzf /clone/volume-data.tar.gz && chown -R 1000:1000 /target'",
|
||||
], $this->targetServer);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
\Log::error("Failed to clone volume {$this->sourceVolume} to {$this->targetVolume}: ".$e->getMessage());
|
||||
throw $e;
|
||||
} finally {
|
||||
try {
|
||||
File::deleteDirectory($localTempDir);
|
||||
} catch (\Exception $e) {
|
||||
\Log::warning('Failed to clean up local volume clone directory: '.$e->getMessage());
|
||||
}
|
||||
|
||||
try {
|
||||
instant_remote_process([
|
||||
"rm -rf {$srcDir}",
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Actions\Database\StartDatabase;
|
||||
use App\Actions\Database\StopDatabase;
|
||||
use App\Actions\Service\StartService;
|
||||
use App\Actions\Service\StopService;
|
||||
use App\Actions\Shared\MigrateResourceToDestination;
|
||||
use App\Jobs\VolumeCloneJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\Environment;
|
||||
@@ -19,6 +20,7 @@ use App\Models\StandaloneMysql;
|
||||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Component;
|
||||
|
||||
class ResourceOperations extends Component
|
||||
@@ -39,6 +41,8 @@ class ResourceOperations extends Component
|
||||
|
||||
public bool $cloneVolumeData = false;
|
||||
|
||||
public bool $migrateVolumeData = true;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$parameters = get_route_parameters();
|
||||
@@ -55,6 +59,11 @@ class ResourceOperations extends Component
|
||||
$this->cloneVolumeData = $value;
|
||||
}
|
||||
|
||||
public function toggleVolumeMigration(bool $value): void
|
||||
{
|
||||
$this->migrateVolumeData = $value;
|
||||
}
|
||||
|
||||
public function cloneTo($destination_uuid, $environment_id = null)
|
||||
{
|
||||
try {
|
||||
@@ -417,6 +426,64 @@ class ResourceOperations extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function migrateTo(string $destination_uuid)
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
$new_destination = find_resource_destination_for_current_team($destination_uuid);
|
||||
if (! $new_destination) {
|
||||
return $this->addError('destination_id', 'Destination not found.');
|
||||
}
|
||||
|
||||
$result = MigrateResourceToDestination::run(
|
||||
$this->resource,
|
||||
$new_destination,
|
||||
$this->migrateVolumeData,
|
||||
);
|
||||
|
||||
$this->dispatch('success', $result['message']);
|
||||
|
||||
$this->resource->loadMissing('environment.project');
|
||||
$projectUuid = $this->projectUuid ?? $this->resource->environment?->project?->uuid;
|
||||
$environmentUuid = $this->environmentUuid ?? $this->resource->environment?->uuid;
|
||||
|
||||
if (! $projectUuid || ! $environmentUuid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->resource->type() === 'application') {
|
||||
$route = route('project.application.configuration', [
|
||||
'project_uuid' => $projectUuid,
|
||||
'environment_uuid' => $environmentUuid,
|
||||
'application_uuid' => $this->resource->uuid,
|
||||
]).'#resource-operations';
|
||||
} elseif (str($this->resource->type())->startsWith('standalone-')) {
|
||||
$route = route('project.database.configuration', [
|
||||
'project_uuid' => $projectUuid,
|
||||
'environment_uuid' => $environmentUuid,
|
||||
'database_uuid' => $this->resource->uuid,
|
||||
]).'#resource-operations';
|
||||
} elseif ($this->resource->type() === 'service') {
|
||||
$route = route('project.service.configuration', [
|
||||
'project_uuid' => $projectUuid,
|
||||
'environment_uuid' => $environmentUuid,
|
||||
'service_uuid' => $this->resource->uuid,
|
||||
]).'#resource-operations';
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
return redirect()->to($route);
|
||||
} catch (ValidationException $e) {
|
||||
$message = collect($e->errors())->flatten()->first() ?? $e->getMessage();
|
||||
|
||||
return $this->addError('destination_id', $message);
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.project.shared.resource-operations');
|
||||
|
||||
Reference in new issue
Block a user