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 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-09-25 11:54:00 +02:00
co-authored by Claude Opus 5.5
parent 9e999a2782
commit 60bac941ea
10 changed files with 303 additions and 47 deletions
+3
View File
@@ -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.
@@ -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,
]);
}
+16 -10
View File
@@ -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;
+1 -1
View File
@@ -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;
}
+29 -11
View File
@@ -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()
@@ -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())
<x-forms.input id="dockerRegistryImageName" required label="Image"
placeholder="ghcr.io/your-org/your-app" x-bind:disabled="!canUpdate" />
<x-forms.input id="dockerRegistryImageTag"
@@ -61,3 +61,16 @@ test('onboarding uses the reusable advanced settings component', function () {
->and($onboarding)
->toContain('<x-forms.collapsible title="Advanced Connection Settings"');
});
test('deployments-only servers require a registry image because builds run elsewhere', function () {
$view = file_get_contents(resource_path('views/livewire/project/application/general.blade.php'));
$requiredImageCondition = str($view)
->after("@if (\$application->build_pack === 'dockerimage')")
->after('@else')
->before('<x-forms.input id="dockerRegistryImageName" required label="Image"')
->toString();
expect($requiredImageCondition)
->toContain('$application->settings->is_build_server_enabled')
->toContain('! $application->destination->server->canBuildApplications()');
});
+156 -18
View File
@@ -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();
});
+49 -2
View File
@@ -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')),
@@ -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 () {