From 60bac941ea6be67935cac7a8ca359f91369aea1b Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:54:00 +0200 Subject: [PATCH] fix(servers): build only on dedicated build servers The server role migration gives every normal server the combined role, and the build server queries counted combined servers as build servers. "Use a build server" then built on a random production server, the resource picker listed each server twice, and "Deployments only" did not stop builds. - Build server selection and the picker use only "Builds only" servers. A null role falls back to the legacy is_build_server flag. - Without a dedicated build server, builds fall back to the deployment server, never to another combined server. - A "Deployments only" server always builds on a build server and needs a Docker image name. It never builds itself, except for restarts. Docker image and Compose applications are not affected. - Setting "Deployments only" requires a dedicated build server. - The API keeps is_build_server in sync with the role for downgrades. Co-Authored-By: Claude Opus 5.5 --- .ai/lessons.md | 3 + .../Controllers/Api/ServersController.php | 6 +- app/Jobs/ApplicationDeploymentJob.php | 26 ++- app/Livewire/Server/Show.php | 2 +- app/Models/Server.php | 40 ++-- .../project/application/general.blade.php | 3 +- .../Feature/ApplicationGeneralLayoutTest.php | 13 ++ .../Feature/BuildServerFallbackPolicyTest.php | 174 ++++++++++++++++-- tests/Feature/ResourceHostingServerTest.php | 51 ++++- .../Feature/ServerUpdatePrivateKeyApiTest.php | 32 +++- 10 files changed, 303 insertions(+), 47 deletions(-) diff --git a/.ai/lessons.md b/.ai/lessons.md index 8b620a0887..cd37f876f5 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -61,3 +61,6 @@ ## Test the real runtime image - Deployment shell commands run in the Alpine/BusyBox helper image and pass through the non-root sudo parser. Verify new flags and shell syntax in that image and with `parseCommandsByLineForSudo()`; faked command output hides both failures. + +## Format only your own files +- `pint --dirty` also rewrites uncommitted files that belong to other work in the tree. When the tree has unrelated changes, pass your changed paths to Pint. diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index 67f9c6932f..76e36fe873 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -540,7 +540,7 @@ class ServersController extends Controller 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.']], + 'errors' => ['server_role' => ['Add a usable build server before you set this server to deployments only.']], ], 422); } if (is_null($request->instant_validate)) { @@ -584,6 +584,7 @@ class ServersController extends Controller $server->settings()->update([ 'server_role' => $serverRole, + 'is_build_server' => $serverRole === ServerRole::BUILD, ]); if ($request->instant_validate) { ValidateServer::dispatch($server); @@ -763,7 +764,7 @@ class ServersController extends Controller 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.']], + 'errors' => ['server_role' => ['Add a usable build server before you set this server to deployments only.']], ], 422); } @@ -771,6 +772,7 @@ class ServersController extends Controller if ($serverRole !== null) { $server->settings()->update([ 'server_role' => $serverRole, + 'is_build_server' => $serverRole === ServerRole::BUILD, ]); } diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index e22dce607c..fb70c9c9cc 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -424,33 +424,39 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private function selectBuildServer(): void { - if (! data_get($this->application, 'settings.is_build_server_enabled')) { - $this->build_server = $this->server; + $this->build_server = $this->server; + // A deployments-only server never builds. Docker image and Compose applications are exempt: + // the first builds nothing and the second does not support build servers. + $mustBuildElsewhere = ! $this->server->canBuildApplications() + && ! in_array($this->application->build_pack, ['dockerimage', 'dockercompose'], true); + + if (! $mustBuildElsewhere && ! data_get($this->application, 'settings.is_build_server_enabled')) { return; } + if ($mustBuildElsewhere && ! $this->restart_only && str($this->application->docker_registry_image_name)->isEmpty()) { + throw new DeploymentException("The deployment server ({$this->server->name}) is set to deployments only, so this application is built on a build server. Set a Docker image name in the application's General settings so the deployment server can pull the built image."); + } + $team = $this->application->environment->project->team; - $buildServers = Server::buildServers($team->id)->get(); + $buildServers = Server::buildServers($team->id)->whereKeyNot($this->server->id)->get(); if ($buildServers->isEmpty()) { + // A restart only rebuilds when the image is missing, so it may still run on the deployment server. + if ($mustBuildElsewhere && ! $this->restart_only) { + throw new DeploymentException("The deployment server ({$this->server->name}) is set to deployments only, and no usable build server was found. Add a build server or change the server role."); + } 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(); - if ($this->build_server->is($this->server)) { - $this->application_deployment_queue->addLogEntry("Using deployment server ({$this->server->name}) for the build."); - - return; - } - $this->application_deployment_queue->build_server_id = $this->build_server->id; $this->application_deployment_queue->addLogEntry("Found a suitable build server ({$this->build_server->name})."); $this->use_build_server = true; diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index cd435b56b6..a420de5b85 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -425,7 +425,7 @@ class Show extends Component 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.'); + $this->dispatch('error', 'Add a usable build server before you set this server to deployments only.'); return; } diff --git a/app/Models/Server.php b/app/Models/Server.php index 20952d5598..3cc0ea3bbf 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -530,12 +530,27 @@ class Server extends BaseModel ->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')); + ? self::whereServerRole($query, ServerRole::BUILD) + : self::whereServerRole($query, ServerRole::DEPLOYMENT, ServerRole::BOTH); + } + + /** + * Filters by the effective server role. A null role falls back to the legacy + * is_build_server flag, like ServerSetting::effectiveServerRole(). + */ + private static function whereServerRole(Builder $query, ServerRole ...$roles): Builder + { + $legacyBuildServerFlags = collect($roles) + ->reject(fn (ServerRole $role) => $role === ServerRole::DEPLOYMENT) + ->map(fn (ServerRole $role) => $role === ServerRole::BUILD) + ->values() + ->all(); + + return $query->whereHas('settings', fn (Builder $settings) => $settings + ->whereIn('server_role', array_map(fn (ServerRole $role) => $role->value, $roles)) + ->orWhere(fn (Builder $legacy) => $legacy + ->whereNull('server_role') + ->whereIn('is_build_server', $legacyBuildServerFlags))); } public function canHostResources(): bool @@ -929,16 +944,19 @@ $siteAddress { return $this->ip === 'host.docker.internal' || $this->id === 0; } - public static function buildServers($teamId) + /** + * Usable dedicated (build-only) servers of a team. Servers with the combined role + * host deployments, so they are never picked as build servers. + */ + public static function buildServers($teamId): Builder { - return Server::whereTeamId($teamId) + $query = Server::whereTeamId($teamId) ->whereRelation('settings', 'is_reachable', true) ->whereRelation('settings', 'is_usable', true) ->whereRelation('settings', 'is_swarm_worker', false) - ->whereHas('settings', fn (Builder $settings) => $settings - ->where('server_role', '!=', ServerRole::DEPLOYMENT->value) - ->orWhereNull('server_role')) ->whereRelation('settings', 'force_disabled', false); + + return self::whereServerRole($query, ServerRole::BUILD); } public function isForceDisabled() diff --git a/resources/views/livewire/project/application/general.blade.php b/resources/views/livewire/project/application/general.blade.php index 6f7323ce41..8057209805 100644 --- a/resources/views/livewire/project/application/general.blade.php +++ b/resources/views/livewire/project/application/general.blade.php @@ -401,7 +401,8 @@ @if ( $application->destination->server->isSwarm() || $application->additional_servers->count() > 0 || - $application->settings->is_build_server_enabled) + $application->settings->is_build_server_enabled || + ! $application->destination->server->canBuildApplications()) and($onboarding) ->toContain('after("@if (\$application->build_pack === 'dockerimage')") + ->after('@else') + ->before('toString(); + + expect($requiredImageCondition) + ->toContain('$application->settings->is_build_server_enabled') + ->toContain('! $application->destination->server->canBuildApplications()'); +}); diff --git a/tests/Feature/BuildServerFallbackPolicyTest.php b/tests/Feature/BuildServerFallbackPolicyTest.php index 2fd3b2f72a..e698a205b4 100644 --- a/tests/Feature/BuildServerFallbackPolicyTest.php +++ b/tests/Feature/BuildServerFallbackPolicyTest.php @@ -23,7 +23,7 @@ afterEach(function () { Server::flushIdentityMap(); }); -function makeBuildServerSelectionJob(Team $team, Server $deploymentServer): array +function makeBuildServerSelectionJob(Team $team, Server $deploymentServer, array $applicationAttributes = [], bool $buildServerEnabled = true, bool $restartOnly = false): array { $project = new Project; $project->setRelation('team', $team); @@ -32,9 +32,10 @@ function makeBuildServerSelectionJob(Team $team, Server $deploymentServer): arra $environment->setRelation('project', $project); $settings = new ApplicationSetting; - $settings->is_build_server_enabled = true; + $settings->is_build_server_enabled = $buildServerEnabled; $application = new Application; + $application->forceFill(array_merge(['build_pack' => 'nixpacks'], $applicationAttributes)); $application->setRelation('environment', $environment); $application->setRelation('settings', $settings); @@ -45,6 +46,7 @@ function makeBuildServerSelectionJob(Team $team, Server $deploymentServer): arra 'application' => $application, 'application_deployment_queue' => $deploymentQueue, 'server' => $deploymentServer, + 'restart_only' => $restartOnly, ] as $property => $value) { (new ReflectionProperty(ApplicationDeploymentJob::class, $property))->setValue($job, $value); } @@ -62,6 +64,26 @@ function selectedBuildServer(ApplicationDeploymentJob $job): Server return (new ReflectionProperty(ApplicationDeploymentJob::class, 'build_server'))->getValue($job); } +function usesRemoteBuildServer(ApplicationDeploymentJob $job): bool +{ + return (new ReflectionProperty(ApplicationDeploymentJob::class, 'use_build_server'))->getValue($job); +} + +function makeRoleServer(Team $team, string $role): Server +{ + $server = Server::factory()->create(['team_id' => $team->id]); + $server->settings()->update([ + 'is_reachable' => true, + 'is_usable' => true, + 'server_role' => $role, + 'is_build_server' => $role === 'build', + 'is_swarm_worker' => false, + 'force_disabled' => false, + ]); + + return $server->fresh(); +} + test('teams allow deployment server fallback by default', function () { $team = Team::factory()->create(); $deploymentServer = Server::factory()->create(['team_id' => $team->id]); @@ -136,28 +158,144 @@ 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 () { +test('strict teams do not treat combined servers as dedicated build servers', 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, - ]); + $deploymentServer = makeRoleServer($team, 'both'); + makeRoleServer($team, 'both'); [$job, $deploymentQueue] = makeBuildServerSelectionJob($team, $deploymentServer); - $deploymentQueue->shouldNotReceive('setAttribute'); - $deploymentQueue->shouldReceive('addLogEntry') - ->once() - ->with("Using deployment server ({$deploymentServer->name}) for the build."); + $deploymentQueue->shouldNotReceive('addLogEntry'); + + expect(fn () => invokeBuildServerSelection($job)) + ->toThrow(DeploymentException::class, 'No available dedicated build server was found.'); +}); + +test('build server selection only picks dedicated build servers', function () { + $team = Team::factory()->create(); + $deploymentServer = makeRoleServer($team, 'both'); + makeRoleServer($team, 'both'); + makeRoleServer($team, 'both'); + makeRoleServer($team, 'deployment'); + $buildServer = makeRoleServer($team, 'build'); + [$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); - $useBuildServer = (new ReflectionProperty(ApplicationDeploymentJob::class, 'use_build_server'))->getValue($job); + expect(selectedBuildServer($job)->is($buildServer))->toBeTrue() + ->and(usesRemoteBuildServer($job))->toBeTrue(); +}); + +test('fallback builds on the deployment server instead of another combined server', function () { + $team = Team::factory()->create(); + $deploymentServer = makeRoleServer($team, 'both'); + makeRoleServer($team, 'both'); + [$job, $deploymentQueue] = makeBuildServerSelectionJob($team, $deploymentServer); + + $deploymentQueue->shouldNotReceive('setAttribute'); + $deploymentQueue->shouldReceive('addLogEntry')->once()->with('No suitable build server found. Using the deployment server.'); + + invokeBuildServerSelection($job); expect(selectedBuildServer($job)->is($deploymentServer))->toBeTrue() - ->and($useBuildServer)->toBeFalse(); + ->and(usesRemoteBuildServer($job))->toBeFalse(); +}); + +test('deployments-only servers build on a dedicated build server without the application setting', function () { + $team = Team::factory()->create(); + $deploymentServer = makeRoleServer($team, 'deployment'); + makeRoleServer($team, 'both'); + $buildServer = makeRoleServer($team, 'build'); + [$job, $deploymentQueue] = makeBuildServerSelectionJob($team, $deploymentServer, ['docker_registry_image_name' => 'ghcr.io/coollabsio/app'], buildServerEnabled: false); + + $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() + ->and(usesRemoteBuildServer($job))->toBeTrue(); +}); + +test('deployments-only servers never fall back to building themselves', function (bool $buildServerEnabled) { + $team = Team::factory()->create(); + $deploymentServer = makeRoleServer($team, 'deployment'); + makeRoleServer($team, 'both'); + [$job, $deploymentQueue] = makeBuildServerSelectionJob($team, $deploymentServer, ['docker_registry_image_name' => 'ghcr.io/coollabsio/app'], $buildServerEnabled); + + $deploymentQueue->shouldNotReceive('addLogEntry'); + + expect(fn () => invokeBuildServerSelection($job)) + ->toThrow(DeploymentException::class, "The deployment server ({$deploymentServer->name}) is set to deployments only"); +})->with([ + 'application uses a build server' => [true], + 'application builds on the deployment server' => [false], +]); + +test('deployments-only servers need a registry image for remote builds', function () { + $team = Team::factory()->create(); + $deploymentServer = makeRoleServer($team, 'deployment'); + makeRoleServer($team, 'build'); + [$job, $deploymentQueue] = makeBuildServerSelectionJob($team, $deploymentServer, buildServerEnabled: false); + + $deploymentQueue->shouldNotReceive('addLogEntry'); + + expect(fn () => invokeBuildServerSelection($job)) + ->toThrow(DeploymentException::class, 'Set a Docker image name'); +}); + +test('deployments-only servers restart without a registry image', function () { + $team = Team::factory()->create(); + $deploymentServer = makeRoleServer($team, 'deployment'); + $buildServer = makeRoleServer($team, 'build'); + [$job, $deploymentQueue] = makeBuildServerSelectionJob($team, $deploymentServer, buildServerEnabled: false, restartOnly: true); + + $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(); +}); + +test('deployments-only servers can restart when no build server is available', function () { + $team = Team::factory()->create(); + $deploymentServer = makeRoleServer($team, 'deployment'); + [$job, $deploymentQueue] = makeBuildServerSelectionJob($team, $deploymentServer, buildServerEnabled: false, restartOnly: true); + + $deploymentQueue->shouldReceive('addLogEntry')->once()->with('No suitable build server found. Using the deployment server.'); + + invokeBuildServerSelection($job); + + expect(selectedBuildServer($job)->is($deploymentServer))->toBeTrue() + ->and(usesRemoteBuildServer($job))->toBeFalse(); +}); + +test('deployments-only servers do not need a build server when nothing is built', function (string $buildPack) { + $team = Team::factory()->create(['is_build_server_fallback_enabled' => false]); + $deploymentServer = makeRoleServer($team, 'deployment'); + [$job, $deploymentQueue] = makeBuildServerSelectionJob($team, $deploymentServer, ['build_pack' => $buildPack], buildServerEnabled: false); + + $deploymentQueue->shouldNotReceive('addLogEntry'); + + invokeBuildServerSelection($job); + + expect(selectedBuildServer($job)->is($deploymentServer))->toBeTrue() + ->and(usesRemoteBuildServer($job))->toBeFalse(); +})->with(['dockerimage', 'dockercompose']); + +test('applications on combined servers build locally unless they use a build server', function () { + $team = Team::factory()->create(['is_build_server_fallback_enabled' => false]); + $deploymentServer = makeRoleServer($team, 'both'); + makeRoleServer($team, 'build'); + [$job, $deploymentQueue] = makeBuildServerSelectionJob($team, $deploymentServer, buildServerEnabled: false); + + $deploymentQueue->shouldNotReceive('addLogEntry'); + + invokeBuildServerSelection($job); + + expect(selectedBuildServer($job)->is($deploymentServer))->toBeTrue() + ->and(usesRemoteBuildServer($job))->toBeFalse(); }); diff --git a/tests/Feature/ResourceHostingServerTest.php b/tests/Feature/ResourceHostingServerTest.php index af64673caa..28b6bb2632 100644 --- a/tests/Feature/ResourceHostingServerTest.php +++ b/tests/Feature/ResourceHostingServerTest.php @@ -86,7 +86,34 @@ test('legacy server settings with a null role use the combined role', function ( 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); + ->and(Server::isUsableBuildServer()->pluck('id'))->not->toContain($this->server->id) + ->and(Server::buildServers($this->team->id)->pluck('id'))->not->toContain($this->server->id); +}); + +test('legacy build servers with a null role stay build only', function () { + $this->actingAs($this->user); + $this->server->settings()->update(['server_role' => null, 'is_build_server' => true]); + $this->server->refresh()->load('settings'); + + expect($this->server->settings->effectiveServerRole()->value)->toBe('build') + ->and(Server::isUsable()->pluck('id'))->not->toContain($this->server->id) + ->and(Server::isUsableBuildServer()->pluck('id'))->toContain($this->server->id) + ->and(Server::buildServers($this->team->id)->pluck('id'))->toContain($this->server->id); +}); + +test('deployments only requires a dedicated build server, not a combined server', function () { + $combinedServer = Server::factory()->create(['team_id' => $this->team->id]); + $combinedServer->settings()->update(['server_role' => 'both', + 'is_build_server' => false, 'is_reachable' => true, 'is_usable' => true]); + + Livewire::actingAs($this->user) + ->test(Show::class, ['server_uuid' => $this->server->uuid]) + ->set('serverRole', 'deployment') + ->call('requestServerRoleChange') + ->assertSet('serverRole', 'both') + ->assertDispatched('error'); + + expect($this->server->settings->fresh()->server_role->value)->toBe('both'); }); test('changing from build only to deployments and builds requires confirmation', function () { @@ -169,11 +196,31 @@ 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) - ->toContain($this->server->id) + ->not->toContain($this->server->id) ->and($component->buildServers->pluck('id'))->toContain($buildServer->id) ->and($component->allServers->pluck('id'))->toContain($this->server->id, $buildServer->id); }); +test('resource selection lists each combined server once', function () { + $this->actingAs($this->user); + $otherServer = Server::factory()->create(['team_id' => $this->team->id]); + $otherServer->settings()->update([ + 'is_reachable' => true, + 'is_usable' => true, + 'server_role' => 'both', + 'is_build_server' => false, + 'is_swarm_worker' => false, + 'force_disabled' => false, + ]); + + $component = new ResourceSelect; + $component->loadServers(); + + expect($component->buildServers)->toBeEmpty() + ->and($component->servers->pluck('id')->all())->toEqualCanonicalizing([$this->server->id, $otherServer->id]) + ->and($component->allServers->pluck('id')->duplicates())->toBeEmpty(); +}); + test('resource selection does not show the empty server message when only build servers are available', function () { $html = Blade::render( file_get_contents(resource_path('views/livewire/project/new/select.blade.php')), diff --git a/tests/Feature/ServerUpdatePrivateKeyApiTest.php b/tests/Feature/ServerUpdatePrivateKeyApiTest.php index 2b67e7b83d..7940c0d9a1 100644 --- a/tests/Feature/ServerUpdatePrivateKeyApiTest.php +++ b/tests/Feature/ServerUpdatePrivateKeyApiTest.php @@ -134,7 +134,20 @@ it('rejects the legacy build server API field', function () { ->assertJsonValidationErrors('is_build_server'); }); -it('requires another build-capable server before selecting deployment only', function () { +it('requires a dedicated build server before selecting deployment only', function () { + patchServerUpdatePrivateKeyApi($this, $this->server, $this->bearerToken, [ + 'server_role' => 'deployment', + ])->assertUnprocessable() + ->assertJsonValidationErrors('server_role'); + + $combinedServer = Server::factory()->create(['team_id' => $this->team->id]); + $combinedServer->settings()->update([ + 'is_reachable' => true, + 'is_usable' => true, + 'server_role' => 'both', + 'is_build_server' => false, + ]); + patchServerUpdatePrivateKeyApi($this, $this->server, $this->bearerToken, [ 'server_role' => 'deployment', ])->assertUnprocessable() @@ -165,7 +178,22 @@ it('creates a server with an API server role', function () { $server = Server::whereUuid($response->json('uuid'))->firstOrFail(); - expect($server->settings->server_role->value)->toBe('build'); + expect($server->settings->server_role->value)->toBe('build') + ->and($server->settings->is_build_server)->toBeTrue(); +}); + +it('keeps the legacy build server flag in sync when the API changes the role', function () { + patchServerUpdatePrivateKeyApi($this, $this->server, $this->bearerToken, [ + 'server_role' => 'build', + ])->assertCreated(); + + expect($this->server->settings->fresh()->is_build_server)->toBeTrue(); + + patchServerUpdatePrivateKeyApi($this, $this->server, $this->bearerToken, [ + 'server_role' => 'both', + ])->assertCreated(); + + expect($this->server->settings->fresh()->is_build_server)->toBeFalse(); }); it('rejects an invalid API server role', function () {