feat(deployments): add team policy for build server fallback

Allow teams to choose whether deployments fall back to the deployment server when no usable dedicated build server is available.
This commit is contained in:
Andras Bacsai
2026-09-19 14:16:30 +02:00
parent 7b84e8722c
commit af75492c83
9 changed files with 231 additions and 17 deletions
+29 -15
View File
@@ -361,21 +361,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
// Check custom port
['repository' => $this->customRepository, 'port' => $this->customPort] = $this->application->customRepository();
if (data_get($this->application, 'settings.is_build_server_enabled')) {
$teamId = data_get($this->application, 'environment.project.team.id');
$buildServers = Server::buildServers($teamId)->get();
if ($buildServers->count() === 0) {
$this->application_deployment_queue->addLogEntry('No suitable build server found. Using the deployment server.');
$this->build_server = $this->server;
} else {
$this->build_server = $buildServers->random();
$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;
}
} else {
$this->build_server = $this->server;
}
$this->selectBuildServer();
$this->detectBuildKitCapabilities();
$this->decide_what_to_do();
} catch (Exception $e) {
@@ -424,6 +410,34 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
}
private function selectBuildServer(): void
{
if (! data_get($this->application, 'settings.is_build_server_enabled')) {
$this->build_server = $this->server;
return;
}
$team = $this->application->environment->project->team;
$buildServers = Server::buildServers($team->id)->get();
if ($buildServers->isEmpty()) {
if (! $team->is_build_server_fallback_enabled) {
throw new DeploymentException('No available dedicated build server was found. Enable a usable build server for this team or allow fallback to the deployment server in the team settings.');
}
$this->application_deployment_queue->addLogEntry('No suitable build server found. Using the deployment server.');
$this->build_server = $this->server;
return;
}
$this->build_server = $buildServers->random();
$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;
}
private function detectBuildKitCapabilities(): void
{
$this->dockerBuildkitSupported = false;
+5
View File
@@ -23,12 +23,15 @@ class Index extends Component
public bool $is_mcp_server_enabled = true;
public bool $is_build_server_fallback_enabled = true;
protected function rules(): array
{
return [
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'is_mcp_server_enabled' => 'boolean',
'is_build_server_fallback_enabled' => 'boolean',
];
}
@@ -59,6 +62,7 @@ class Index extends Component
$this->team->name = $this->name;
$this->team->description = $this->description;
$this->team->is_mcp_server_enabled = $this->is_mcp_server_enabled;
$this->team->is_build_server_fallback_enabled = $this->is_build_server_fallback_enabled;
} else {
// Sync FROM model (on load/refresh)
$this->name = $this->team->name;
@@ -66,6 +70,7 @@ class Index extends Component
// Null can appear after Team::create() when the DB default is not
// hydrated onto the in-memory model stored in session.
$this->is_mcp_server_enabled = (bool) ($this->team->is_mcp_server_enabled ?? true);
$this->is_build_server_fallback_enabled = (bool) ($this->team->is_build_server_fallback_enabled ?? true);
}
}
+6 -1
View File
@@ -903,7 +903,12 @@ $siteAddress {
public static function buildServers($teamId)
{
return Server::whereTeamId($teamId)->whereRelation('settings', 'is_reachable', true)->whereRelation('settings', 'is_build_server', true);
return Server::whereTeamId($teamId)
->whereRelation('settings', 'is_reachable', true)
->whereRelation('settings', 'is_usable', true)
->whereRelation('settings', 'is_swarm_worker', false)
->whereRelation('settings', 'is_build_server', true)
->whereRelation('settings', 'force_disabled', false);
}
public function isForceDisabled()
+3
View File
@@ -48,15 +48,18 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
'show_boarding',
'custom_server_limit',
'is_mcp_server_enabled',
'is_build_server_fallback_enabled',
];
protected $attributes = [
'is_mcp_server_enabled' => true,
'is_build_server_fallback_enabled' => true,
];
protected $casts = [
'personal_team' => 'boolean',
'is_mcp_server_enabled' => 'boolean',
'is_build_server_fallback_enabled' => 'boolean',
];
protected static function booted()
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('teams', function (Blueprint $table) {
$table->boolean('is_build_server_fallback_enabled')->default(true);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('teams', function (Blueprint $table) {
$table->dropColumn('is_build_server_fallback_enabled');
});
}
};
@@ -287,11 +287,14 @@
? ['value' => true, 'label' => 'Available build server (auto-select)']
: ['value' => true, 'label' => 'No build servers connected', 'disabled' => true],
];
$buildServerFallbackPolicy = $application->environment->project->team->is_build_server_fallback_enabled
? 'If no usable build server is available, Coolify builds on the deployment server.'
: 'If no usable build server is available, the deployment fails.';
@endphp
<div class="grid gap-4 pt-2 sm:grid-cols-2">
<x-forms.listbox id="isBuildServerEnabled" label="Builder selection"
onChange="instantSave" :options="$buildServerOptions"
helper="Build your application on a dedicated build server. If several build servers are connected, Coolify picks an available one automatically. More info in the <a href='https://coolify.io/docs/knowledge-base/server/build-server' class='underline' target='_blank'>documentation</a>."
helper="Build your application on a dedicated build server. If several build servers are connected, Coolify picks an available one automatically. {{ $buildServerFallbackPolicy }} More info in the <a href='https://coolify.io/docs/knowledge-base/server/build-server' class='underline' target='_blank'>documentation</a>."
x-bind:disabled="!canUpdate" />
</div>
@endif
@@ -25,6 +25,15 @@
['value' => true, 'label' => 'Enabled for this team'],
]" />
</div>
<div class="lg:col-span-2">
<x-forms.listbox canGate="update" :canResource="$team"
id="is_build_server_fallback_enabled" label="Build server fallback"
helper="Controls what happens when an application is configured to use a dedicated build server, but no usable build server is available."
:disabled="! auth()->user()->can('update', $team)" :options="[
['value' => true, 'label' => 'Build on the deployment server'],
['value' => false, 'label' => 'Fail the deployment'],
]" />
</div>
</div>
</x-application.settings-section>
</form>
@@ -142,6 +142,18 @@ test('owner can update team MCP setting', function () {
expect($this->team->fresh()->is_mcp_server_enabled)->toBeFalse();
});
test('owner can update team build server fallback policy', function () {
$this->actingAs($this->owner);
session(['currentTeam' => $this->team]);
Livewire::test(TeamIndex::class)
->set('is_build_server_fallback_enabled', false)
->call('submit')
->assertDispatched('success');
expect($this->team->fresh()->is_build_server_fallback_enabled)->toBeFalse();
});
test('team index mounts when is_mcp_server_enabled is null on the session team', function () {
$this->actingAs($this->owner);
@@ -0,0 +1,135 @@
<?php
use App\Exceptions\DeploymentException;
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\ApplicationSetting;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
Server::flushIdentityMap();
});
afterEach(function () {
Server::flushIdentityMap();
});
function makeBuildServerSelectionJob(Team $team, Server $deploymentServer): array
{
$project = new Project;
$project->setRelation('team', $team);
$environment = new Environment;
$environment->setRelation('project', $project);
$settings = new ApplicationSetting;
$settings->is_build_server_enabled = true;
$application = new Application;
$application->setRelation('environment', $environment);
$application->setRelation('settings', $settings);
$deploymentQueue = Mockery::mock(ApplicationDeploymentQueue::class);
$job = (new ReflectionClass(ApplicationDeploymentJob::class))->newInstanceWithoutConstructor();
foreach ([
'application' => $application,
'application_deployment_queue' => $deploymentQueue,
'server' => $deploymentServer,
] as $property => $value) {
(new ReflectionProperty(ApplicationDeploymentJob::class, $property))->setValue($job, $value);
}
return [$job, $deploymentQueue];
}
function invokeBuildServerSelection(ApplicationDeploymentJob $job): void
{
(new ReflectionMethod(ApplicationDeploymentJob::class, 'selectBuildServer'))->invoke($job);
}
function selectedBuildServer(ApplicationDeploymentJob $job): Server
{
return (new ReflectionProperty(ApplicationDeploymentJob::class, 'build_server'))->getValue($job);
}
test('teams allow deployment server fallback by default', function () {
$team = Team::factory()->create();
$deploymentServer = Server::factory()->create(['team_id' => $team->id]);
[$job, $deploymentQueue] = makeBuildServerSelectionJob($team, $deploymentServer);
$deploymentQueue->shouldReceive('addLogEntry')
->once()
->with('No suitable build server found. Using the deployment server.');
invokeBuildServerSelection($job);
expect($team->fresh()->is_build_server_fallback_enabled)->toBeTrue()
->and(selectedBuildServer($job)->is($deploymentServer))->toBeTrue();
});
test('strict teams fail when no dedicated build server is available', function () {
$team = Team::factory()->create(['is_build_server_fallback_enabled' => false]);
$deploymentServer = Server::factory()->create(['team_id' => $team->id]);
[$job, $deploymentQueue] = makeBuildServerSelectionJob($team, $deploymentServer);
$deploymentQueue->shouldNotReceive('addLogEntry');
expect(fn () => invokeBuildServerSelection($job))
->toThrow(DeploymentException::class, 'No available dedicated build server was found.');
});
test('strict teams reject ineligible dedicated build servers', function (array $settings) {
$team = Team::factory()->create(['is_build_server_fallback_enabled' => false]);
$deploymentServer = Server::factory()->create(['team_id' => $team->id]);
$buildServer = Server::factory()->create(['team_id' => $team->id]);
$buildServer->settings()->update(array_merge([
'is_reachable' => true,
'is_usable' => true,
'is_build_server' => true,
'is_swarm_worker' => false,
'force_disabled' => false,
], $settings));
[$job, $deploymentQueue] = makeBuildServerSelectionJob($team, $deploymentServer);
$deploymentQueue->shouldNotReceive('addLogEntry');
expect(fn () => invokeBuildServerSelection($job))
->toThrow(DeploymentException::class, 'No available dedicated build server was found.');
})->with([
'unreachable' => [['is_reachable' => false]],
'unusable' => [['is_usable' => false]],
'force-disabled' => [['force_disabled' => true]],
'swarm worker' => [['is_swarm_worker' => true]],
]);
test('strict teams use an available dedicated build server', function () {
$team = Team::factory()->create(['is_build_server_fallback_enabled' => false]);
$deploymentServer = Server::factory()->create(['team_id' => $team->id]);
$buildServer = Server::factory()->create(['team_id' => $team->id]);
$buildServer->settings()->update([
'is_reachable' => true,
'is_usable' => true,
'is_build_server' => true,
'force_disabled' => false,
]);
[$job, $deploymentQueue] = makeBuildServerSelectionJob($team, $deploymentServer);
$deploymentQueue->shouldReceive('setAttribute')->with('build_server_id', $buildServer->id)->once()->andReturnSelf();
$deploymentQueue->shouldReceive('addLogEntry')
->once()
->with("Found a suitable build server ({$buildServer->name}).");
invokeBuildServerSelection($job);
expect(selectedBuildServer($job)->is($buildServer))->toBeTrue();
});