feat(resources): add cross-server resource migration (dev-only) (#11165)

This commit is contained in:
Andras Bacsai
2026-08-07 23:01:31 +02:00
committed by GitHub
parent d83ba627cd
commit c15e3b35fd
15 changed files with 1354 additions and 7 deletions
@@ -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);
}
}
}
+41
View File
@@ -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.',
+46
View File
@@ -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
);
}
}
+132
View File
@@ -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());
}
}
}
}
+31 -7
View File
@@ -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');
+78
View File
@@ -1,9 +1,12 @@
<?php
use App\Actions\Shared\MigrateResourceToDestination;
use App\Enums\BuildPackTypes;
use App\Enums\RedirectTypes;
use App\Enums\StaticImageTypes;
use App\Models\Environment;
use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
use App\Rules\ValidGitBranch;
use App\Support\ValidationPatterns;
use Illuminate\Database\Eloquent\Collection;
@@ -13,6 +16,7 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
function getTeamIdFromToken()
{
@@ -258,6 +262,80 @@ function moveResourceToEnvironment(Request $request, $resource, string $resource
]);
}
function migrateResourceToDestination(Request $request, $resource, string $resourceType, int $teamId): JsonResponse
{
if (! isDev()) {
abort(404);
}
$validator = Validator::make($request->all(), [
'destination_uuid' => 'required|string',
'migrate_volumes' => 'boolean',
]);
if ($validator->fails()) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $validator->errors(),
], 422);
}
$allowedFields = ['destination_uuid', 'migrate_volumes'];
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if (! empty($extraFields)) {
return response()->json([
'message' => 'Validation failed.',
'errors' => collect($extraFields)->mapWithKeys(fn ($field) => [$field => 'This field is not allowed.'])->toArray(),
], 422);
}
Gate::authorize('update', $resource);
$destination = StandaloneDocker::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->destination_uuid)->first()
?? SwarmDocker::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->destination_uuid)->first();
if (! $destination || ! $destination->server?->canHostResources()) {
return response()->json(['message' => 'Destination not found.'], 404);
}
$sourceDestination = $resource->destination;
$migrateVolumes = $request->boolean('migrate_volumes', true);
try {
$result = MigrateResourceToDestination::run(
$resource,
$destination,
$migrateVolumes,
);
} catch (ValidationException $e) {
return response()->json([
'message' => collect($e->errors())->flatten()->first() ?? $e->getMessage(),
'errors' => $e->errors(),
], 422);
}
auditLog('api.'.str($resourceType)->lower()->value().'.migrated', [
'team_id' => $teamId,
'resource_uuid' => $resource->uuid,
'resource_type' => str($resourceType)->lower()->value(),
'from_destination_uuid' => $sourceDestination?->uuid,
'to_destination_uuid' => $destination->uuid,
'from_server_id' => $sourceDestination?->server_id,
'to_server_id' => $destination->server_id,
'migrate_volumes' => $migrateVolumes,
'async' => $result['async'],
'volume_jobs' => $result['volume_jobs'],
]);
return response()->json([
'message' => $result['message'],
'uuid' => $resource->uuid,
'destination_uuid' => $destination->uuid,
'async' => $result['async'],
'volume_jobs' => $result['volume_jobs'],
]);
}
function validateIncomingRequest(Request $request)
{
// check if request is json
+29
View File
@@ -102,6 +102,35 @@ function instant_scp(string $source, string $dest, Server $server, $throwError =
);
}
/**
* Download a remote file from a managed server onto the Coolify host via SCP.
*/
function instant_scp_from_server(string $remoteSource, string $localDest, Server $server, $throwError = true)
{
return SshRetryHandler::retry(
function () use ($remoteSource, $localDest, $server) {
$scp_command = SshMultiplexingHelper::generateScpDownloadCommand($server, $remoteSource, $localDest);
$process = Process::timeout(config('constants.ssh.command_timeout'))->run($scp_command);
$output = trim($process->output());
$exitCode = $process->exitCode();
if ($exitCode !== 0) {
excludeCertainErrors($process->errorOutput(), $exitCode);
}
return $output === 'null' ? null : $output;
},
[
'server' => $server->ip,
'source' => $remoteSource,
'dest' => $localDest,
'function' => 'instant_scp_from_server',
],
$throwError
);
}
function instant_remote_process_with_timeout(Collection|array $command, Server $server, bool $throwError = true, bool $no_sudo = false): ?string
{
$command = $command instanceof Collection ? $command->toArray() : $command;
+23
View File
@@ -0,0 +1,23 @@
# Development-only features
Development-only features must only be available when `APP_ENV=local`. Use the
`isDev()` helper consistently at every entry point so that hiding the UI is not
the only protection.
## Resource migration between servers
Resource migration is under development and is not available in production.
- **UI:** The **Migrate to another server** section in
`resources/views/livewire/project/shared/resource-operations.blade.php` is
rendered only when `isDev()` returns `true` and displays a **Dev** badge.
- **API:** Application, database, and service migration requests are rejected
with `404 Not Found` outside development mode by
`migrateResourceToDestination()` in `bootstrap/helpers/api.php`.
- **Action:** `MigrateResourceToDestination` rejects execution outside
development mode as a defense-in-depth check.
- **Tests:** Development-mode UI, API access, and production isolation are
covered by `tests/Feature/MigrateResourceToDestinationTest.php`.
Before promoting this feature, remove all three runtime gates together and
update the tests and this document in the same change.
@@ -3,6 +3,8 @@
selectedCloneDestination: null,
selectedCloneProject: null,
selectedCloneEnvironment: null,
selectedMigrateServer: null,
selectedMigrateDestination: null,
selectedMoveProject: null,
selectedMoveEnvironment: null,
currentProjectId: {{ $resource->environment->project->id }},
@@ -15,6 +17,7 @@
'id' => $server->id,
'name' => $server->name,
'ip' => $server->ip,
'is_functional' => $server->isFunctional(),
'destinations' => $server->destinations()->map(
fn ($destination) => [
'id' => $destination->id,
@@ -46,6 +49,15 @@
const server = this.servers.find(server => server.id == this.selectedCloneServer);
return server ? server.destinations : [];
},
get availableMigrateDestinations() {
if (this.selectedMigrateServer === null || this.selectedMigrateServer === '') return [];
const server = this.servers.find(server => server.id == this.selectedMigrateServer);
if (!server) return [];
return server.destinations.filter(destination => destination.uuid !== this.currentDestinationUuid);
},
get isCrossServerMigration() {
return this.selectedMigrateServer && this.selectedMigrateServer != this.currentServerId;
},
get availableCloneEnvironments() {
if (this.selectedCloneProject === null || this.selectedCloneProject === '') return [];
const project = this.projects.find(project => project.id == this.selectedCloneProject);
@@ -88,6 +100,20 @@
label: destination.name + (destination.uuid == this.currentDestinationUuid ? ' (current)' : ''),
}));
},
get migrateServerOptions() {
return this.servers
.filter(server => server.is_functional && server.id != this.currentServerId)
.map(server => ({
value: server.id,
label: `${server.name} (${server.ip})`,
}));
},
get migrateDestinationOptions() {
return this.availableMigrateDestinations.map(destination => ({
value: destination.uuid,
label: destination.name,
}));
},
get cloneProjectOptions() {
return this.projects.map(project => ({
value: project.id,
@@ -117,8 +143,11 @@
selectedCloneDestination = null;
selectedCloneProject = null;
selectedCloneEnvironment = null;
selectedMigrateServer = null;
selectedMigrateDestination = null;
$watch('selectedCloneServer', () => selectedCloneDestination = null);
$watch('selectedCloneProject', () => selectedCloneEnvironment = null);
$watch('selectedMigrateServer', () => selectedMigrateDestination = null);
$watch('selectedMoveProject', () => selectedMoveEnvironment = null);
" class="flex flex-col gap-6">
@can('update', $resource)
@@ -152,6 +181,51 @@
</div>
</x-application.settings-section>
@if (isDev())
<x-application.settings-section id="migrate-destination-section" title="Migrate to another server"
helper="Move this resource to a different validated and reachable server. The resource is stopped and persistent volumes can be transferred.">
<x-slot:actions>
<x-status-badge status="Dev" type="warning" />
</x-slot:actions>
<x-callout type="warning" title="Downtime">
Migration stops the resource on the source server. After volume transfer finishes, redeploy on
the target server. Source volumes are left in place for safety clean them up manually when you
confirm the migration succeeded. Only other servers that are validated and reachable are listed.
</x-callout>
<div class="mt-4 grid gap-4 md:grid-cols-2">
<x-forms.listbox id="migrate-resource-server" label="Target server" :wire="false"
x-model="selectedMigrateServer" x-effect="options = migrateServerOptions"
placeholder="Choose a server…"
emptyText="No other validated and reachable servers are available." />
<x-forms.listbox id="migrate-resource-destination" label="Network destination" :wire="false"
x-model="selectedMigrateDestination" x-effect="options = migrateDestinationOptions"
x-bind:disabled="selectedMigrateServer === null || selectedMigrateServer === ''"
placeholder="Choose a destination…"
emptyText="No network destinations are available on this server." />
</div>
<div x-show="selectedMigrateDestination" x-cloak class="mt-4">
<x-forms.checkbox id="migrateVolumeData" live label="Migrate persistent volume data"
helper="Named Docker volumes and bind-mount host paths are transferred when both servers are managed by Coolify." />
</div>
<div x-show="selectedMigrateDestination" x-cloak
class="mt-4 flex flex-col gap-3 border-t border-neutral-200 pt-4 sm:flex-row sm:items-center sm:justify-between dark:border-white/[0.07]">
<p class="text-[13px] text-neutral-500 dark:text-fg-dim">
The resource keeps the same UUID and configuration. Only the hosting server and network
destination change.
</p>
<x-forms.button
wire:confirm="Migrate this resource? It will be stopped on the source server. Redeploy after migration completes."
@click="$wire.migrateTo(selectedMigrateDestination)">
Migrate resource
</x-forms.button>
</div>
</x-application.settings-section>
@endif
<x-application.settings-section id="clone-environment-section" title="Clone to another environment"
helper="Create the clone in another project environment while keeping the current server and network.">
<div class="grid gap-4 md:grid-cols-2">
+3
View File
@@ -255,6 +255,7 @@ Route::group([
Route::delete('/applications/{uuid}/tags/{tag_uuid}', [ApplicationsController::class, 'delete_tag'])->middleware(['api.ability:write']);
Route::post('/applications/{uuid}/move', [ApplicationsController::class, 'move_by_uuid'])->middleware(['api.ability:write']);
Route::post('/applications/{uuid}/migrate', [ApplicationsController::class, 'migrate_by_uuid'])->middleware(['api.ability:write']);
Route::post('/applications/{uuid}/clone', [ApplicationsController::class, 'clone_by_uuid'])->middleware(['api.ability:write']);
Route::get('/applications/{uuid}/rollback-images', [ApplicationsController::class, 'rollback_images'])->middleware(['api.ability:read']);
Route::post('/applications/{uuid}/rollback', [ApplicationsController::class, 'rollback_by_uuid'])->middleware(['api.ability:deploy']);
@@ -328,6 +329,7 @@ Route::group([
Route::delete('/databases/{uuid}/tags/{tag_uuid}', [DatabasesController::class, 'delete_tag'])->middleware(['api.ability:write']);
Route::post('/databases/{uuid}/move', [DatabasesController::class, 'move_by_uuid'])->middleware(['api.ability:write']);
Route::post('/databases/{uuid}/migrate', [DatabasesController::class, 'migrate_by_uuid'])->middleware(['api.ability:write']);
Route::post('/databases/{uuid}/clone', [DatabasesController::class, 'clone_by_uuid'])->middleware(['api.ability:write']);
Route::get('/databases/{uuid}/start', [OtherController::class, 'post_required'])->middleware(['api.ability:deploy']);
Route::get('/databases/{uuid}/restart', [OtherController::class, 'post_required'])->middleware(['api.ability:deploy']);
@@ -369,6 +371,7 @@ Route::group([
Route::delete('/services/{uuid}/tags/{tag_uuid}', [ServicesController::class, 'delete_tag'])->middleware(['api.ability:write']);
Route::post('/services/{uuid}/move', [ServicesController::class, 'move_by_uuid'])->middleware(['api.ability:write']);
Route::post('/services/{uuid}/migrate', [ServicesController::class, 'migrate_by_uuid'])->middleware(['api.ability:write']);
Route::post('/services/{uuid}/clone', [ServicesController::class, 'clone_by_uuid'])->middleware(['api.ability:write']);
Route::get('/services/{uuid}/start', [OtherController::class, 'post_required'])->middleware(['api.ability:deploy']);
Route::get('/services/{uuid}/restart', [OtherController::class, 'post_required'])->middleware(['api.ability:deploy']);
@@ -0,0 +1,396 @@
<?php
use App\Actions\Application\StopApplication;
use App\Actions\Database\StopDatabase;
use App\Actions\Service\StopService;
use App\Actions\Shared\MigrateResourceToDestination;
use App\Jobs\FinalizeResourceMigrationJob;
use App\Jobs\HostPathCloneJob;
use App\Jobs\VolumeCloneJob;
use App\Livewire\Project\Shared\ResourceOperations;
use App\Models\Application;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\LocalPersistentVolume;
use App\Models\Project;
use App\Models\Server;
use App\Models\ServerSetting;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
config(['app.env' => 'local']);
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(
['id' => 0],
['is_api_enabled' => true],
));
$this->user = User::factory()->create();
$this->team = Team::factory()->create();
$this->user->teams()->attach($this->team, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$plainTextToken = Str::random(40);
$token = $this->user->tokens()->create([
'name' => 'test-token',
'token' => hash('sha256', $plainTextToken),
'abilities' => ['*'],
'team_id' => $this->team->id,
]);
$this->bearerToken = $token->getKey().'|'.$plainTextToken;
$this->server = Server::factory()->create(['team_id' => $this->team->id]);
$this->server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
]);
$this->destination = StandaloneDocker::where('server_id', $this->server->id)->firstOrFail();
$this->targetServer = Server::factory()->create(['team_id' => $this->team->id, 'name' => 'Target Server']);
$this->targetServer->settings()->update([
'is_reachable' => true,
'is_usable' => true,
]);
$this->targetDestination = StandaloneDocker::where('server_id', $this->targetServer->id)->firstOrFail();
$this->project = Project::factory()->create(['team_id' => $this->team->id]);
$this->environment = Environment::factory()->create(['project_id' => $this->project->id]);
});
function createMigrateTestApplication($context): Application
{
return Application::factory()->create([
'environment_id' => $context->environment->id,
'destination_id' => $context->destination->id,
'destination_type' => $context->destination->getMorphClass(),
'status' => 'running:unknown',
]);
}
test('migrates application destination in the database without volumes', function () {
StopApplication::shouldRun()->once();
$application = createMigrateTestApplication($this);
$result = MigrateResourceToDestination::run($application, $this->targetDestination, migrateVolumes: false);
$application->refresh();
expect($result['async'])->toBeFalse()
->and($application->destination_id)->toBe($this->targetDestination->id)
->and($application->destination_type)->toBe($this->targetDestination->getMorphClass())
->and($application->status)->toStartWith('exited');
});
test('rejects migration to the same destination', function () {
StopApplication::shouldNotRun();
$application = createMigrateTestApplication($this);
MigrateResourceToDestination::run($application, $this->destination, migrateVolumes: false);
})->throws(ValidationException::class);
test('rejects migration to a build server destination', function () {
StopApplication::shouldNotRun();
$buildServer = Server::factory()->create(['team_id' => $this->team->id]);
$buildServer->settings()->update([
'is_reachable' => true,
'is_usable' => true,
'is_build_server' => true,
]);
$buildDestination = StandaloneDocker::where('server_id', $buildServer->id)->firstOrFail();
$application = createMigrateTestApplication($this);
MigrateResourceToDestination::run($application, $buildDestination, migrateVolumes: false);
})->throws(ValidationException::class);
test('chains volume clone and finalize jobs when migrating volumes across servers', function () {
Bus::fake();
StopApplication::shouldRun()->once();
$application = createMigrateTestApplication($this);
LocalPersistentVolume::create([
'name' => $application->uuid.'-data',
'mount_path' => '/data',
'resource_id' => $application->id,
'resource_type' => $application->getMorphClass(),
]);
LocalPersistentVolume::create([
'name' => $application->uuid.'-bind',
'mount_path' => '/bind',
'host_path' => '/var/lib/coolify-test-bind',
'resource_id' => $application->id,
'resource_type' => $application->getMorphClass(),
]);
$result = MigrateResourceToDestination::run($application, $this->targetDestination, migrateVolumes: true);
expect($result['async'])->toBeTrue()
->and($result['volume_jobs'])->toBe(2);
// Destination must not flip until volume transfer finishes.
$application->refresh();
expect($application->destination_id)->toBe($this->destination->id);
Bus::assertChained([
VolumeCloneJob::class,
HostPathCloneJob::class,
FinalizeResourceMigrationJob::class,
]);
});
test('finalize job updates destination after volume transfer', function () {
$application = createMigrateTestApplication($this);
(new FinalizeResourceMigrationJob($application, $this->targetDestination))->handle();
$application->refresh();
expect($application->destination_id)->toBe($this->targetDestination->id)
->and($application->destination_type)->toBe($this->targetDestination->getMorphClass())
->and($application->status)->toStartWith('exited');
});
test('migrates standalone database destination and server linkage', function () {
StopDatabase::shouldRun()->once();
$database = StandalonePostgresql::create([
'name' => 'pg-migrate',
'uuid' => new_public_id(),
'postgres_password' => 'secret',
'postgres_user' => 'postgres',
'postgres_db' => 'postgres',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'status' => 'running:unknown',
]);
MigrateResourceToDestination::run($database, $this->targetDestination, migrateVolumes: false);
$database->refresh();
expect($database->destination_id)->toBe($this->targetDestination->id)
->and($database->status)->toStartWith('exited');
});
test('migrates service destination and server_id', function () {
StopService::shouldRun()->once();
$service = Service::create([
'name' => 'svc-migrate',
'uuid' => new_public_id(),
'environment_id' => $this->environment->id,
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
'docker_compose_raw' => base64_encode("services:\n app:\n image: nginx\n"),
'docker_compose' => base64_encode("services:\n app:\n image: nginx\n"),
]);
MigrateResourceToDestination::run($service, $this->targetDestination, migrateVolumes: false);
$service->refresh();
expect($service->destination_id)->toBe($this->targetDestination->id)
->and($service->server_id)->toBe($this->targetServer->id);
});
test('livewire migrateTo updates destination on same team target', function () {
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
StopApplication::shouldRun()->once();
$application = createMigrateTestApplication($this);
Livewire::test(ResourceOperations::class, ['resource' => $application])
->set('migrateVolumeData', false)
->call('migrateTo', $this->targetDestination->uuid)
->assertHasNoErrors('destination_id')
->assertRedirect();
$application->refresh();
expect($application->destination_id)->toBe($this->targetDestination->id);
});
test('livewire migrateTo rejects cross-team destination', function () {
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
StopApplication::shouldNotRun();
$otherTeam = Team::factory()->create();
$otherServer = Server::factory()->create(['team_id' => $otherTeam->id]);
$otherDestination = StandaloneDocker::where('server_id', $otherServer->id)->firstOrFail();
$application = createMigrateTestApplication($this);
Livewire::test(ResourceOperations::class, ['resource' => $application])
->set('migrateVolumeData', false)
->call('migrateTo', $otherDestination->uuid)
->assertHasErrors('destination_id');
$application->refresh();
expect($application->destination_id)->toBe($this->destination->id);
});
test('api migrates application to another destination', function () {
StopApplication::shouldRun()->once();
$application = createMigrateTestApplication($this);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/applications/{$application->uuid}/migrate", [
'destination_uuid' => $this->targetDestination->uuid,
'migrate_volumes' => false,
]);
$response->assertSuccessful()
->assertJsonPath('uuid', $application->uuid)
->assertJsonPath('destination_uuid', $this->targetDestination->uuid)
->assertJsonPath('async', false);
$application->refresh();
expect($application->destination_id)->toBe($this->targetDestination->id);
});
test('api migrates database to another destination', function () {
StopDatabase::shouldRun()->once();
$database = StandalonePostgresql::create([
'name' => 'pg-api-migrate',
'uuid' => new_public_id(),
'postgres_password' => 'secret',
'postgres_user' => 'postgres',
'postgres_db' => 'postgres',
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
$response = $this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/databases/{$database->uuid}/migrate", [
'destination_uuid' => $this->targetDestination->uuid,
'migrate_volumes' => false,
]);
$response->assertSuccessful();
$database->refresh();
expect($database->destination_id)->toBe($this->targetDestination->id);
});
test('rejects migration to another destination on the same server', function () {
StopApplication::shouldNotRun();
$secondDestination = StandaloneDocker::factory()->create([
'server_id' => $this->server->id,
'network' => 'second-network',
'name' => 'Second network',
]);
$application = createMigrateTestApplication($this);
MigrateResourceToDestination::run($application, $secondDestination, migrateVolumes: false);
})->throws(ValidationException::class);
test('rejects migration to a server that is not validated and reachable', function () {
StopApplication::shouldNotRun();
ServerSetting::query()->where('server_id', $this->targetServer->id)->update([
'is_reachable' => false,
'is_usable' => false,
]);
$application = createMigrateTestApplication($this);
MigrateResourceToDestination::run($application, $this->targetDestination, migrateVolumes: false);
})->throws(ValidationException::class);
test('resource operations migrate list only includes other functional servers', function () {
$unreachableServer = Server::factory()->create([
'team_id' => $this->team->id,
'name' => 'Unreachable Server',
]);
$unreachableServer->settings()->update([
'is_reachable' => false,
'is_usable' => false,
]);
$application = createMigrateTestApplication($this);
$application->load(['destination.server', 'environment.project']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$component = Livewire::test(ResourceOperations::class, ['resource' => $application]);
$servers = $component->get('servers');
$serverIds = collect($servers)->pluck('id')->all();
expect($serverIds)->toContain($this->server->id)
->and($serverIds)->toContain($this->targetServer->id)
->and($serverIds)->toContain($unreachableServer->id);
$view = file_get_contents(resource_path('views/livewire/project/shared/resource-operations.blade.php'));
expect($view)
->toContain('server.is_functional && server.id != this.currentServerId')
->toContain("'is_functional' => \$server->isFunctional()");
});
test('migration is unavailable outside development mode', function () {
config(['app.env' => 'production']);
StopApplication::shouldNotRun();
$application = createMigrateTestApplication($this);
MigrateResourceToDestination::run($application, $this->targetDestination, migrateVolumes: false);
})->throws(ValidationException::class, 'Resource migration is only available in development mode.');
test('resource operations only shows migration in development mode', function () {
$application = createMigrateTestApplication($this);
$application->load(['destination.server', 'environment.project']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
Livewire::test(ResourceOperations::class, ['resource' => $application])
->assertSee('Migrate to another server')
->assertSee('Dev');
config(['app.env' => 'production']);
Livewire::test(ResourceOperations::class, ['resource' => $application])
->assertDontSee('Migrate to another server');
});
test('migration api is unavailable outside development mode', function () {
config(['app.env' => 'production']);
StopApplication::shouldNotRun();
$application = createMigrateTestApplication($this);
$this->withHeaders([
'Authorization' => 'Bearer '.$this->bearerToken,
'Content-Type' => 'application/json',
])->postJson("/api/v1/applications/{$application->uuid}/migrate", [
'destination_uuid' => $this->targetDestination->uuid,
'migrate_volumes' => false,
])->assertNotFound();
expect($application->fresh()->destination_id)->toBe($this->destination->id);
});