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

This commit is contained in:
Andras Bacsai authored and GitHub committed 2026-08-07 23:01:31 +02:00
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);
}
}
}
+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);
});