diff --git a/app/Actions/Development/SeedDevelopmentQemuServer.php b/app/Actions/Development/SeedDevelopmentQemuServer.php index ad385f878e..288e1ab8c2 100644 --- a/app/Actions/Development/SeedDevelopmentQemuServer.php +++ b/app/Actions/Development/SeedDevelopmentQemuServer.php @@ -2,6 +2,8 @@ namespace App\Actions\Development; +use App\Enums\ProxyStatus; +use App\Enums\ProxyTypes; use App\Models\PrivateKey; use App\Models\Server; use InvalidArgumentException; @@ -50,6 +52,10 @@ class SeedDevelopmentQemuServer 'team_id' => 0, 'private_key_id' => $privateKey->id, ]); + if (! $server->proxyType()) { + $server->proxy->set('type', ProxyTypes::CADDY->value); + $server->proxy->set('status', ProxyStatus::EXITED->value); + } $server->deleted_at = null; $server->save(); diff --git a/app/Actions/Shared/MigrateResourceToDestination.php b/app/Actions/Shared/MigrateResourceToDestination.php index b0979e1677..3650b68624 100644 --- a/app/Actions/Shared/MigrateResourceToDestination.php +++ b/app/Actions/Shared/MigrateResourceToDestination.php @@ -86,6 +86,12 @@ class MigrateResourceToDestination ]); } + if ($this->hasDomains($resource) && $targetServer->isProxyShouldRun() && $targetServer->proxy->get('status') !== 'running') { + throw ValidationException::withMessages([ + 'destination_id' => 'The proxy of the target server is not running. Start it on the server page before you migrate a resource with domains.', + ]); + } + $crossServer = $sourceServer && (int) $sourceServer->id !== (int) $targetServer->id; if (! $crossServer) { @@ -169,6 +175,20 @@ class MigrateResourceToDestination $this->resaveFileStorages($resource->fresh()); } + protected function hasDomains( + Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|StandaloneSqlite $resource, + ): bool { + if ($resource instanceof Application) { + return $resource->maintenanceDomains()->isNotEmpty(); + } + + if ($resource instanceof Service) { + return $resource->applications()->get()->contains(fn ($application) => filled($application->fqdn)); + } + + return false; + } + protected function stopResource( Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse|StandaloneSqlite $resource, ): void { diff --git a/app/Livewire/Project/Shared/ResourceOperations.php b/app/Livewire/Project/Shared/ResourceOperations.php index a459b988e0..c4f408e489 100644 --- a/app/Livewire/Project/Shared/ResourceOperations.php +++ b/app/Livewire/Project/Shared/ResourceOperations.php @@ -458,7 +458,7 @@ class ResourceOperations extends Component $new_destination = $this->findResourceTeamDestination($destination_uuid); if (! $new_destination) { - return $this->addError('destination_id', 'Destination not found.'); + return $this->dispatch('error', 'Destination not found.'); } $result = MigrateResourceToDestination::run( @@ -503,7 +503,7 @@ class ResourceOperations extends Component } catch (ValidationException $e) { $message = collect($e->errors())->flatten()->first() ?? $e->getMessage(); - return $this->addError('destination_id', $message); + return $this->dispatch('error', $message); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/tests/Feature/Authorization/ResourceOperationsCrossTenantTest.php b/tests/Feature/Authorization/ResourceOperationsCrossTenantTest.php index 8bb086a972..68139819bb 100644 --- a/tests/Feature/Authorization/ResourceOperationsCrossTenantTest.php +++ b/tests/Feature/Authorization/ResourceOperationsCrossTenantTest.php @@ -171,7 +171,9 @@ describe('after the session team changes', function () { test('migrateTo rejects a destination of the session team', function () { resourceOperationsAfterTeamSwitch($this, $this->applicationA) ->call('migrateTo', $this->destinationB->uuid) - ->assertHasErrors(['destination_id' => 'Destination not found.']); + ->assertDispatched('error', 'Destination not found.'); + + expect($this->applicationA->fresh()->destination_id)->toBe($this->destinationA->id); }); test('cloneTo and moveTo still work inside the resource team', function () { diff --git a/tests/Feature/DevelopmentQemuVmTest.php b/tests/Feature/DevelopmentQemuVmTest.php index 3512a20fd2..180c861b12 100644 --- a/tests/Feature/DevelopmentQemuVmTest.php +++ b/tests/Feature/DevelopmentQemuVmTest.php @@ -145,6 +145,21 @@ it('seeds one predefined root qemu server', function () { ->and(Server::query()->where('uuid', 'like', 'development-qemu-%')->count())->toBe(1); }); +it('seeds a new qemu server with the caddy proxy', function () { + $server = SeedDevelopmentQemuServer::run('debian-root'); + + expect($server->proxyType())->toBe('CADDY') + ->and($server->proxy->get('status'))->toBe('exited'); +}); + +it('keeps the proxy of an existing server when it seeds a qemu vm for it', function () { + $this->seed(ServerSeeder::class); + + $server = SeedDevelopmentQemuServer::run('ubuntu-root', true, true); + + expect($server->proxyType())->toBe('TRAEFIK'); +}); + it('uses a qemu vm as the localhost sentinel without changing its identity', function () { $this->seed(ServerSeeder::class); diff --git a/tests/Feature/MigrateResourceToDestinationTest.php b/tests/Feature/MigrateResourceToDestinationTest.php index 964a3ed565..dd23f4e3cf 100644 --- a/tests/Feature/MigrateResourceToDestinationTest.php +++ b/tests/Feature/MigrateResourceToDestinationTest.php @@ -240,7 +240,7 @@ test('livewire migrateTo rejects cross-team destination', function () { Livewire::test(ResourceOperations::class, ['resource' => $application]) ->set('migrateVolumeData', false) ->call('migrateTo', $otherDestination->uuid) - ->assertHasErrors('destination_id'); + ->assertDispatched('error', 'Destination not found.'); $application->refresh(); expect($application->destination_id)->toBe($this->destination->id); @@ -322,6 +322,73 @@ test('rejects migration to a server that is not validated and reachable', functi 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();