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.
This commit is contained in:
Andras Bacsai
2026-09-19 17:22:13 +02:00
parent af75492c83
commit 383a5a742f
28 changed files with 651 additions and 126 deletions
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Enums;
enum ServerRole: string
{
case DEPLOYMENT = 'deployment';
case BUILD = 'build';
case BOTH = 'both';
public function canBuild(): bool
{
return $this !== self::DEPLOYMENT;
}
public function canDeploy(): bool
{
return $this !== self::BUILD;
}
}
+34 -14
View File
@@ -6,6 +6,7 @@ use App\Actions\Server\DeleteServer;
use App\Actions\Server\ValidateServer;
use App\Enums\ProxyStatus;
use App\Enums\ProxyTypes;
use App\Enums\ServerRole;
use App\Http\Controllers\Controller;
use App\Jobs\DeleteResourceJob;
use App\Jobs\ValidateAndInstallServerJob;
@@ -439,7 +440,7 @@ class ServersController extends Controller
'port' => ['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,
]);
}
@@ -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.',
+6
View File
@@ -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;
+3 -3
View File
@@ -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;
}
+8 -6
View File
@@ -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]);
+58 -22
View File
@@ -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 {
+22 -8
View File
@@ -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)
+10 -1
View File
@@ -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.
+1
View File
@@ -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',
@@ -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,
];
@@ -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();
}
@@ -0,0 +1,28 @@
<?php
use App\Enums\ServerRole;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('server_settings', function (Blueprint $table) {
$table->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');
});
}
};
+60
View File
@@ -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",
+39
View File
@@ -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
@@ -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' => [
@@ -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'),
],
@@ -83,11 +83,12 @@
helper="Non-root SSH users are experimental." />
<x-forms.input type="number" id="port" label="Port" required />
</div>
<x-forms.listbox id="is_build_server"
helper="Build servers compile applications but do not host deployments. Enabling this makes the server build-only."
label="Use as a dedicated build server" :options="[
['value' => false, 'label' => 'No'],
['value' => true, 'label' => 'Yes'],
<x-forms.listbox id="server_role"
helper="Choose whether this server runs deployments, application builds, or both."
label="Server role" :options="[
['value' => 'deployment', 'label' => 'Deployments only'],
['value' => 'build', 'label' => 'Builds only'],
['value' => 'both', 'label' => 'Deployments and builds'],
]" />
</x-forms.collapsible>
</x-application.settings-section>
+28 -12
View File
@@ -59,7 +59,7 @@
@endphp
<form wire:submit.prevent="submit" class="application-settings-form flex flex-col gap-6">
{{-- isBuildServer uses instantSave; keep dirty tracking on explicit-save fields. --}}
{{-- Server role saves separately; keep dirty tracking on explicit-save fields. --}}
<x-unsaved-bar action="submit"
targets="name,description,ip,user,port,connectionTimeout,serverTimezone,wildcardDomain" />
@@ -249,7 +249,7 @@
'value' => $timezone,
'label' => $timezone,
])->all()" :disabled="$isValidating || !auth()->user()->can('update', $server)" />
@if (!$isSwarmWorker && !$isBuildServer)
@if (!$isSwarmWorker && $serverRole !== 'build')
<x-forms.input canGate="update" :canResource="$server"
placeholder="https://example.com" id="wildcardDomain" label="Wildcard domain"
helper="New resources can receive generated subdomains from this domain."
@@ -259,16 +259,14 @@
@if (!$server->isLocalhost())
<div class="mt-4 border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
@if ($isBuildServerLocked)
<x-forms.checkbox disabled id="isBuildServer"
helper="This server already hosts resources and cannot become build-only."
label="Use as a dedicated build server" />
@else
<x-forms.checkbox canGate="update" :canResource="$server" instantSave
id="isBuildServer" label="Use as a dedicated build server"
helper="Build servers compile applications but do not host deployments. Enabling this makes the server build-only."
:disabled="$isValidating" />
@endif
<x-forms.listbox canGate="update" :canResource="$server" id="serverRole"
label="Server role" onChange="requestServerRoleChange"
helper="Builds can use large amounts of CPU and memory. Deployments on the same server can become slow or unreachable during a build."
:disabled="$isValidating" :options="[
['value' => 'deployment', 'label' => 'Deployments only'],
['value' => 'build', 'label' => 'Builds only'],
['value' => 'both', 'label' => 'Deployments and builds'],
]" />
</div>
@endif
</x-application.settings-section>
@@ -286,4 +284,22 @@
@endif
</div>
</div>
<x-modal-confirmation title="Use this server for deployments and builds?"
submitAction="confirmServerRoleChange" :confirmWithText="false" :confirmWithPassword="false"
step2ButtonText="Enable deployments and builds"
warningMessage="Builds can use a large amount of CPU and memory. During a build, deployed resources on this server can become slow or unreachable."
:actions="['Enable builds on this deployment server.']">
<x-slot:trigger>
<button id="server-role-confirmation-trigger" type="button" class="hidden" aria-hidden="true"></button>
</x-slot:trigger>
</x-modal-confirmation>
@script
<script>
$wire.on('open-server-role-confirmation', () => {
document.getElementById('server-role-confirmation-trigger')?.click();
});
</script>
@endscript
</div>
+1
View File
@@ -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']);
+48 -1
View File
@@ -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 () {
@@ -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();
});
@@ -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');
});
+76 -27
View File
@@ -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,
]);
+18 -3
View File
@@ -1,9 +1,24 @@
<?php
test('server settings explain dedicated build server mode', function () {
test('server settings show all server roles and the build risk confirmation', function () {
$view = file_get_contents(resource_path('views/livewire/server/show.blade.php'));
expect($view)
->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('<x-modal-confirmation title="Use this server for deployments and builds?"')
->not->toContain('<x-modal modalId="server-role-confirmation"')
->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])");
});
@@ -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('<x-forms.collapsible class="mt-5 border-t border-neutral-200 pt-4 dark:border-white/[0.08]"')
->toContain('<x-forms.listbox id="is_build_server"')
->toContain('label="Use as a dedicated build server"')
->toContain("['value' => false, 'label' => 'No']")
->toContain("['value' => true, 'label' => 'Yes']")
->not->toContain('<x-forms.checkbox id="is_build_server"')
->toContain('helper="Build servers compile applications but do not host deployments. Enabling this makes the server build-only."');
->toContain('<x-forms.listbox id="server_role"')
->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('<x-forms.checkbox id="is_build_server"');
});
test('server creation places the IP address and private key before optional details', function () {
@@ -32,6 +32,7 @@ test('destination creation modal can mount with selected team server even when g
$server->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,
]);
@@ -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');
});