From 383a5a742f36130a10e6f26db50d8764cf7978b1 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 19 Sep 2026 17:22:13 +0200 Subject: [PATCH] feat(servers): add configurable deployment and build roles Introduce deployment, build, and dual-purpose server roles, with API and UI support, build-server fallback controls, and role-aware resource hosting. --- app/Enums/ServerRole.php | 20 ++++ .../Controllers/Api/ServersController.php | 48 +++++--- app/Http/Controllers/Api/TeamController.php | 53 +++++++++ app/Jobs/ApplicationDeploymentJob.php | 6 + app/Livewire/Project/New/Select.php | 6 +- app/Livewire/Server/New/ByIp.php | 14 ++- app/Livewire/Server/Show.php | 80 ++++++++++---- app/Models/Server.php | 30 +++-- app/Models/ServerSetting.php | 11 +- app/Models/Team.php | 1 + .../ServerTransfer/ServerTransferExporter.php | 3 +- .../ServerTransfer/ServerTransferImporter.php | 10 +- ...d_server_role_to_server_settings_table.php | 28 +++++ openapi.json | 60 ++++++++++ openapi.yaml | 39 +++++++ .../views/components/server/sidebar.blade.php | 4 +- .../views/livewire/server/navbar.blade.php | 4 +- .../views/livewire/server/new/by-ip.blade.php | 11 +- .../views/livewire/server/show.blade.php | 40 +++++-- routes/api.php | 1 + tests/Feature/Api/TeamTokenTeamApiTest.php | 49 ++++++++- .../Feature/BuildServerFallbackPolicyTest.php | 28 +++++ .../NewServerPrivateKeySelectionTest.php | 8 +- tests/Feature/ResourceHostingServerTest.php | 103 +++++++++++++----- tests/Feature/ServerBuildRoleHelperTest.php | 21 +++- .../ServerCreationBuildRoleLayoutTest.php | 12 +- tests/Feature/ServerDestinationsPageTest.php | 2 + .../Feature/ServerUpdatePrivateKeyApiTest.php | 85 +++++++++++++-- 28 files changed, 651 insertions(+), 126 deletions(-) create mode 100644 app/Enums/ServerRole.php create mode 100644 database/migrations/2026_09_19_121840_add_server_role_to_server_settings_table.php diff --git a/app/Enums/ServerRole.php b/app/Enums/ServerRole.php new file mode 100644 index 0000000000..d07c353aee --- /dev/null +++ b/app/Enums/ServerRole.php @@ -0,0 +1,20 @@ + ['type' => 'integer', 'example' => 22, 'description' => 'The port of the server.'], 'user' => ['type' => 'string', 'example' => 'root', 'description' => 'The user of the server.'], 'private_key_uuid' => ['type' => 'string', 'example' => 'og888os', 'description' => 'The UUID of the private key.'], - 'is_build_server' => ['type' => 'boolean', 'example' => false, 'description' => 'Is build server.'], + 'server_role' => ['type' => 'string', 'enum' => ['deployment', 'build', 'both'], 'example' => 'both', 'description' => 'Server role.'], 'instant_validate' => ['type' => 'boolean', 'example' => false, 'description' => 'Instant validate.'], 'proxy_type' => ['type' => 'string', 'enum' => ['traefik', 'caddy', 'none'], 'example' => 'traefik', 'description' => 'The proxy type.'], ], @@ -481,7 +482,7 @@ class ServersController extends Controller )] public function create_server(Request $request) { - $allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type']; + $allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'server_role', 'instant_validate', 'proxy_type']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -500,7 +501,7 @@ class ServersController extends Controller 'port' => 'integer|nullable|between:1,65535', 'private_key_uuid' => 'string|required', 'user' => ValidationPatterns::serverUsernameRules(required: false), - 'is_build_server' => 'boolean|nullable', + 'server_role' => 'string|nullable|in:deployment,build,both', 'instant_validate' => 'boolean|nullable', 'proxy_type' => 'string|nullable', ], [ @@ -530,8 +531,15 @@ class ServersController extends Controller if (is_null($request->port)) { $request->offsetSet('port', 22); } - if (is_null($request->is_build_server)) { - $request->offsetSet('is_build_server', false); + $serverRole = $request->filled('server_role') + ? ServerRole::from($request->string('server_role')->toString()) + : ServerRole::BOTH; + + if ($serverRole === ServerRole::DEPLOYMENT && ! ModelsServer::buildServers($teamId)->exists()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['server_role' => ['Add another build-capable server before you set this server to deployments only.']], + ], 422); } if (is_null($request->instant_validate)) { $request->offsetSet('instant_validate', false); @@ -569,7 +577,7 @@ class ServersController extends Controller $server->save(); $server->settings()->update([ - 'is_build_server' => $request->is_build_server, + 'server_role' => $serverRole, ]); if ($request->instant_validate) { ValidateServer::dispatch($server); @@ -580,7 +588,7 @@ class ServersController extends Controller 'server_uuid' => $server->uuid, 'server_name' => $server->name, 'ip' => $server->ip, - 'is_build_server' => (bool) $request->is_build_server, + 'server_role' => $serverRole->value, ]); return response()->json([ @@ -614,7 +622,7 @@ class ServersController extends Controller 'port' => ['type' => 'integer', 'description' => 'The port of the server.'], 'user' => ['type' => 'string', 'description' => 'The user of the server.'], 'private_key_uuid' => ['type' => 'string', 'description' => 'The UUID of the private key.'], - 'is_build_server' => ['type' => 'boolean', 'description' => 'Is build server.'], + 'server_role' => ['type' => 'string', 'enum' => ['deployment', 'build', 'both'], 'description' => 'Server role.'], 'instant_validate' => ['type' => 'boolean', 'description' => 'Instant validate.'], 'proxy_type' => ['type' => 'string', 'enum' => ['traefik', 'caddy', 'none'], 'description' => 'The proxy type.'], 'concurrent_builds' => ['type' => 'integer', 'description' => 'Number of concurrent builds.'], @@ -659,7 +667,7 @@ class ServersController extends Controller )] public function update_server(Request $request) { - $allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'is_build_server', 'instant_validate', 'proxy_type', 'concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'connection_timeout', 'is_terminal_enabled']; + $allowedFields = ['name', 'description', 'ip', 'port', 'user', 'private_key_uuid', 'server_role', 'instant_validate', 'proxy_type', 'concurrent_builds', 'dynamic_timeout', 'deployment_queue_limit', 'server_disk_usage_notification_threshold', 'server_disk_usage_check_frequency', 'connection_timeout', 'is_terminal_enabled']; $teamId = getTeamIdFromToken(); if (is_null($teamId)) { @@ -677,7 +685,7 @@ class ServersController extends Controller 'port' => 'integer|nullable|between:1,65535', 'private_key_uuid' => 'string|nullable', 'user' => ValidationPatterns::serverUsernameRules(required: false), - 'is_build_server' => 'boolean|nullable', + 'server_role' => 'string|nullable|in:deployment,build,both', 'instant_validate' => 'boolean|nullable', 'proxy_type' => 'string|nullable', 'concurrent_builds' => 'integer|min:1', @@ -734,17 +742,29 @@ class ServersController extends Controller ], 422); } - if ($request->boolean('is_build_server') && ! $server->isBuildServer() && ! $server->isEmpty()) { + $serverRole = null; + if ($request->filled('server_role')) { + $serverRole = ServerRole::from($request->string('server_role')->toString()); + } + + if ($serverRole === ServerRole::BUILD && ! $server->isBuildServer() && ! $server->isEmpty()) { return response()->json([ 'message' => 'Validation failed.', - 'errors' => ['is_build_server' => ['A server with existing resources cannot be configured as a build server.']], + 'errors' => ['server_role' => ['A server with existing resources cannot be configured as build only.']], + ], 422); + } + + if ($serverRole === ServerRole::DEPLOYMENT && ! ModelsServer::buildServers($teamId)->whereKeyNot($server->id)->exists()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => ['server_role' => ['Add another build-capable server before you set this server to deployments only.']], ], 422); } $server->update($updateFields); - if ($request->has('is_build_server')) { + if ($serverRole !== null) { $server->settings()->update([ - 'is_build_server' => $request->boolean('is_build_server'), + 'server_role' => $serverRole, ]); } diff --git a/app/Http/Controllers/Api/TeamController.php b/app/Http/Controllers/Api/TeamController.php index b9f8572673..ed8d34d226 100644 --- a/app/Http/Controllers/Api/TeamController.php +++ b/app/Http/Controllers/Api/TeamController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Api; use App\Http\Controllers\Controller; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use OpenApi\Attributes as OA; @@ -224,6 +225,58 @@ class TeamController extends Controller ); } + #[OA\Patch( + summary: 'Update authenticated team', + description: 'Update settings for the team bound to the API token.', + path: '/team', + operationId: 'update-token-team', + security: [['bearerAuth' => []]], + tags: ['Teams'], + requestBody: new OA\RequestBody( + required: true, + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + required: ['is_build_server_fallback_enabled'], + properties: [ + 'is_build_server_fallback_enabled' => [ + 'type' => 'boolean', + 'description' => 'Whether deployments can fall back to the deployment server when no usable dedicated build server is available.', + ], + ], + ), + ), + ), + responses: [ + new OA\Response(response: 200, description: 'Updated team.', content: new OA\JsonContent(ref: '#/components/schemas/Team')), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 400, ref: '#/components/responses/400'), + new OA\Response(response: 403, description: 'Forbidden.'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function update_current_team(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $team = auth()->user()->teams->where('id', $teamId)->first(); + if (is_null($team)) { + return response()->json(['message' => 'Team not found.'], 404); + } + + $this->authorize('update', $team); + $validated = $request->validate([ + 'is_build_server_fallback_enabled' => ['required', 'boolean'], + ]); + + $team->update($validated); + + return response()->json($this->removeSensitiveData($team)); + } + #[OA\Get( summary: 'Authenticated Team Members', description: 'Get members of the team bound to the API token.', diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 7ef4774f8c..ca4f17ecd4 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -433,6 +433,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } $this->build_server = $buildServers->random(); + if ($this->build_server->is($this->server)) { + $this->application_deployment_queue->addLogEntry("Using deployment server ({$this->server->name}) for the build."); + + return; + } + $this->application_deployment_queue->build_server_id = $this->build_server->id; $this->application_deployment_queue->addLogEntry("Found a suitable build server ({$this->build_server->name})."); $this->use_build_server = true; diff --git a/app/Livewire/Project/New/Select.php b/app/Livewire/Project/New/Select.php index ba03041e2b..307fbb8936 100644 --- a/app/Livewire/Project/New/Select.php +++ b/app/Livewire/Project/New/Select.php @@ -274,7 +274,7 @@ class Select extends Component $this->servers = $this->allServers; } else { if ($this->allServers instanceof Collection) { - $this->servers = $this->allServers->where('settings.is_swarm_worker', false)->where('settings.is_swarm_manager', false)->where('settings.is_build_server', false); + $this->servers = $this->allServers->where('settings.is_swarm_worker', false)->where('settings.is_swarm_manager', false)->filter(fn (Server $server) => $server->canHostResources()); } else { $this->servers = $this->allServers; } @@ -372,7 +372,7 @@ class Select extends Component $this->isDatabase = true; $this->includeSwarm = false; if ($this->allServers instanceof Collection) { - $this->servers = $this->allServers->where('settings.is_swarm_worker', false)->where('settings.is_swarm_manager', false)->where('settings.is_build_server', false); + $this->servers = $this->allServers->where('settings.is_swarm_worker', false)->where('settings.is_swarm_manager', false)->filter(fn (Server $server) => $server->canHostResources()); } else { $this->servers = $this->allServers; } @@ -382,7 +382,7 @@ class Select extends Component $this->isDatabase = true; $this->includeSwarm = false; if ($this->allServers instanceof Collection) { - $this->servers = $this->allServers->where('settings.is_swarm_worker', false)->where('settings.is_swarm_manager', false)->where('settings.is_build_server', false); + $this->servers = $this->allServers->where('settings.is_swarm_worker', false)->where('settings.is_swarm_manager', false)->filter(fn (Server $server) => $server->canHostResources()); } else { $this->servers = $this->allServers; } diff --git a/app/Livewire/Server/New/ByIp.php b/app/Livewire/Server/New/ByIp.php index a85306f6bf..1c92078f27 100644 --- a/app/Livewire/Server/New/ByIp.php +++ b/app/Livewire/Server/New/ByIp.php @@ -3,6 +3,7 @@ namespace App\Livewire\Server\New; use App\Enums\ProxyTypes; +use App\Enums\ServerRole; use App\Models\PrivateKey; use App\Models\Server; use App\Models\Team; @@ -40,7 +41,7 @@ class ByIp extends Component public int $port = 22; - public bool $is_build_server = false; + public string $server_role = ServerRole::BOTH->value; public function mount() { @@ -60,7 +61,7 @@ class ByIp extends Component 'ip' => ['required', 'string', new ValidServerIp], 'user' => ValidationPatterns::serverUsernameRules(), 'port' => 'required|integer|between:1,65535', - 'is_build_server' => 'required|boolean', + 'server_role' => ['required', 'in:deployment,build,both'], ]; } @@ -80,8 +81,8 @@ class ByIp extends Component 'port.required' => 'The Port field is required.', 'port.integer' => 'The Port field must be an integer.', 'port.between' => 'The Port field must be between 1 and 65535.', - 'is_build_server.required' => 'The Build Server field is required.', - 'is_build_server.boolean' => 'The Build Server field must be true or false.', + 'server_role.required' => 'The Server Role field is required.', + 'server_role.in' => 'The selected Server Role is invalid.', ]); } @@ -164,14 +165,15 @@ class ByIp extends Component 'team_id' => currentTeam()->id, 'private_key_id' => $this->private_key_id, ]; - if ($this->is_build_server) { + if ($this->server_role === ServerRole::BUILD->value) { data_forget($payload, 'proxy'); } $server = Server::create($payload); $server->proxy->set('status', 'exited'); $server->proxy->set('type', ProxyTypes::TRAEFIK->value); $server->save(); - $server->settings->is_build_server = $this->is_build_server; + $server->settings->server_role = ServerRole::from($this->server_role); + $server->settings->is_build_server = $this->server_role === ServerRole::BUILD->value; $server->settings->save(); return redirectRoute($this, 'server.show', [$server->uuid]); diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index b58050cef8..e6e8ec0d69 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -3,6 +3,7 @@ namespace App\Livewire\Server; use App\Actions\Server\StopSentinel; +use App\Enums\ServerRole; use App\Events\ServerReachabilityChanged; use App\Models\CloudProviderToken; use App\Models\Server; @@ -47,10 +48,10 @@ class Show extends Component public bool $isSwarmWorker; - public bool $isBuildServer; + public string $serverRole; #[Locked] - public bool $isBuildServerLocked = false; + public ?string $pendingServerRole = null; public bool $isMetricsEnabled; @@ -150,7 +151,7 @@ class Show extends Component 'isUsable' => 'required', 'isSwarmManager' => 'required', 'isSwarmWorker' => 'required', - 'isBuildServer' => 'required', + 'serverRole' => ['required', 'in:deployment,build,both'], 'isMetricsEnabled' => 'required', 'sentinelToken' => 'required', 'sentinelUpdatedAt' => 'nullable', @@ -198,9 +199,6 @@ class Show extends Component try { $this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail(); $this->syncData(); - if (! $this->server->isBuildServer() && ! $this->server->isEmpty()) { - $this->isBuildServerLocked = true; - } // Load saved Hetzner status and validation state $this->hetznerServerStatus = $this->server->hetzner_server_status; $this->vultrInstanceStatus = $this->server->vultr_instance_status; @@ -254,7 +252,9 @@ class Show extends Component $this->server->settings->is_swarm_manager = $this->isSwarmManager; $this->server->settings->wildcard_domain = $this->wildcardDomain; $this->server->settings->is_swarm_worker = $this->isSwarmWorker; - $this->server->settings->is_build_server = $this->isBuildServer; + $role = ServerRole::from($this->serverRole); + $this->server->settings->server_role = $role; + $this->server->settings->is_build_server = $role === ServerRole::BUILD; $this->server->settings->is_metrics_enabled = $this->isMetricsEnabled; $this->server->settings->sentinel_token = $this->sentinelToken; $this->server->settings->sentinel_metrics_refresh_rate_seconds = $this->sentinelMetricsRefreshRateSeconds; @@ -284,7 +284,7 @@ class Show extends Component $this->isUsable = $this->server->settings->is_usable; $this->isSwarmManager = $this->server->settings->is_swarm_manager; $this->isSwarmWorker = $this->server->settings->is_swarm_worker; - $this->isBuildServer = $this->server->settings->is_build_server; + $this->serverRole = $this->server->settings->effectiveServerRole()->value; $this->isMetricsEnabled = $this->server->settings->is_metrics_enabled; $this->sentinelToken = $this->server->settings->sentinel_token; $this->sentinelMetricsRefreshRateSeconds = $this->server->settings->sentinel_metrics_refresh_rate_seconds; @@ -409,31 +409,67 @@ class Show extends Component } } - public function updatedIsBuildServer($value) + public function requestServerRoleChange(): void { try { $this->authorize('update', $this->server); - if ($value === true && ! $this->server->isEmpty()) { - $this->isBuildServer = false; - $this->dispatch('error', 'A server with existing resources cannot be configured as a build server.'); + $newRole = ServerRole::from($this->serverRole); + $currentRole = $this->server->settings()->firstOrFail()->effectiveServerRole(); + + if ($newRole === ServerRole::BUILD && ! $this->server->isEmpty()) { + $this->serverRole = $currentRole->value; + $this->dispatch('error', 'Move or remove the existing resources before you set this server to build only.'); return; } - if ($value === true && $this->server->isSentinelEnabled()) { - $this->isMetricsEnabled = false; - $this->isSentinelDebugEnabled = false; - $this->server->settings->is_sentinel_enabled = false; - StopSentinel::dispatch($this->server); - $this->dispatch('info', 'Sentinel has been disabled as build servers cannot run Sentinel.'); + + if ($newRole === ServerRole::DEPLOYMENT && ! Server::buildServers($this->server->team_id)->whereKeyNot($this->server->id)->exists()) { + $this->serverRole = $currentRole->value; + $this->dispatch('error', 'Add another build-capable server before you set this server to deployments only.'); + + return; } - $this->submit(); - // Dispatch event to refresh the navbar - $this->dispatch('refreshServerShow'); + + if ($newRole === ServerRole::BOTH && $currentRole !== ServerRole::BOTH) { + $this->pendingServerRole = $newRole->value; + $this->serverRole = $currentRole->value; + $this->dispatch('open-server-role-confirmation'); + + return; + } + + $this->saveServerRole($newRole); } catch (\Throwable $e) { - return handleError($e, $this); + handleError($e, $this); } } + public function confirmServerRoleChange(): void + { + try { + $this->authorize('update', $this->server); + $role = ServerRole::from($this->pendingServerRole ?? ''); + $this->pendingServerRole = null; + $this->saveServerRole($role); + } catch (\Throwable $e) { + handleError($e, $this); + } + } + + private function saveServerRole(ServerRole $role): void + { + $this->serverRole = $role->value; + if ($role === ServerRole::BUILD && $this->server->isSentinelEnabled()) { + $this->isMetricsEnabled = false; + $this->isSentinelDebugEnabled = false; + $this->server->settings->is_sentinel_enabled = false; + StopSentinel::dispatch($this->server); + $this->dispatch('info', 'Sentinel has been disabled as build servers cannot run Sentinel.'); + } + $this->submit(); + $this->dispatch('refreshServerShow'); + } + public function regenerateSentinelToken() { try { diff --git a/app/Models/Server.php b/app/Models/Server.php index dfca0593e4..1da5c8e91d 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -8,6 +8,7 @@ use App\Actions\Server\InstallPrerequisites; use App\Actions\Server\StartSentinel; use App\Actions\Server\ValidatePrerequisites; use App\Enums\ProxyTypes; +use App\Enums\ServerRole; use App\Events\ServerReachabilityChanged; use App\Helpers\SslHelper; use App\Jobs\CheckAndStartSentinelJob; @@ -262,7 +263,6 @@ class Server extends BaseModel 'delete_unused_volumes' => 'boolean', 'delete_unused_networks' => 'boolean', 'unreachable_notification_sent' => 'boolean', - 'is_build_server' => 'boolean', 'force_disabled' => 'boolean', 'sentinel_waiting_since' => 'datetime', ]; @@ -522,17 +522,24 @@ class Server extends BaseModel private static function usableByBuildServerStatus(bool $isBuildServer): Builder { - return Server::ownedByCurrentTeam() + $query = Server::ownedByCurrentTeam() ->whereRelation('settings', 'is_reachable', true) ->whereRelation('settings', 'is_usable', true) ->whereRelation('settings', 'is_swarm_worker', false) - ->whereRelation('settings', 'is_build_server', $isBuildServer) ->whereRelation('settings', 'force_disabled', false); + + return $isBuildServer + ? $query->whereHas('settings', fn (Builder $settings) => $settings + ->where('server_role', '!=', ServerRole::DEPLOYMENT->value) + ->orWhereNull('server_role')) + : $query->whereHas('settings', fn (Builder $settings) => $settings + ->where('server_role', '!=', ServerRole::BUILD->value) + ->orWhereNull('server_role')); } public function canHostResources(): bool { - return ! $this->isBuildServer(); + return $this->settings->effectiveServerRole()->canDeploy(); } public function settings() @@ -547,7 +554,7 @@ class Server extends BaseModel public function proxySet() { - return $this->proxyType() && $this->proxyType() !== 'NONE' && $this->isFunctional() && ! $this->isSwarmWorker() && ! $this->settings->is_build_server; + return $this->proxyType() && $this->proxyType() !== 'NONE' && $this->isFunctional() && ! $this->isSwarmWorker() && $this->canHostResources(); } public function setupDefaultRedirect() @@ -907,7 +914,9 @@ $siteAddress { ->whereRelation('settings', 'is_reachable', true) ->whereRelation('settings', 'is_usable', true) ->whereRelation('settings', 'is_swarm_worker', false) - ->whereRelation('settings', 'is_build_server', true) + ->whereHas('settings', fn (Builder $settings) => $settings + ->where('server_role', '!=', ServerRole::DEPLOYMENT->value) + ->orWhereNull('server_role')) ->whereRelation('settings', 'force_disabled', false); } @@ -1655,7 +1664,7 @@ $siteAddress { } $this->settings->is_usable = true; $this->settings->save(); - $this->validateCoolifyNetwork(isSwarm: false, isBuildServer: $this->settings->is_build_server); + $this->validateCoolifyNetwork(isSwarm: false, isBuildServer: $this->isBuildServer()); return true; } @@ -1766,7 +1775,12 @@ $siteAddress { public function isBuildServer() { - return $this->settings->is_build_server; + return $this->settings->effectiveServerRole() === ServerRole::BUILD; + } + + public function canBuildApplications(): bool + { + return $this->settings->effectiveServerRole()->canBuild(); } public static function createWithPrivateKey(array $data, PrivateKey $privateKey) diff --git a/app/Models/ServerSetting.php b/app/Models/ServerSetting.php index e3500b732d..02dc0e8afb 100644 --- a/app/Models/ServerSetting.php +++ b/app/Models/ServerSetting.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Enums\ServerRole; use Illuminate\Contracts\Encryption\DecryptException; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; @@ -19,7 +20,7 @@ use OpenApi\Attributes as OA; 'dynamic_timeout' => ['type' => 'integer'], 'force_disabled' => ['type' => 'boolean'], 'force_server_cleanup' => ['type' => 'boolean'], - 'is_build_server' => ['type' => 'boolean'], + 'server_role' => ['type' => 'string', 'enum' => ['deployment', 'build', 'both']], 'is_cloudflare_tunnel' => ['type' => 'boolean'], 'is_jump_server' => ['type' => 'boolean'], 'is_logdrain_axiom_enabled' => ['type' => 'boolean'], @@ -71,6 +72,7 @@ class ServerSetting extends Model 'is_swarm_manager', 'is_jump_server', 'is_build_server', + 'server_role', 'is_reachable', 'is_usable', 'wildcard_domain', @@ -126,6 +128,7 @@ class ServerSetting extends Model 'is_reachable' => 'boolean', 'is_usable' => 'boolean', 'is_build_server' => 'boolean', + 'server_role' => ServerRole::class, 'is_terminal_enabled' => 'boolean', 'disable_application_image_retention' => 'boolean', 'connection_timeout' => 'integer', @@ -140,6 +143,7 @@ class ServerSetting extends Model * `read:sensitive` or `root` token ability. */ protected $hidden = [ + 'is_build_server', 'sentinel_token', 'sentinel_custom_url', 'logdrain_newrelic_license_key', @@ -175,6 +179,11 @@ class ServerSetting extends Model }); } + public function effectiveServerRole(): ServerRole + { + return $this->server_role ?? ($this->is_build_server ? ServerRole::BUILD : ServerRole::BOTH); + } + /** * Validate that a sentinel token contains only safe characters. * Prevents OS command injection when the token is interpolated into shell commands. diff --git a/app/Models/Team.php b/app/Models/Team.php index 2478028798..5c748b4ae0 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -28,6 +28,7 @@ use OpenApi\Attributes as OA; 'updated_at' => ['type' => 'string', 'description' => 'The date and time the team was last updated.'], 'show_boarding' => ['type' => 'boolean', 'description' => 'Whether to show the boarding screen or not.'], 'custom_server_limit' => ['type' => 'string', 'description' => 'The custom server limit.'], + 'is_build_server_fallback_enabled' => ['type' => 'boolean', 'description' => 'Whether deployments can fall back to the deployment server when no usable dedicated build server is available.'], 'members' => new OA\Property( property: 'members', type: 'array', diff --git a/app/Services/ServerTransfer/ServerTransferExporter.php b/app/Services/ServerTransfer/ServerTransferExporter.php index bd67f8e39a..4a07e813ea 100644 --- a/app/Services/ServerTransfer/ServerTransferExporter.php +++ b/app/Services/ServerTransfer/ServerTransferExporter.php @@ -384,7 +384,8 @@ class ServerTransferExporter 'port' => (int) $server->port, 'user' => (string) $server->user, 'proxy' => $server->proxy?->toArray() ?? [], - 'is_build_server' => (bool) $server->is_build_server, + 'server_role' => $server->settings->effectiveServerRole()->value, + 'is_build_server' => $server->isBuildServer(), 'cloud_provider_token_uuid' => $server->cloudProviderToken?->uuid, 'settings' => $settingsPayload, ]; diff --git a/app/Services/ServerTransfer/ServerTransferImporter.php b/app/Services/ServerTransfer/ServerTransferImporter.php index b341725f9e..371844bcf9 100644 --- a/app/Services/ServerTransfer/ServerTransferImporter.php +++ b/app/Services/ServerTransfer/ServerTransferImporter.php @@ -2,6 +2,7 @@ namespace App\Services\ServerTransfer; +use App\Enums\ServerRole; use App\Models\Application; use App\Models\ApplicationPreview; use App\Models\CloudProviderToken; @@ -451,8 +452,13 @@ class ServerTransferImporter $server->uuid = $uuid; $server->save(); - if ($server->settings && data_get($payload, 'is_build_server')) { - $server->settings->is_build_server = true; + if ($server->settings) { + $serverRole = data_get($payload, 'server_role'); + if (! in_array($serverRole, array_column(ServerRole::cases(), 'value'), true)) { + $serverRole = data_get($payload, 'is_build_server') ? ServerRole::BUILD->value : ServerRole::BOTH->value; + } + $server->settings->server_role = $serverRole; + $server->settings->is_build_server = $serverRole === ServerRole::BUILD->value; $server->settings->save(); } diff --git a/database/migrations/2026_09_19_121840_add_server_role_to_server_settings_table.php b/database/migrations/2026_09_19_121840_add_server_role_to_server_settings_table.php new file mode 100644 index 0000000000..4c982a6a49 --- /dev/null +++ b/database/migrations/2026_09_19_121840_add_server_role_to_server_settings_table.php @@ -0,0 +1,28 @@ +string('server_role')->nullable()->default(ServerRole::BOTH->value)->after('is_build_server'); + }); + + DB::table('server_settings') + ->where('is_build_server', true) + ->update(['server_role' => ServerRole::BUILD->value]); + } + + public function down(): void + { + Schema::table('server_settings', function (Blueprint $table) { + $table->dropColumn('server_role'); + }); + } +}; diff --git a/openapi.json b/openapi.json index 500ffdd58b..f8173b7aa8 100644 --- a/openapi.json +++ b/openapi.json @@ -20782,6 +20782,62 @@ "bearerAuth": [] } ] + }, + "patch": { + "tags": [ + "Teams" + ], + "summary": "Update authenticated team", + "description": "Update settings for the team bound to the API token.", + "operationId": "update-token-team", + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "is_build_server_fallback_enabled" + ], + "properties": { + "is_build_server_fallback_enabled": { + "type": "boolean", + "description": "Whether deployments can fall back to the deployment server when no usable dedicated build server is available." + } + }, + "type": "object" + } + } + } + }, + "responses": { + "200": { + "description": "Updated team.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/Team" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "400": { + "$ref": "#\/components\/responses\/400" + }, + "403": { + "description": "Forbidden." + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] } }, "\/team\/members": { @@ -22874,6 +22930,10 @@ "type": "string", "description": "The custom server limit." }, + "is_build_server_fallback_enabled": { + "type": "boolean", + "description": "Whether deployments can fall back to the deployment server when no usable dedicated build server is available." + }, "members": { "description": "The members of the team.", "type": "array", diff --git a/openapi.yaml b/openapi.yaml index 393aa2e60e..3f4993eae9 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -13186,6 +13186,42 @@ paths: security: - bearerAuth: [] + patch: + tags: + - Teams + summary: 'Update authenticated team' + description: 'Update settings for the team bound to the API token.' + operationId: update-token-team + requestBody: + required: true + content: + application/json: + schema: + required: + - is_build_server_fallback_enabled + properties: + is_build_server_fallback_enabled: + type: boolean + description: 'Whether deployments can fall back to the deployment server when no usable dedicated build server is available.' + type: object + responses: + '200': + description: 'Updated team.' + content: + application/json: + schema: + $ref: '#/components/schemas/Team' + '401': + $ref: '#/components/responses/401' + '400': + $ref: '#/components/responses/400' + '403': + description: Forbidden. + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] /team/members: get: tags: @@ -14673,6 +14709,9 @@ components: custom_server_limit: type: string description: 'The custom server limit.' + is_build_server_fallback_enabled: + type: boolean + description: 'Whether deployments can fall back to the deployment server when no usable dedicated build server is available.' members: description: 'The members of the team.' type: array diff --git a/resources/views/components/server/sidebar.blade.php b/resources/views/components/server/sidebar.blade.php index 4b652bd51b..14fc7751d4 100644 --- a/resources/views/components/server/sidebar.blade.php +++ b/resources/views/components/server/sidebar.blade.php @@ -63,7 +63,7 @@ 'active' => $activeMenu === 'proxy', 'icon' => 'network', 'group' => 'Platform', - 'visible' => ! $server->isSwarmWorker() && ! $server->settings->is_build_server, + 'visible' => ! $server->isSwarmWorker() && $server->canHostResources(), 'warning' => $server->hasCurrentTraefikOutdatedInfo(), 'tracks_proxy_configuration' => true, 'children' => [ @@ -78,7 +78,7 @@ 'active' => request()->routeIs('server.sentinel', 'server.sentinel.*'), 'icon' => 'shield-star', 'group' => 'Platform', - 'visible' => $server->isFunctional() && ! $server->isSwarm() && ! $server->settings->is_build_server && auth()->user()?->can('viewSentinel', $server), + 'visible' => $server->isFunctional() && ! $server->isSwarm() && $server->canHostResources() && auth()->user()?->can('viewSentinel', $server), 'warning' => $server->isSentinelEnabled() && $sentinelStatus === 'out_of_sync', 'tracks_sentinel_status' => true, 'children' => [ diff --git a/resources/views/livewire/server/navbar.blade.php b/resources/views/livewire/server/navbar.blade.php index d9ec926f96..30a2e0ecdd 100644 --- a/resources/views/livewire/server/navbar.blade.php +++ b/resources/views/livewire/server/navbar.blade.php @@ -42,7 +42,7 @@ 'label' => 'Proxy', 'route' => 'server.proxy', 'active' => request()->routeIs('server.proxy', 'server.proxy.*'), - 'visible' => ! $server->isSwarmWorker() && ! $server->settings->is_build_server, + 'visible' => ! $server->isSwarmWorker() && $server->canHostResources(), 'warning' => $this->hasTraefikOutdated || $this->hasPendingProxyConfiguration, ], [ @@ -51,7 +51,7 @@ 'active' => request()->routeIs('server.sentinel', 'server.sentinel.*'), 'visible' => $server->isFunctional() && ! $server->isSwarm() - && ! $server->settings->is_build_server + && $server->canHostResources() && auth()->user()?->can('viewSentinel', $server), 'warning' => $sentinelWarningOverride ?? ($server->isSentinelEnabled() && $server->sentinelStatus() === 'out_of_sync'), ], diff --git a/resources/views/livewire/server/new/by-ip.blade.php b/resources/views/livewire/server/new/by-ip.blade.php index 189c059242..d3110316d3 100644 --- a/resources/views/livewire/server/new/by-ip.blade.php +++ b/resources/views/livewire/server/new/by-ip.blade.php @@ -83,11 +83,12 @@ helper="Non-root SSH users are experimental." /> - diff --git a/resources/views/livewire/server/show.blade.php b/resources/views/livewire/server/show.blade.php index 604041c770..4edbf479a3 100644 --- a/resources/views/livewire/server/show.blade.php +++ b/resources/views/livewire/server/show.blade.php @@ -59,7 +59,7 @@ @endphp
- {{-- isBuildServer uses instantSave; keep dirty tracking on explicit-save fields. --}} + {{-- Server role saves separately; keep dirty tracking on explicit-save fields. --}} @@ -249,7 +249,7 @@ 'value' => $timezone, 'label' => $timezone, ])->all()" :disabled="$isValidating || !auth()->user()->can('update', $server)" /> - @if (!$isSwarmWorker && !$isBuildServer) + @if (!$isSwarmWorker && $serverRole !== 'build') isLocalhost())
- @if ($isBuildServerLocked) - - @else - - @endif +
@endif @@ -286,4 +284,22 @@ @endif + + + + + + + + @script + + @endscript diff --git a/routes/api.php b/routes/api.php index 9834a078d1..36ab66ec57 100644 --- a/routes/api.php +++ b/routes/api.php @@ -68,6 +68,7 @@ Route::group([ Route::get('/teams', [TeamController::class, 'teams'])->middleware(['api.ability:read']); // Token's team Route::get('/team', [TeamController::class, 'current_team'])->middleware(['api.ability:read']); + Route::patch('/team', [TeamController::class, 'update_current_team'])->middleware(['api.ability:write']); Route::get('/team/members', [TeamController::class, 'current_team_members'])->middleware(['api.ability:read']); // Deprecated aliases — same handlers as /team and /team/members (remove in a later release) Route::get('/teams/current', [TeamController::class, 'current_team'])->middleware(['api.ability:read']); diff --git a/tests/Feature/Api/TeamTokenTeamApiTest.php b/tests/Feature/Api/TeamTokenTeamApiTest.php index 447579e294..ca850769ff 100644 --- a/tests/Feature/Api/TeamTokenTeamApiTest.php +++ b/tests/Feature/Api/TeamTokenTeamApiTest.php @@ -56,7 +56,54 @@ describe('token team endpoints', function () { ->getJson('/api/v1/team') ->assertOk() ->assertJsonPath('id', $this->team->id) - ->assertJsonPath('name', 'Token Team'); + ->assertJsonPath('name', 'Token Team') + ->assertJsonPath('is_build_server_fallback_enabled', true); + }); + + test('PATCH /team updates the build server fallback policy', function () { + $this->withHeaders(teamTokenApiHeaders($this->bearerToken)) + ->patchJson('/api/v1/team', [ + 'is_build_server_fallback_enabled' => false, + ]) + ->assertOk() + ->assertJsonPath('is_build_server_fallback_enabled', false); + + expect($this->team->fresh()->is_build_server_fallback_enabled)->toBeFalse(); + }); + + test('PATCH /team validates the build server fallback policy', function () { + $this->withHeaders(teamTokenApiHeaders($this->bearerToken)) + ->patchJson('/api/v1/team', [ + 'is_build_server_fallback_enabled' => 'invalid', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('is_build_server_fallback_enabled'); + + expect($this->team->fresh()->is_build_server_fallback_enabled)->toBeTrue(); + }); + + test('team members cannot update the build server fallback policy', function () { + $this->team->members()->updateExistingPivot($this->user->id, ['role' => 'member']); + + $this->withHeaders(teamTokenApiHeaders($this->bearerToken)) + ->patchJson('/api/v1/team', [ + 'is_build_server_fallback_enabled' => false, + ]) + ->assertForbidden(); + + expect($this->team->fresh()->is_build_server_fallback_enabled)->toBeTrue(); + }); + + test('read-only tokens cannot update the build server fallback policy', function () { + $readOnlyToken = $this->user->createToken('read-only-team-token', ['read'])->plainTextToken; + + $this->withHeaders(teamTokenApiHeaders($readOnlyToken)) + ->patchJson('/api/v1/team', [ + 'is_build_server_fallback_enabled' => false, + ]) + ->assertForbidden(); + + expect($this->team->fresh()->is_build_server_fallback_enabled)->toBeTrue(); }); test('GET /team/members returns members of the token team', function () { diff --git a/tests/Feature/BuildServerFallbackPolicyTest.php b/tests/Feature/BuildServerFallbackPolicyTest.php index 9443a38264..2fd3b2f72a 100644 --- a/tests/Feature/BuildServerFallbackPolicyTest.php +++ b/tests/Feature/BuildServerFallbackPolicyTest.php @@ -95,6 +95,7 @@ test('strict teams reject ineligible dedicated build servers', function (array $ $buildServer->settings()->update(array_merge([ 'is_reachable' => true, 'is_usable' => true, + 'server_role' => 'build', 'is_build_server' => true, 'is_swarm_worker' => false, 'force_disabled' => false, @@ -119,6 +120,7 @@ test('strict teams use an available dedicated build server', function () { $buildServer->settings()->update([ 'is_reachable' => true, 'is_usable' => true, + 'server_role' => 'build', 'is_build_server' => true, 'force_disabled' => false, ]); @@ -133,3 +135,29 @@ test('strict teams use an available dedicated build server', function () { expect(selectedBuildServer($job)->is($buildServer))->toBeTrue(); }); + +test('a combined deployment server builds locally without remote build handling', function () { + $team = Team::factory()->create(['is_build_server_fallback_enabled' => false]); + $deploymentServer = Server::factory()->create(['team_id' => $team->id]); + $deploymentServer->settings()->update([ + 'server_role' => 'both', + 'is_build_server' => false, + 'is_reachable' => true, + 'is_usable' => true, + 'is_swarm_worker' => false, + 'force_disabled' => false, + ]); + [$job, $deploymentQueue] = makeBuildServerSelectionJob($team, $deploymentServer); + + $deploymentQueue->shouldNotReceive('setAttribute'); + $deploymentQueue->shouldReceive('addLogEntry') + ->once() + ->with("Using deployment server ({$deploymentServer->name}) for the build."); + + invokeBuildServerSelection($job); + + $useBuildServer = (new ReflectionProperty(ApplicationDeploymentJob::class, 'use_build_server'))->getValue($job); + + expect(selectedBuildServer($job)->is($deploymentServer))->toBeTrue() + ->and($useBuildServer)->toBeFalse(); +}); diff --git a/tests/Feature/NewServerPrivateKeySelectionTest.php b/tests/Feature/NewServerPrivateKeySelectionTest.php index b1d77266a1..ff850eb3d9 100644 --- a/tests/Feature/NewServerPrivateKeySelectionTest.php +++ b/tests/Feature/NewServerPrivateKeySelectionTest.php @@ -45,7 +45,7 @@ it('generates and preselects a new private key without clearing server form data ->set('ip', '192.0.2.50') ->set('user', 'deploy.user') ->set('port', 2222) - ->set('is_build_server', true) + ->set('server_role', 'build') ->call('generatePrivateKey', 'ed25519') ->assertHasNoErrors() ->assertSet('name', 'Production Server') @@ -53,7 +53,7 @@ it('generates and preselects a new private key without clearing server form data ->assertSet('ip', '192.0.2.50') ->assertSet('user', 'deploy.user') ->assertSet('port', 2222) - ->assertSet('is_build_server', true); + ->assertSet('server_role', 'build'); $newPrivateKeyId = $component->get('private_key_id'); @@ -80,7 +80,7 @@ it('preselects a manually added private key without clearing server form data', ->set('ip', '192.0.2.51') ->set('user', 'deploy.user') ->set('port', 2222) - ->set('is_build_server', true) + ->set('server_role', 'build') ->call('handlePrivateKeyCreated', $manualPrivateKey->id) ->assertSet('private_key_id', $manualPrivateKey->id) ->assertSet('name', 'Production Server') @@ -88,6 +88,6 @@ it('preselects a manually added private key without clearing server form data', ->assertSet('ip', '192.0.2.51') ->assertSet('user', 'deploy.user') ->assertSet('port', 2222) - ->assertSet('is_build_server', true) + ->assertSet('server_role', 'build') ->assertSee('Manual SSH Key'); }); diff --git a/tests/Feature/ResourceHostingServerTest.php b/tests/Feature/ResourceHostingServerTest.php index 21377de485..af64673caa 100644 --- a/tests/Feature/ResourceHostingServerTest.php +++ b/tests/Feature/ResourceHostingServerTest.php @@ -73,36 +73,82 @@ function createResourceHostingTestApplication(object $test): Application test('only eligible deployment servers can host resources', function () { expect($this->server->canHostResources())->toBeTrue(); - $this->server->settings->update(['is_build_server' => true]); + $this->server->settings->update(['server_role' => 'build', 'is_build_server' => true]); expect($this->server->fresh()->canHostResources())->toBeFalse(); }); -test('a populated build server can be changed back to a deployment server', function () { - createResourceHostingTestApplication($this); - $this->server->settings()->update(['is_build_server' => true]); +test('legacy server settings with a null role use the combined role', function () { + $this->actingAs($this->user); + $this->server->settings()->update(['server_role' => null]); + $this->server->refresh()->load('settings'); - Livewire::actingAs($this->user) - ->test(Show::class, ['server_uuid' => $this->server->uuid]) - ->assertSet('isBuildServer', true) - ->assertSet('isBuildServerLocked', false) - ->set('isBuildServer', false) - ->assertHasNoErrors(); - - expect((bool) $this->server->settings->fresh()->is_build_server)->toBeFalse(); + expect($this->server->settings->effectiveServerRole()->value)->toBe('both') + ->and($this->server->canHostResources())->toBeTrue() + ->and(Server::isUsable()->pluck('id'))->toContain($this->server->id) + ->and(Server::isUsableBuildServer()->pluck('id'))->toContain($this->server->id); }); -test('a populated deployment server cannot be changed into a build server', function () { +test('changing from build only to deployments and builds requires confirmation', function () { createResourceHostingTestApplication($this); + $this->server->settings()->update(['server_role' => 'build', 'is_build_server' => true]); Livewire::actingAs($this->user) ->test(Show::class, ['server_uuid' => $this->server->uuid]) - ->assertSet('isBuildServer', false) - ->assertSet('isBuildServerLocked', true) - ->set('isBuildServer', true) - ->assertSet('isBuildServer', false); + ->assertSet('serverRole', 'build') + ->set('serverRole', 'both') + ->call('requestServerRoleChange') + ->assertSet('serverRole', 'build') + ->assertSet('pendingServerRole', 'both') + ->assertDispatched('open-server-role-confirmation') + ->call('confirmServerRoleChange') + ->assertSet('serverRole', 'both'); - expect((bool) $this->server->settings->fresh()->is_build_server)->toBeFalse(); + expect($this->server->settings->fresh()->server_role->value)->toBe('both'); +}); + +test('an empty deployment-only server requires confirmation before the combined role is enabled', function () { + $this->server->settings()->update([ + 'server_role' => 'deployment', + 'is_build_server' => false, + ]); + + Livewire::actingAs($this->user) + ->test(Show::class, ['server_uuid' => $this->server->uuid]) + ->set('serverRole', 'both') + ->call('requestServerRoleChange') + ->assertSet('serverRole', 'deployment') + ->assertSet('pendingServerRole', 'both') + ->assertDispatched('open-server-role-confirmation'); + + expect($this->server->settings->fresh()->server_role->value)->toBe('deployment'); +}); + +test('a populated server requires confirmation before builds are enabled', function () { + createResourceHostingTestApplication($this); + + $component = Livewire::actingAs($this->user) + ->test(Show::class, ['server_uuid' => $this->server->uuid]) + ->assertSet('serverRole', 'both') + ->set('serverRole', 'deployment') + ->call('requestServerRoleChange'); + + $otherBuildServer = Server::factory()->create(['team_id' => $this->team->id]); + $otherBuildServer->settings()->update(['server_role' => 'build', + 'is_build_server' => true, 'is_reachable' => true, 'is_usable' => true]); + + $component + ->set('serverRole', 'deployment') + ->call('requestServerRoleChange') + ->set('serverRole', 'both') + ->call('requestServerRoleChange') + ->assertSet('serverRole', 'deployment') + ->assertSet('pendingServerRole', 'both') + ->assertDispatched('open-server-role-confirmation') + ->call('confirmServerRoleChange') + ->assertSet('serverRole', 'both'); + + expect($this->server->settings->fresh()->server_role->value)->toBe('both'); }); test('resource selection keeps excluded build servers visible for explanation', function () { @@ -111,6 +157,7 @@ test('resource selection keeps excluded build servers visible for explanation', $buildServer->settings()->update([ 'is_reachable' => true, 'is_usable' => true, + 'server_role' => 'build', 'is_build_server' => true, 'is_swarm_worker' => false, 'force_disabled' => false, @@ -122,7 +169,7 @@ test('resource selection keeps excluded build servers visible for explanation', expect($component->servers->pluck('id'))->toContain($this->server->id) ->not->toContain($buildServer->id) ->and(Server::isUsableBuildServer()->pluck('id'))->toContain($buildServer->id) - ->not->toContain($this->server->id) + ->toContain($this->server->id) ->and($component->buildServers->pluck('id'))->toContain($buildServer->id) ->and($component->allServers->pluck('id'))->toContain($this->server->id, $buildServer->id); }); @@ -144,7 +191,7 @@ test('resource selection does not show the empty server message when only build }); test('application API rejects build servers', function () { - $this->server->settings()->update(['is_build_server' => true]); + $this->server->settings()->update(['server_role' => 'build', 'is_build_server' => true]); $this->withHeaders(resourceHostingApiHeaders($this->bearerToken)) ->postJson('/api/v1/applications/dockerimage', [ @@ -163,7 +210,7 @@ test('application API rejects build servers', function () { }); test('database API rejects build servers', function () { - $this->server->settings()->update(['is_build_server' => true]); + $this->server->settings()->update(['server_role' => 'build', 'is_build_server' => true]); $this->withHeaders(resourceHostingApiHeaders($this->bearerToken)) ->postJson('/api/v1/databases/postgresql', [ @@ -177,7 +224,7 @@ test('database API rejects build servers', function () { }); test('service API rejects build servers', function () { - $this->server->settings()->update(['is_build_server' => true]); + $this->server->settings()->update(['server_role' => 'build', 'is_build_server' => true]); $this->withHeaders(resourceHostingApiHeaders($this->bearerToken)) ->postJson('/api/v1/services', [ @@ -209,7 +256,7 @@ test('server API rejects enabling build mode when resources exist', function () test('server API allows keeping build mode enabled when resources exist', function () { createResourceHostingTestApplication($this); - $this->server->settings()->update(['is_build_server' => true]); + $this->server->settings()->update(['server_role' => 'build', 'is_build_server' => true]); $this->withHeaders(resourceHostingApiHeaders($this->bearerToken)) ->patchJson('/api/v1/servers/'.$this->server->uuid, [ @@ -222,7 +269,7 @@ test('server API allows keeping build mode enabled when resources exist', functi test('server API allows disabling build mode when resources exist', function () { createResourceHostingTestApplication($this); - $this->server->settings()->update(['is_build_server' => true]); + $this->server->settings()->update(['server_role' => 'build', 'is_build_server' => true]); $this->withHeaders(resourceHostingApiHeaders($this->bearerToken)) ->patchJson('/api/v1/servers/'.$this->server->uuid, [ @@ -272,7 +319,7 @@ test('resource APIs still accept deployment servers', function () { test('a crafted web request cannot create a resource on a build server', function () { $this->actingAs($this->user); - $this->server->settings()->update(['is_build_server' => true]); + $this->server->settings()->update(['server_role' => 'build', 'is_build_server' => true]); $url = route('project.resource.create', [ 'project_uuid' => $this->project->uuid, @@ -286,7 +333,7 @@ test('a crafted web request cannot create a resource on a build server', functio test('a manipulated resource form cannot submit to a build server', function () { $this->actingAs($this->user); - $this->server->settings()->update(['is_build_server' => true]); + $this->server->settings()->update(['server_role' => 'build', 'is_build_server' => true]); $routeParameters = [ 'project_uuid' => $this->project->uuid, 'environment_uuid' => $this->environment->uuid, @@ -303,7 +350,7 @@ test('a manipulated resource form cannot submit to a build server', function () test('a manipulated project clone cannot target a build server', function () { $this->actingAs($this->user); - $this->server->settings()->update(['is_build_server' => true]); + $this->server->settings()->update(['server_role' => 'build', 'is_build_server' => true]); $projectCount = Project::count(); Livewire::test(CloneMe::class, [ @@ -391,6 +438,7 @@ test('a manipulated clone request cannot target a build server', function () { $buildServer->settings()->update([ 'is_reachable' => true, 'is_usable' => true, + 'server_role' => 'build', 'is_build_server' => true, ]); $buildDestination = StandaloneDocker::where('server_id', $buildServer->id)->firstOrFail(); @@ -430,6 +478,7 @@ test('resource operations explains why build servers cannot be clone targets', f $buildServer->settings()->update([ 'is_reachable' => true, 'is_usable' => true, + 'server_role' => 'build', 'is_build_server' => true, ]); diff --git a/tests/Feature/ServerBuildRoleHelperTest.php b/tests/Feature/ServerBuildRoleHelperTest.php index 3cb85e608d..89fdeafd94 100644 --- a/tests/Feature/ServerBuildRoleHelperTest.php +++ b/tests/Feature/ServerBuildRoleHelperTest.php @@ -1,9 +1,24 @@ toContain('label="Use as a dedicated build server"') - ->toContain('helper="Build servers compile applications but do not host deployments. Enabling this makes the server build-only."'); + ->toContain('id="serverRole"') + ->toContain("['value' => 'deployment', 'label' => 'Deployments only']") + ->toContain("['value' => 'build', 'label' => 'Builds only']") + ->toContain("['value' => 'both', 'label' => 'Deployments and builds']") + ->toContain('not->toContain('toContain('Use this server for deployments and builds?') + ->toContain('can become slow or unreachable'); +}); + +test('server role migration keeps dedicated build servers and defaults other servers to both', function () { + $migration = file_get_contents(base_path('database/migrations/2026_09_19_121840_add_server_role_to_server_settings_table.php')); + + expect($migration) + ->toContain('->nullable()->default(ServerRole::BOTH->value)') + ->toContain("->where('is_build_server', true)") + ->toContain("->update(['server_role' => ServerRole::BUILD->value])"); }); diff --git a/tests/Feature/ServerCreationBuildRoleLayoutTest.php b/tests/Feature/ServerCreationBuildRoleLayoutTest.php index 3095d6b2ea..2f813196e6 100644 --- a/tests/Feature/ServerCreationBuildRoleLayoutTest.php +++ b/tests/Feature/ServerCreationBuildRoleLayoutTest.php @@ -6,12 +6,12 @@ test('server creation keeps private key actions together and advanced options co expect($view) ->toContain('class="flex items-end gap-3"') ->toContain('toContain('toContain('label="Use as a dedicated build server"') - ->toContain("['value' => false, 'label' => 'No']") - ->toContain("['value' => true, 'label' => 'Yes']") - ->not->toContain('toContain('helper="Build servers compile applications but do not host deployments. Enabling this makes the server build-only."'); + ->toContain('toContain('label="Server role"') + ->toContain("['value' => 'deployment', 'label' => 'Deployments only']") + ->toContain("['value' => 'build', 'label' => 'Builds only']") + ->toContain("['value' => 'both', 'label' => 'Deployments and builds']") + ->not->toContain('settings()->update([ 'is_reachable' => true, 'is_usable' => true, + 'server_role' => 'build', 'is_build_server' => true, ]); @@ -47,6 +48,7 @@ test('server destinations page renders when selected server has no destinations' $server->settings()->update([ 'is_reachable' => true, 'is_usable' => true, + 'server_role' => 'build', 'is_build_server' => true, ]); diff --git a/tests/Feature/ServerUpdatePrivateKeyApiTest.php b/tests/Feature/ServerUpdatePrivateKeyApiTest.php index 8bc5f1d0e5..2b67e7b83d 100644 --- a/tests/Feature/ServerUpdatePrivateKeyApiTest.php +++ b/tests/Feature/ServerUpdatePrivateKeyApiTest.php @@ -51,6 +51,14 @@ function patchServerUpdatePrivateKeyApi(object $test, Server $server, string $be ])->patchJson('/api/v1/servers/'.$server->uuid, $payload); } +function postServerApi(object $test, string $bearerToken, array $payload): TestResponse +{ + return $test->withHeaders([ + 'Authorization' => 'Bearer '.$bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/servers', $payload); +} + it('updates the server private key from private_key_uuid', function () { patchServerUpdatePrivateKeyApi($this, $this->server, $this->bearerToken, [ 'private_key_uuid' => $this->newPrivateKey->uuid, @@ -93,25 +101,88 @@ it('keeps the existing private key when private_key_uuid is omitted', function ( ->and($server->private_key_id)->toBe($this->oldPrivateKey->id); }); -it('can disable build server mode via API', function () { - $this->server->settings()->update(['is_build_server' => true]); +it('can change a build-only server to the combined role via API', function () { + $this->server->settings()->update(['server_role' => 'build']); patchServerUpdatePrivateKeyApi($this, $this->server, $this->bearerToken, [ - 'is_build_server' => false, + 'server_role' => 'both', ])->assertCreated() ->assertJson(['uuid' => $this->server->uuid]); - expect($this->server->settings->fresh()->is_build_server)->toBeFalse(); + $settings = $this->server->settings->fresh(); + + expect($settings->server_role->value)->toBe('both'); +}); + +it('updates the server role through the API', function (string $role) { + patchServerUpdatePrivateKeyApi($this, $this->server, $this->bearerToken, [ + 'server_role' => $role, + ])->assertCreated(); + + $settings = $this->server->settings->fresh(); + + expect($settings->server_role->value)->toBe($role); +})->with([ + 'build only' => ['build'], + 'deployment and build' => ['both'], +]); + +it('rejects the legacy build server API field', function () { + patchServerUpdatePrivateKeyApi($this, $this->server, $this->bearerToken, [ + 'is_build_server' => true, + ])->assertUnprocessable() + ->assertJsonValidationErrors('is_build_server'); +}); + +it('requires another build-capable server before selecting deployment only', function () { + patchServerUpdatePrivateKeyApi($this, $this->server, $this->bearerToken, [ + 'server_role' => 'deployment', + ])->assertUnprocessable() + ->assertJsonValidationErrors('server_role'); + + $buildServer = Server::factory()->create(['team_id' => $this->team->id]); + $buildServer->settings()->update([ + 'is_reachable' => true, + 'is_usable' => true, + 'server_role' => 'build', + 'is_build_server' => true, + ]); + + patchServerUpdatePrivateKeyApi($this, $this->server, $this->bearerToken, [ + 'server_role' => 'deployment', + ])->assertCreated(); + + expect($this->server->settings->fresh()->server_role->value)->toBe('deployment'); +}); + +it('creates a server with an API server role', function () { + $response = postServerApi($this, $this->bearerToken, [ + 'name' => 'API Build Server', + 'ip' => '192.0.2.55', + 'private_key_uuid' => $this->oldPrivateKey->uuid, + 'server_role' => 'build', + ])->assertCreated(); + + $server = Server::whereUuid($response->json('uuid'))->firstOrFail(); + + expect($server->settings->server_role->value)->toBe('build'); +}); + +it('rejects an invalid API server role', function () { + patchServerUpdatePrivateKeyApi($this, $this->server, $this->bearerToken, [ + 'server_role' => 'invalid', + ])->assertUnprocessable() + ->assertJsonValidationErrors('server_role'); }); it('rejects an invalid disk usage check frequency without partially updating the server', function () { $this->server->proxy->set('type', 'TRAEFIK'); $this->server->save(); - $this->server->settings()->update(['is_build_server' => false]); + $this->server->settings()->update(['server_role' => 'both']); patchServerUpdatePrivateKeyApi($this, $this->server, $this->bearerToken, [ 'name' => 'Renamed Server', - 'is_build_server' => true, + 'server_role' => 'build', 'proxy_type' => 'none', 'server_disk_usage_check_frequency' => 'not a valid schedule', ])->assertUnprocessable() @@ -125,6 +196,6 @@ it('rejects an invalid disk usage check frequency without partially updating the $server = $this->server->fresh(); expect($server->name)->not->toBe('Renamed Server') - ->and($server->settings->is_build_server)->toBeFalse() + ->and($server->settings->server_role->value)->toBe('both') ->and($server->proxy->get('type'))->toBe('TRAEFIK'); });