diff --git a/app/Http/Controllers/Api/DigitalOceanController.php b/app/Http/Controllers/Api/DigitalOceanController.php index 5bd9d2392e..249f34f04c 100644 --- a/app/Http/Controllers/Api/DigitalOceanController.php +++ b/app/Http/Controllers/Api/DigitalOceanController.php @@ -16,6 +16,7 @@ use App\Services\DigitalOceanService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; +use Illuminate\Validation\ValidationException; use OpenApi\Attributes as OA; class DigitalOceanController extends Controller @@ -316,7 +317,7 @@ class DigitalOceanController extends Controller $dropletId = (int) $droplet['id']; $server = DB::transaction(function () use ($normalizedServerName, $teamId, $privateKey, $token, $dropletId, $droplet): Server { - $server = Server::create([ + $server = Team::createServerWithinLimit($teamId, [ 'name' => $normalizedServerName, 'ip' => Server::PLACEHOLDER_IP, 'user' => 'root', @@ -366,6 +367,13 @@ class DigitalOceanController extends Controller 'digitalocean_droplet_id' => $dropletId, 'ip' => $server->ip, ])->setStatusCode(201); + } catch (ValidationException $e) { + if (! isset($e->errors()['server'])) { + throw $e; + } + $this->deleteUntrackedDroplet($digitalOceanService, $dropletId, $server); + + return response()->json(['message' => 'Server limit reached for your subscription.'], 400); } catch (RateLimitException $e) { $this->deleteUntrackedDroplet($digitalOceanService, $dropletId, $server); diff --git a/app/Http/Controllers/Api/HetznerController.php b/app/Http/Controllers/Api/HetznerController.php index 4cadc0eb64..7c2cbd8ace 100644 --- a/app/Http/Controllers/Api/HetznerController.php +++ b/app/Http/Controllers/Api/HetznerController.php @@ -15,6 +15,7 @@ use App\Rules\ValidHostname; use App\Services\HetznerService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Validation\ValidationException; use OpenApi\Attributes as OA; class HetznerController extends Controller @@ -939,7 +940,7 @@ class HetznerController extends Controller } // Create server in Coolify database - $server = Server::create([ + $server = Team::createServerWithinLimit($teamId, [ 'name' => $normalizedServerName, 'ip' => $ipAddress, 'user' => 'root', @@ -980,6 +981,19 @@ class HetznerController extends Controller 'hetzner_server_id' => $hetznerServer['id'], 'ip' => $ipAddress, ])->setStatusCode(201); + } catch (ValidationException $e) { + if (! isset($e->errors()['server'])) { + throw $e; + } + if (isset($hetznerService, $hetznerServer['id'])) { + try { + $hetznerService->deleteServer((int) $hetznerServer['id']); + } catch (\Throwable $cleanupError) { + report($cleanupError); + } + } + + return response()->json(['message' => 'Server limit reached for your subscription.'], 400); } catch (RateLimitException $e) { $response = response()->json(['message' => $e->getMessage()], 429); if ($e->retryAfter !== null) { diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index 7f6b94c80a..67f9c6932f 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -14,10 +14,12 @@ use App\Models\Application; use App\Models\PrivateKey; use App\Models\Project; use App\Models\Server as ModelsServer; +use App\Models\Team; use App\Rules\ValidServerIp; use App\Support\ValidationPatterns; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Validation\ValidationException; use OpenApi\Attributes as OA; use Stringable; @@ -563,15 +565,19 @@ class ServersController extends Controller $proxyType = $request->proxy_type ? str($request->proxy_type)->upper() : ProxyTypes::TRAEFIK->value; - $server = ModelsServer::create([ - 'name' => $request->name, - 'description' => $request->description, - 'ip' => $request->ip, - 'port' => $request->port, - 'user' => $request->user, - 'private_key_id' => $privateKey->id, - 'team_id' => $teamId, - ]); + try { + $server = Team::createServerWithinLimit($teamId, [ + 'name' => $request->name, + 'description' => $request->description, + 'ip' => $request->ip, + 'port' => $request->port, + 'user' => $request->user, + 'private_key_id' => $privateKey->id, + 'team_id' => $teamId, + ]); + } catch (ValidationException) { + return response()->json(['message' => 'Server limit reached for your subscription.'], 400); + } $server->proxy->set('type', $proxyType); $server->proxy->set('status', ProxyStatus::EXITED->value); $server->save(); diff --git a/app/Http/Controllers/Api/VultrController.php b/app/Http/Controllers/Api/VultrController.php index 51fad6a0bb..879e887517 100644 --- a/app/Http/Controllers/Api/VultrController.php +++ b/app/Http/Controllers/Api/VultrController.php @@ -16,6 +16,7 @@ use App\Services\VultrService; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; +use Illuminate\Validation\ValidationException; use OpenApi\Attributes as OA; class VultrController extends Controller @@ -326,7 +327,7 @@ class VultrController extends Controller $ipAddress = $vultrService->getPublicIp($vultrInstance, $request->disable_public_ipv4, $request->enable_ipv6) ?? Server::PLACEHOLDER_IP; $server = DB::transaction(function () use ($normalizedServerName, $ipAddress, $teamId, $privateKey, $token, $vultrInstanceId, $vultrInstance): Server { - $server = Server::create([ + $server = Team::createServerWithinLimit($teamId, [ 'name' => $normalizedServerName, 'ip' => $ipAddress, 'user' => 'root', @@ -375,6 +376,13 @@ class VultrController extends Controller 'vultr_instance_id' => $vultrInstanceId, 'ip' => $server->ip, ])->setStatusCode(201); + } catch (ValidationException $e) { + if (! isset($e->errors()['server'])) { + throw $e; + } + $this->deleteUntrackedInstance($vultrService, $vultrInstanceId, $server); + + return response()->json(['message' => 'Server limit reached for your subscription.'], 400); } catch (RateLimitException $e) { $this->deleteUntrackedInstance($vultrService, $vultrInstanceId, $server); diff --git a/app/Livewire/Boarding/Index.php b/app/Livewire/Boarding/Index.php index 7e8a68dfca..4ea36b9b58 100644 --- a/app/Livewire/Boarding/Index.php +++ b/app/Livewire/Boarding/Index.php @@ -11,6 +11,7 @@ use App\Services\ConfigurationRepository; use App\Support\ValidationPatterns; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; +use Illuminate\Validation\ValidationException; use Livewire\Attributes\Url; use Livewire\Component; @@ -317,15 +318,19 @@ class Index extends Component $this->createdPrivateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($privateKeyId); $this->authorize('view', $this->createdPrivateKey); - $this->createdServer = Server::create([ - 'name' => $this->remoteServerName, - 'ip' => $this->remoteServerHost, - 'port' => $this->remoteServerPort, - 'user' => $this->remoteServerUser, - 'description' => $this->remoteServerDescription, - 'private_key_id' => $this->createdPrivateKey->id, - 'team_id' => currentTeam()->id, - ]); + try { + $this->createdServer = Team::createServerWithinLimit(currentTeam()->id, [ + 'name' => $this->remoteServerName, + 'ip' => $this->remoteServerHost, + 'port' => $this->remoteServerPort, + 'user' => $this->remoteServerUser, + 'description' => $this->remoteServerDescription, + 'private_key_id' => $this->createdPrivateKey->id, + 'team_id' => currentTeam()->id, + ]); + } catch (ValidationException) { + return $this->dispatch('error', 'You have reached the server limit for your subscription.'); + } $this->createdServer->settings->is_cloudflare_tunnel = $this->isCloudflareTunnel; $this->createdServer->settings->save(); $this->selectedExistingServer = $this->createdServer->id; diff --git a/app/Livewire/Server/New/ByDigitalOcean.php b/app/Livewire/Server/New/ByDigitalOcean.php index cf27f0f081..d05532776e 100644 --- a/app/Livewire/Server/New/ByDigitalOcean.php +++ b/app/Livewire/Server/New/ByDigitalOcean.php @@ -478,7 +478,7 @@ class ByDigitalOcean extends Component // Persist the server immediately so the droplet is always tracked // in Coolify, even if waiting for the public IP fails below. $server = DB::transaction(function () use ($dropletId, $droplet): Server { - $server = Server::create([ + $server = Team::createServerWithinLimit(currentTeam()->id, [ 'name' => strtolower(trim($this->server_name)), 'ip' => Server::PLACEHOLDER_IP, 'user' => 'root', diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php index 1059c67136..3d704a88e9 100644 --- a/app/Livewire/Server/New/ByHetzner.php +++ b/app/Livewire/Server/New/ByHetzner.php @@ -15,6 +15,7 @@ use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Http\Client\RequestException; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Http; +use Illuminate\Validation\ValidationException; use Livewire\Attributes\Locked; use Livewire\Component; @@ -720,7 +721,7 @@ class ByHetzner extends Component // Create server in Coolify database immediately so the Hetzner // server is always tracked, even when no IP is assigned yet — // the server page polling backfills the placeholder IP later. - $server = Server::create([ + $server = Team::createServerWithinLimit(currentTeam()->id, [ 'name' => $this->server_name, 'ip' => $ipAddress ?? Server::PLACEHOLDER_IP, 'user' => 'root', @@ -755,6 +756,19 @@ class ByHetzner extends Component } return redirectRoute($this, 'server.show', [$server->uuid]); + } catch (ValidationException $e) { + if (! isset($e->errors()['server'])) { + throw $e; + } + if (isset($hetznerService, $hetznerServer['id'])) { + try { + $hetznerService->deleteServer((int) $hetznerServer['id']); + } catch (\Throwable $cleanupError) { + report($cleanupError); + } + } + + return $this->dispatch('error', 'You have reached the server limit for your subscription.'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Server/New/ByIp.php b/app/Livewire/Server/New/ByIp.php index 1c92078f27..1d1c0d1b8a 100644 --- a/app/Livewire/Server/New/ByIp.php +++ b/app/Livewire/Server/New/ByIp.php @@ -168,7 +168,7 @@ class ByIp extends Component if ($this->server_role === ServerRole::BUILD->value) { data_forget($payload, 'proxy'); } - $server = Server::create($payload); + $server = Team::createServerWithinLimit(currentTeam()->id, $payload); $server->proxy->set('status', 'exited'); $server->proxy->set('type', ProxyTypes::TRAEFIK->value); $server->save(); diff --git a/app/Livewire/Server/New/ByVultr.php b/app/Livewire/Server/New/ByVultr.php index 4246d5836c..3a6ce532df 100644 --- a/app/Livewire/Server/New/ByVultr.php +++ b/app/Livewire/Server/New/ByVultr.php @@ -446,7 +446,7 @@ class ByVultr extends Component $ipAddress = $vultrService->getPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6) ?? Server::PLACEHOLDER_IP; $server = DB::transaction(function () use ($ipAddress, $vultrInstanceId, $vultrInstance): Server { - $server = Server::create([ + $server = Team::createServerWithinLimit(currentTeam()->id, [ 'name' => strtolower(trim($this->server_name)), 'ip' => $ipAddress, 'user' => 'root', diff --git a/app/Models/Team.php b/app/Models/Team.php index b8c22cfb2e..6ce1c1d3c1 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -15,6 +15,8 @@ use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Notifications\Notifiable; +use Illuminate\Support\Facades\DB; +use Illuminate\Validation\ValidationException; use OpenApi\Attributes as OA; #[OA\Schema( @@ -125,11 +127,29 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen return true; } $serverLimit = Team::serverLimit($team); - $servers = $team->servers->count(); + $servers = $team->servers()->count(); return $servers >= $serverLimit; } + public static function createServerWithinLimit(int $teamId, array $attributes): Server + { + return DB::transaction(function () use ($teamId, $attributes): Server { + self::ensureServerCapacity($teamId); + + return Server::create($attributes); + }); + } + + /** Call within a transaction so the team lock lasts through the server insert. */ + public static function ensureServerCapacity(int $teamId): void + { + $team = self::query()->lockForUpdate()->findOrFail($teamId); + if (self::serverLimitReached($team)) { + throw ValidationException::withMessages(['server' => 'Server limit reached for your subscription.']); + } + } + public function subscriptionPastOverDue() { if (isCloud()) { diff --git a/app/Services/ServerTransfer/ServerTransferImporter.php b/app/Services/ServerTransfer/ServerTransferImporter.php index 371844bcf9..995555954b 100644 --- a/app/Services/ServerTransfer/ServerTransferImporter.php +++ b/app/Services/ServerTransfer/ServerTransferImporter.php @@ -35,6 +35,7 @@ use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; use App\Models\SwarmDocker; use App\Models\Tag; +use App\Models\Team; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; @@ -208,6 +209,8 @@ class ServerTransferImporter } $result = DB::transaction(function () use ($bundle, $teamId, $preserveUuids, $adoptMode, $warnings, &$created) { + Team::ensureServerCapacity($teamId); + $this->privateKeyMap = []; $this->githubAppMap = []; $this->gitlabAppMap = []; diff --git a/tests/Feature/Api/ServerTransferApiTest.php b/tests/Feature/Api/ServerTransferApiTest.php index 7d4720a302..6efaf3a038 100644 --- a/tests/Feature/Api/ServerTransferApiTest.php +++ b/tests/Feature/Api/ServerTransferApiTest.php @@ -233,6 +233,29 @@ describe('POST /api/v1/servers/import', function () { ->assertStatus(422) ->assertJsonPath('message', fn ($m) => str_contains($m, 'already exists') || str_contains(json_encode($m), 'already exists') || true); }); + + test('rejects import when the destination team is at its cloud server limit', function () { + config()->set('constants.coolify.self_hosted', false); + $this->team->update(['custom_server_limit' => 1]); + + $export = $this->withHeaders(transferHeaders($this->sensitiveToken)) + ->getJson("/api/v1/servers/{$this->server->uuid}/export") + ->json(); + + $this->server->forceDelete(); + Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->privateKey->id, + 'ip' => '10.66.0.21', + ]); + + $this->withHeaders(transferHeaders($this->sensitiveToken)) + ->postJson('/api/v1/servers/import', ['bundle' => $export, 'claim' => false]) + ->assertStatus(422) + ->assertJsonPath('errors.server.0', 'Server limit reached for your subscription.'); + + expect(Server::where('team_id', $this->team->id)->count())->toBe(1); + }); }); describe('POST /api/v1/servers/{uuid}/claim', function () { diff --git a/tests/Feature/ServerCreationLimitTest.php b/tests/Feature/ServerCreationLimitTest.php new file mode 100644 index 0000000000..964905306a --- /dev/null +++ b/tests/Feature/ServerCreationLimitTest.php @@ -0,0 +1,92 @@ +set('constants.coolify.self_hosted', false); + InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]); + + $this->team = Team::factory()->create(['custom_server_limit' => 1]); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + + $this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $this->token = $this->user->createToken('write', ['write']); + $this->token->accessToken->forceFill(['team_id' => $this->team->id])->save(); +}); + +it('rejects an API server when the cloud team is at its limit', function () { + Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id]); + + $this->withToken($this->token->plainTextToken)->postJson('/api/v1/servers', [ + 'name' => 'Over limit', + 'ip' => '192.0.2.100', + 'private_key_uuid' => $this->privateKey->uuid, + ])->assertStatus(400)->assertJsonPath('message', 'Server limit reached for your subscription.'); + + expect(Server::where('team_id', $this->team->id)->count())->toBe(1); +}); + +it('allows an API server while the cloud team has capacity', function () { + $this->withToken($this->token->plainTextToken)->postJson('/api/v1/servers', [ + 'name' => 'Within limit', + 'ip' => '192.0.2.104', + 'private_key_uuid' => $this->privateKey->uuid, + ])->assertCreated(); + + expect(Server::where('team_id', $this->team->id)->count())->toBe(1); +}); + +it('rejects an onboarding server when the cloud team is at its limit', function () { + Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id]); + + Livewire::actingAs($this->user)->test(BoardingIndex::class) + ->set('remoteServerName', 'Over limit') + ->set('remoteServerHost', '192.0.2.101') + ->set('remoteServerPort', 22) + ->set('remoteServerUser', 'root') + ->set('privateKey', $this->privateKey->private_key) + ->set('selectedExistingPrivateKey', $this->privateKey->id) + ->call('saveServer') + ->assertDispatched('error'); + + expect(Server::where('team_id', $this->team->id)->count())->toBe(1); +}); + +it('does not apply the cloud limit to self-hosted teams', function () { + config()->set('constants.coolify.self_hosted', true); + Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->privateKey->id]); + + $this->withToken($this->token->plainTextToken)->postJson('/api/v1/servers', [ + 'name' => 'Self-hosted server', + 'ip' => '192.0.2.102', + 'private_key_uuid' => $this->privateKey->uuid, + ])->assertCreated(); + + expect(Server::where('team_id', $this->team->id)->count())->toBe(2); +}); + +it('does not let a team member create a server through the API', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + $token = $member->createToken('write', ['write']); + $token->accessToken->forceFill(['team_id' => $this->team->id])->save(); + + $this->withToken($token->plainTextToken)->postJson('/api/v1/servers', [ + 'name' => 'Member server', + 'ip' => '192.0.2.103', + 'private_key_uuid' => $this->privateKey->uuid, + ])->assertForbidden(); + + expect(Server::where('team_id', $this->team->id)->count())->toBe(0); +}); diff --git a/tests/Feature/TeamServerLimitTest.php b/tests/Feature/TeamServerLimitTest.php index 11d7f09d12..3fe5229e80 100644 --- a/tests/Feature/TeamServerLimitTest.php +++ b/tests/Feature/TeamServerLimitTest.php @@ -51,3 +51,13 @@ it('returns true for serverLimitReached when team has servers at limit', functio expect($result)->toBeTrue(); }); + +it('checks the current server count even when the relation was loaded earlier', function () { + config()->set('constants.coolify.self_hosted', false); + + $team = Team::factory()->create(['custom_server_limit' => 1]); + $team->load('servers'); + Server::factory()->create(['team_id' => $team->id]); + + expect(Team::serverLimitReached($team))->toBeTrue(); +});