mirror of
https://github.com/coollabsio/coolify.git
synced 2026-09-26 01:10:30 -04:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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']);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user