diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index a420de5b85..61339a1c23 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -10,6 +10,7 @@ use App\Models\Server; use App\Rules\ValidServerIp; use App\Services\DigitalOceanService; use App\Services\HetznerService; +use App\Services\ServerTransfer\ServerTransferClaimer; use App\Services\VultrService; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; @@ -352,6 +353,45 @@ class Show extends Component } } + public function toggleManagement(ServerTransferClaimer $claimer): void + { + abort_unless(isDev(), 404); + $this->authorize('update', $this->server); + + if ($this->server->isLocalhost()) { + $this->dispatch('error', 'The Coolify host cannot be transferred.'); + + return; + } + + if ($this->server->isTransferredAway() && $this->server->team->serverOverflow()) { + $this->dispatch('error', 'Your team is over its server limit. Upgrade your subscription or remove a server first.'); + + return; + } + + if ($this->server->isTransferredAway()) { + $claimer->claim($this->server, writeRemote: false, rebindSentinel: true); + $event = 'ui.server.management_enabled'; + $message = 'This Coolify instance now manages the server.'; + } else { + $claimer->markTransferred($this->server); + $event = 'ui.server.management_disabled'; + $message = 'Server automations are disabled on this Coolify instance.'; + } + + $this->server->refresh(); + $this->syncData(); + + auditLog($event, [ + 'team_id' => $this->server->team_id, + 'server_uuid' => $this->server->uuid, + 'server_name' => $this->server->name, + ]); + + $this->dispatch('success', $message); + } + public function checkLocalhostConnection() { try { diff --git a/app/Services/ServerTransfer/ServerTransferClaimer.php b/app/Services/ServerTransfer/ServerTransferClaimer.php index 76cb4a5e8d..164fac444d 100644 --- a/app/Services/ServerTransfer/ServerTransferClaimer.php +++ b/app/Services/ServerTransfer/ServerTransferClaimer.php @@ -55,10 +55,16 @@ class ServerTransferClaimer $server->settings->sentinel_custom_url = $instanceUrl; $server->settings->ensureValidSentinelToken(); $server->settings->is_sentinel_enabled = true; - $server->settings->save(); $sentinelRebound = true; } + if ($server->settings) { + if (! $server->team->serverOverflow()) { + $server->settings->force_disabled = false; + } + $server->settings->save(); + } + $metadata = $server->server_metadata ?? []; $metadata['transfer'] = array_merge((array) data_get($metadata, 'transfer', []), [ 'status' => 'claimed', diff --git a/resources/views/livewire/server/show.blade.php b/resources/views/livewire/server/show.blade.php index 6ae3c02aa8..b2650fa9d5 100644 --- a/resources/views/livewire/server/show.blade.php +++ b/resources/views/livewire/server/show.blade.php @@ -154,6 +154,52 @@ @endif + @if (isDev()) + +
+
+
+ Manage this server from this instance +
+

+ @if ($server->isTransferredAway()) + Automations are disabled here. Existing workloads continue to run on the server. + @else + This instance runs deployments, webhooks, scheduled tasks, backups, proxy updates, + and monitoring for this server. + @endif +

+
+ + +
+ + @if ($server->isTransferredAway()) + + Before you enable management, disable this server on every other Coolify instance. Two + active instances can cause conflicting deployments, proxy changes, backups, and restarts. + + @endif +
+ @endif + diff --git a/tests/Feature/ServerManagementOwnershipTest.php b/tests/Feature/ServerManagementOwnershipTest.php new file mode 100644 index 0000000000..5d8e9a30d0 --- /dev/null +++ b/tests/Feature/ServerManagementOwnershipTest.php @@ -0,0 +1,163 @@ + 'local']); + InstanceSettings::forceCreate([ + 'id' => 0, + 'fqdn' => 'https://coolify-a.test', + ]); + + $this->team = Team::factory()->create(); + $this->owner = User::factory()->create(); + $this->team->members()->attach($this->owner, ['role' => 'owner']); + $this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + ]); + + $this->actingAs($this->owner); + session(['currentTeam' => $this->team]); +}); + +test('owner can stop managing a server from this instance', function () { + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->call('toggleManagement') + ->assertDispatched('success'); + + $this->server->refresh(); + + expect($this->server->isTransferredAway())->toBeTrue() + ->and((bool) $this->server->settings->force_disabled)->toBeTrue() + ->and((bool) $this->server->settings->is_sentinel_enabled)->toBeFalse(); +}); + +test('owner can take management of a transferred server', function () { + $this->server->server_metadata = [ + 'transfer' => ['status' => 'transferred'], + ]; + $this->server->save(); + $this->server->settings->forceFill([ + 'force_disabled' => true, + 'is_sentinel_enabled' => false, + ])->save(); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->call('toggleManagement') + ->assertDispatched('success'); + + $this->server->refresh(); + + expect(data_get($this->server->server_metadata, 'transfer.status'))->toBe('claimed') + ->and((bool) $this->server->settings->force_disabled)->toBeFalse() + ->and((bool) $this->server->settings->is_sentinel_enabled)->toBeTrue() + ->and((string) $this->server->settings->sentinel_custom_url)->toBe('https://coolify-a.test'); +}); + +function markServerTransferredAway(Server $server): void +{ + $server->server_metadata = [ + 'transfer' => ['status' => 'transferred'], + ]; + $server->save(); + $server->settings->forceFill([ + 'force_disabled' => true, + 'is_sentinel_enabled' => false, + ])->save(); +} + +test('cloud team over its server limit cannot take management of a transferred server', function () { + config()->set('constants.coolify.self_hosted', false); + $this->team->forceFill(['custom_server_limit' => 1])->save(); + Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + ]); + markServerTransferredAway($this->server); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->call('toggleManagement') + ->assertDispatched('error') + ->assertNotDispatched('success'); + + $this->server->refresh(); + + expect($this->server->isTransferredAway())->toBeTrue() + ->and((bool) $this->server->settings->force_disabled)->toBeTrue(); +}); + +test('cloud team within its server limit can take management of a transferred server', function () { + config()->set('constants.coolify.self_hosted', false); + $this->team->forceFill(['custom_server_limit' => 1])->save(); + markServerTransferredAway($this->server); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->call('toggleManagement') + ->assertDispatched('success'); + + $this->server->refresh(); + + expect(data_get($this->server->server_metadata, 'transfer.status'))->toBe('claimed') + ->and((bool) $this->server->settings->force_disabled)->toBeFalse(); +}); + +test('claiming a server keeps it force disabled when the cloud team is over its server limit', function () { + config()->set('constants.coolify.self_hosted', false); + $this->team->forceFill(['custom_server_limit' => 1])->save(); + Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + ]); + markServerTransferredAway($this->server); + + app(ServerTransferClaimer::class)->claim($this->server, writeRemote: false); + + $this->server->refresh(); + + expect(data_get($this->server->server_metadata, 'transfer.status'))->toBe('claimed') + ->and((bool) $this->server->settings->force_disabled)->toBeTrue(); +}); + +test('team members cannot change server management ownership', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member, ['role' => 'member']); + + $this->actingAs($member); + session(['currentTeam' => $this->team]); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->call('toggleManagement') + ->assertForbidden(); + + expect($this->server->fresh()->isTransferredAway())->toBeFalse(); +}); + +test('server overview explains the management ownership switch', function () { + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->assertSee('Manage this server from this instance') + ->assertSee('This instance runs deployments, webhooks, scheduled tasks, backups, proxy updates,') + ->assertSee('and monitoring for this server.'); +}); + +test('management ownership is not available outside development', function () { + config(['app.env' => 'production']); + + Livewire::test(Show::class, ['server_uuid' => $this->server->uuid]) + ->assertDontSee('Manage this server from this instance') + ->call('toggleManagement') + ->assertNotFound(); + + expect($this->server->fresh()->isTransferredAway())->toBeFalse(); +});