From af75492c835915ab64e6837fca0c2849c0e82af0 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 19 Sep 2026 14:16:30 +0200 Subject: [PATCH] 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. --- app/Jobs/ApplicationDeploymentJob.php | 44 ++++-- app/Livewire/Team/Index.php | 5 + app/Models/Server.php | 7 +- app/Models/Team.php | 3 + ...server_fallback_enabled_to_teams_table.php | 28 ++++ .../project/application/general.blade.php | 5 +- resources/views/livewire/team/index.blade.php | 9 ++ .../Authorization/TeamAuthorizationTest.php | 12 ++ .../Feature/BuildServerFallbackPolicyTest.php | 135 ++++++++++++++++++ 9 files changed, 231 insertions(+), 17 deletions(-) create mode 100644 database/migrations/2026_09_18_165916_add_is_build_server_fallback_enabled_to_teams_table.php create mode 100644 tests/Feature/BuildServerFallbackPolicyTest.php diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 99ff1ac874..7ef4774f8c 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -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; diff --git a/app/Livewire/Team/Index.php b/app/Livewire/Team/Index.php index abec26dc36..9ae34b5610 100644 --- a/app/Livewire/Team/Index.php +++ b/app/Livewire/Team/Index.php @@ -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); } } diff --git a/app/Models/Server.php b/app/Models/Server.php index 6db5368da9..dfca0593e4 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -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() diff --git a/app/Models/Team.php b/app/Models/Team.php index 4cf6391231..2478028798 100644 --- a/app/Models/Team.php +++ b/app/Models/Team.php @@ -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() diff --git a/database/migrations/2026_09_18_165916_add_is_build_server_fallback_enabled_to_teams_table.php b/database/migrations/2026_09_18_165916_add_is_build_server_fallback_enabled_to_teams_table.php new file mode 100644 index 0000000000..4b103d4263 --- /dev/null +++ b/database/migrations/2026_09_18_165916_add_is_build_server_fallback_enabled_to_teams_table.php @@ -0,0 +1,28 @@ +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'); + }); + } +}; diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index 65d806fca9..6f7323ce41 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -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
@endif diff --git a/resources/views/livewire/team/index.blade.php b/resources/views/livewire/team/index.blade.php index d30df3411d..c1b7cc2c3f 100644 --- a/resources/views/livewire/team/index.blade.php +++ b/resources/views/livewire/team/index.blade.php @@ -25,6 +25,15 @@ ['value' => true, 'label' => 'Enabled for this team'], ]" /> +
+ +
diff --git a/tests/Feature/Authorization/TeamAuthorizationTest.php b/tests/Feature/Authorization/TeamAuthorizationTest.php index eaae106ef3..0637a48db5 100644 --- a/tests/Feature/Authorization/TeamAuthorizationTest.php +++ b/tests/Feature/Authorization/TeamAuthorizationTest.php @@ -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); diff --git a/tests/Feature/BuildServerFallbackPolicyTest.php b/tests/Feature/BuildServerFallbackPolicyTest.php new file mode 100644 index 0000000000..9443a38264 --- /dev/null +++ b/tests/Feature/BuildServerFallbackPolicyTest.php @@ -0,0 +1,135 @@ + 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(); +});