From cd8d299955864b34df6aabc05a5e0842986dfd0d Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 1 Oct 2026 07:16:14 +0200 Subject: [PATCH] fix(deploy): never build the image on additional servers An additional server of a multi-server application must run the image that the main server built and pushed. On servers with the build or both role, a disabled build cache still forced a local rebuild, and a failed registry pull fell back to a local build. That ignored the build server and the build-server fallback setting, and the servers could run different images. Additional servers now always pull the main server's image (a forced rebuild is reset, except for Docker Compose applications, which build on each server by design). When the image is not in the registry, the deployment fails with a clear message instead of building it. --- app/Jobs/ApplicationDeploymentJob.php | 13 +++-- .../Feature/BuildServerFallbackPolicyTest.php | 49 +++++++++++++++++++ 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 10364885b6..a5f421da3d 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -434,8 +434,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue // An additional server of a multi-server application does not build: it pulls the image the main server pushed. if ($this->is_this_additional_server && str($this->application->docker_registry_image_name)->isNotEmpty()) { - if (! $this->server->canBuildApplications()) { - // Disabled build cache forces a rebuild, which would skip the registry pull on a server that cannot build. + // The main deployment already built the image (also for a forced rebuild or disabled build cache). + // A forced rebuild would skip the registry pull. Compose applications build on each server that can build. + if ($this->application->build_pack !== 'dockercompose' || ! $this->server->canBuildApplications()) { $this->force_rebuild = false; } $this->application_deployment_queue->addLogEntry('Additional server: pulls the image from the registry, no build server needed.'); @@ -1472,8 +1473,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->application_deployment_queue->addLogEntry('Build configuration changed. Rebuilding image.'); } } else { - if ($this->is_this_additional_server && ! $this->server->canBuildApplications() && $this->application->build_pack !== 'dockercompose') { - throw new DeploymentException("Image ({$this->production_image_name}) not found in the registry; the main server must push it first. The server ({$this->server->name}) is set to deployments only and cannot build it."); + if ($this->is_this_additional_server && $this->application->build_pack !== 'dockercompose') { + $message = "Image ({$this->production_image_name}) not found in the registry; the main server must push it first."; + $message .= $this->server->canBuildApplications() + ? ' Additional servers do not build, so they run the same image as the main server.' + : " The server ({$this->server->name}) is set to deployments only and cannot build it."; + throw new DeploymentException($message); } $this->application_deployment_queue->addLogEntry("Image not found ({$this->production_image_name}). Building new image."); } diff --git a/tests/Feature/BuildServerFallbackPolicyTest.php b/tests/Feature/BuildServerFallbackPolicyTest.php index 779db2f463..a983402447 100644 --- a/tests/Feature/BuildServerFallbackPolicyTest.php +++ b/tests/Feature/BuildServerFallbackPolicyTest.php @@ -381,3 +381,52 @@ test('additional deployments-only servers fail instead of building when the regi 'deploy' => [false], 'restart' => [true], ]); + +test('additional servers that can build still pull the image when the build cache is disabled', function (string $role) { + $team = Team::factory()->create(['is_build_server_fallback_enabled' => false]); + $deploymentServer = makeRoleServer($team, $role); + [$job, $deploymentQueue] = makeBuildServerSelectionJob($team, $deploymentServer, ['docker_registry_image_name' => 'ghcr.io/coollabsio/app']); + markAsAdditionalServer($job, forceRebuild: true); + + $deploymentQueue->shouldReceive('addLogEntry')->once()->with('Additional server: pulls the image from the registry, no build server needed.'); + + invokeBuildServerSelection($job); + + expect((new ReflectionProperty(ApplicationDeploymentJob::class, 'force_rebuild'))->getValue($job))->toBeFalse() + ->and(usesRemoteBuildServer($job))->toBeFalse(); +})->with([ + 'deployments and builds' => ['both'], + 'builds only' => ['build'], +]); + +test('additional Compose servers that can build keep a disabled build cache', function () { + $team = Team::factory()->create(); + $deploymentServer = makeRoleServer($team, 'both'); + [$job, $deploymentQueue] = makeBuildServerSelectionJob($team, $deploymentServer, ['build_pack' => 'dockercompose', 'docker_registry_image_name' => 'ghcr.io/coollabsio/app']); + markAsAdditionalServer($job, forceRebuild: true); + + $deploymentQueue->shouldReceive('addLogEntry'); + + invokeBuildServerSelection($job); + + expect((new ReflectionProperty(ApplicationDeploymentJob::class, 'force_rebuild'))->getValue($job))->toBeTrue(); +}); + +test('additional servers that can build fail instead of building when the registry image is missing', function (string $role, bool $restartOnly) { + $team = Team::factory()->create(['is_build_server_fallback_enabled' => false]); + $deploymentServer = makeRoleServer($team, $role); + [$job, $deploymentQueue] = makeBuildServerSelectionJob($team, $deploymentServer, ['docker_registry_image_name' => 'ghcr.io/coollabsio/app'], restartOnly: $restartOnly); + markAsAdditionalServer($job); + (new ReflectionProperty(ApplicationDeploymentJob::class, 'saved_outputs'))->setValue($job, collect()); + (new ReflectionProperty(ApplicationDeploymentJob::class, 'pull_request_id'))->setValue($job, 0); + (new ReflectionProperty(ApplicationDeploymentJob::class, 'production_image_name'))->setValue($job, 'ghcr.io/coollabsio/app:abc123'); + + $deploymentQueue->shouldNotReceive('addLogEntry'); + + expect(fn () => (new ReflectionMethod(ApplicationDeploymentJob::class, 'should_skip_build'))->invoke($job)) + ->toThrow(DeploymentException::class, 'Image (ghcr.io/coollabsio/app:abc123) not found in the registry; the main server must push it first. Additional servers do not build, so they run the same image as the main server.'); +})->with([ + 'deployments and builds, deploy' => ['both', false], + 'deployments and builds, restart' => ['both', true], + 'builds only, deploy' => ['build', false], +]);