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