'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, 'server_role' => ServerRole::BUILD, ]); $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) ->assertDispatched('error', 'Destination not found.'); $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('rejects migrating an application with a domain when the target proxy is not running', function () { StopApplication::shouldNotRun(); $this->targetServer->proxy->set('type', 'TRAEFIK'); $this->targetServer->proxy->set('status', 'exited'); $this->targetServer->save(); $application = createMigrateTestApplication($this); $application->update(['fqdn' => 'http://app.example.com']); MigrateResourceToDestination::run($application, $this->targetDestination, migrateVolumes: false); })->throws(ValidationException::class, 'The proxy of the target server is not running.'); test('rejects migrating a service with a domain when the target proxy is not running', function () { StopService::shouldNotRun(); $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"), ]); $service->applications()->create(['name' => 'app', 'image' => 'nginx', 'fqdn' => 'http://svc.example.com']); MigrateResourceToDestination::run($service, $this->targetDestination, migrateVolumes: false); })->throws(ValidationException::class, 'The proxy of the target server is not running.'); test('livewire migrateTo shows why the migration was rejected', function () { $this->actingAs($this->user); session(['currentTeam' => $this->team]); StopApplication::shouldNotRun(); $this->targetServer->proxy->set('type', 'TRAEFIK'); $this->targetServer->proxy->set('status', 'exited'); $this->targetServer->save(); $application = createMigrateTestApplication($this); $application->update(['fqdn' => 'http://app.example.com']); Livewire::test(ResourceOperations::class, ['resource' => $application]) ->call('migrateTo', $this->targetDestination->uuid) ->assertDispatched('error', 'The proxy of the target server is not running. Start it on the server page before you migrate a resource with domains.') ->assertNoRedirect(); }); test('migrates an application with a domain when the target proxy does not block it', function (string $proxyType, string $proxyStatus) { StopApplication::shouldRun()->once(); $this->targetServer->proxy->set('type', $proxyType); $this->targetServer->proxy->set('status', $proxyStatus); $this->targetServer->save(); $application = createMigrateTestApplication($this); $application->update(['fqdn' => 'http://app.example.com']); MigrateResourceToDestination::run($application, $this->targetDestination, migrateVolumes: false); expect($application->refresh()->destination_id)->toBe($this->targetDestination->id); })->with([ 'proxy running' => ['TRAEFIK', 'running'], 'no managed proxy' => ['NONE', 'exited'], ]); 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); });