diff --git a/app/Actions/Shared/MigrateResourceToDestination.php b/app/Actions/Shared/MigrateResourceToDestination.php new file mode 100644 index 000000000..7e3df4bff --- /dev/null +++ b/app/Actions/Shared/MigrateResourceToDestination.php @@ -0,0 +1,290 @@ + '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 + */ + 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 + */ + 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); + } + } +} diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php index 907cb4456..1a9b688f4 100644 --- a/app/Helpers/SshMultiplexingHelper.php +++ b/app/Helpers/SshMultiplexingHelper.php @@ -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) { diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index bc058b927..3402220f9 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -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(); diff --git a/app/Http/Controllers/Api/DatabasesController.php b/app/Http/Controllers/Api/DatabasesController.php index d9953fa9c..7b62b4980 100644 --- a/app/Http/Controllers/Api/DatabasesController.php +++ b/app/Http/Controllers/Api/DatabasesController.php @@ -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.', diff --git a/app/Http/Controllers/Api/ServicesController.php b/app/Http/Controllers/Api/ServicesController.php index 11a6294f4..d7d4953f9 100644 --- a/app/Http/Controllers/Api/ServicesController.php +++ b/app/Http/Controllers/Api/ServicesController.php @@ -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.', diff --git a/app/Jobs/FinalizeResourceMigrationJob.php b/app/Jobs/FinalizeResourceMigrationJob.php new file mode 100644 index 000000000..b517656f3 --- /dev/null +++ b/app/Jobs/FinalizeResourceMigrationJob.php @@ -0,0 +1,46 @@ +onQueue('high'); + } + + public function handle(): void + { + MigrateResourceToDestination::make()->applyDestination( + $this->resource->fresh(), + $this->destination + ); + } +} diff --git a/app/Jobs/HostPathCloneJob.php b/app/Jobs/HostPathCloneJob.php new file mode 100644 index 000000000..2b26fc551 --- /dev/null +++ b/app/Jobs/HostPathCloneJob.php @@ -0,0 +1,132 @@ +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()); + } + } + } +} diff --git a/app/Jobs/VolumeCloneJob.php b/app/Jobs/VolumeCloneJob.php index 060ec3ac6..f511d2546 100644 --- a/app/Jobs/VolumeCloneJob.php +++ b/app/Jobs/VolumeCloneJob.php @@ -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}", diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php index 7a9928a4b..dd00be25c 100644 --- a/app/Livewire/Project/Shared/ResourceOperations.php +++ b/app/Livewire/Project/Shared/ResourceOperations.php @@ -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'); diff --git a/bootstrap/helpers/api.php b/bootstrap/helpers/api.php index 60ec07aa8..657da89dd 100644 --- a/bootstrap/helpers/api.php +++ b/bootstrap/helpers/api.php @@ -1,9 +1,12 @@ 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 diff --git a/bootstrap/helpers/remoteProcess.php b/bootstrap/helpers/remoteProcess.php index fee3f376f..8d1a30a62 100644 --- a/bootstrap/helpers/remoteProcess.php +++ b/bootstrap/helpers/remoteProcess.php @@ -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; diff --git a/docs/dev-only-features.md b/docs/dev-only-features.md new file mode 100644 index 000000000..2b7a67cb4 --- /dev/null +++ b/docs/dev-only-features.md @@ -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. diff --git a/resources/views/livewire/project/shared/resource-operations.blade.php b/resources/views/livewire/project/shared/resource-operations.blade.php index a7ad60d3a..70ba5acfa 100644 --- a/resources/views/livewire/project/shared/resource-operations.blade.php +++ b/resources/views/livewire/project/shared/resource-operations.blade.php @@ -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 @@ + @if (isDev()) + + + + + + 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. + + +
+ + + +
+ +
+ +
+ +
+

+ The resource keeps the same UUID and configuration. Only the hosting server and network + destination change. +

+ + Migrate resource + +
+
+ @endif +
diff --git a/routes/api.php b/routes/api.php index 78f24404b..1a01725f5 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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']); diff --git a/tests/Feature/MigrateResourceToDestinationTest.php b/tests/Feature/MigrateResourceToDestinationTest.php new file mode 100644 index 000000000..ef6de1aec --- /dev/null +++ b/tests/Feature/MigrateResourceToDestinationTest.php @@ -0,0 +1,396 @@ + '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); +});