fix(migration): block server migration when the target proxy is not running

This commit is contained in:
Andras Bacsai
2026-10-24 17:55:11 +02:00
parent e4c1e1a6b3
commit 8aed790a78
6 changed files with 114 additions and 4 deletions
@@ -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();
@@ -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 {
@@ -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);
}
@@ -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 () {
+15
View File
@@ -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);
@@ -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();