diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index fe01e5b32f..0cbfbc3a48 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -968,18 +968,35 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return $composeFile; } + /** + * Pulls the registry images of the Compose services before the current deployment stops. Images that + * this deployment builds are not pulled: a pull fails for an image that is only local, and a pull of + * an image that is also in a registry replaces the new build with the old registry image. + */ private function pull_docker_compose_images(): void { + if ($this->docker_compose_custom_build_command && ! $this->use_build_server) { + $this->application_deployment_queue->addLogEntry('Skipping the image pull before stopping the current deployment, because the custom build command can build images outside of Docker Compose.'); + + return; + } + + $services = $this->registry_image_compose_services(); + if ($services->isEmpty()) { + return; + } + $this->application_deployment_queue->addLogEntry('Pulling image-based services before stopping the current deployment.'); + $services = $services->map(fn (string $service) => escapeshellarg($service))->implode(' '); if ($this->use_build_server) { $this->write_deployment_configurations(); $this->server = $this->mainServer; $workdir = $this->application->workdir(); - $command = "{$this->coolify_variables} docker compose --env-file {$workdir}/.env --project-name {$this->application->uuid} --project-directory {$workdir} -f {$workdir}{$this->docker_compose_location} pull --ignore-buildable"; + $command = "{$this->coolify_variables} docker compose --env-file {$workdir}/.env --project-name {$this->application->uuid} --project-directory {$workdir} -f {$workdir}{$this->docker_compose_location} pull --ignore-buildable {$services}"; } else { $workdir = $this->workdir; - $command = executeInDocker($this->deployment_uuid, "{$this->coolify_variables} docker compose --env-file {$workdir}/.env --project-name {$this->application->uuid} --project-directory {$workdir} -f {$workdir}{$this->docker_compose_location} pull --ignore-buildable"); + $command = executeInDocker($this->deployment_uuid, "{$this->coolify_variables} docker compose --env-file {$workdir}/.env --project-name {$this->application->uuid} --project-directory {$workdir} -f {$workdir}{$this->docker_compose_location} pull --ignore-buildable {$services}"); } $this->execute_remote_command([ @@ -988,6 +1005,26 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue ]); } + /** + * Returns the names of the Compose services that get their image from a registry. A service with a + * `build` section, or a service that uses the image of such a service, gets its image from the build. + * + * @return Collection + */ + private function registry_image_compose_services(): Collection + { + $services = collect(data_get(Yaml::parse($this->generated_docker_compose), 'services', [])); + $builtImages = $services->filter(fn ($service) => filled(data_get($service, 'build')))->pluck('image')->filter(); + + return $services + ->filter(fn ($service) => filled(data_get($service, 'image')) + && blank(data_get($service, 'build')) + && ! $builtImages->contains(data_get($service, 'image'))) + ->keys() + ->map(fn ($service) => (string) $service) + ->values(); + } + /** * Starts the Compose containers. Content files are written first, because `docker compose up` * creates a missing bind source as an empty directory. diff --git a/tests/Unit/DockerComposeImagePullOrderTest.php b/tests/Unit/DockerComposeImagePullOrderTest.php index 5593d37b83..fb3f95a508 100644 --- a/tests/Unit/DockerComposeImagePullOrderTest.php +++ b/tests/Unit/DockerComposeImagePullOrderTest.php @@ -3,6 +3,7 @@ use App\Jobs\ApplicationDeploymentJob; use App\Models\Application; use App\Models\ApplicationDeploymentQueue; +use App\Models\ApplicationSetting; use App\Models\Server; class TestableDockerComposeImagePullDeploymentJob extends ApplicationDeploymentJob @@ -27,17 +28,33 @@ class TestableDockerComposeImagePullDeploymentJob extends ApplicationDeploymentJ } } -function makeDockerComposeImagePullJob(bool $useBuildServer = false): array +function makeDockerComposeImagePullJob(bool $useBuildServer = false, ?string $customBuildCommand = null, ?string $compose = null): array { + $compose ??= <<<'YAML' + services: + app: + build: . + image: 'myapp:latest' + worker: + image: 'myapp:latest' + db: + image: 'postgres:16' + cache: + image: 'redis:7' + YAML; + $job = new TestableDockerComposeImagePullDeploymentJob; $reflection = new ReflectionClass(ApplicationDeploymentJob::class); $application = Mockery::mock(Application::class)->makePartial(); $application->uuid = 'application-uuid'; + $application->name = 'application'; + $application->setRelation('settings', new ApplicationSetting(['is_raw_compose_deployment_enabled' => false])); $application->shouldReceive('workdir')->andReturn('/runtime/application'); $queue = Mockery::mock(ApplicationDeploymentQueue::class)->makePartial(); - $queue->shouldReceive('addLogEntry')->once()->andReturnNull(); + $queue->shouldReceive('getAttribute')->with('updated_at')->andReturn('2026-10-16 00:00:00'); + $queue->shouldReceive('addLogEntry')->andReturnNull(); $buildServer = new Server; $buildServer->id = 1; @@ -52,9 +69,13 @@ function makeDockerComposeImagePullJob(bool $useBuildServer = false): array 'mainServer' => $mainServer, 'use_build_server' => $useBuildServer, 'workdir' => '/artifacts/application', + 'configuration_dir' => '/runtime/application', + 'pull_request_id' => 0, 'deployment_uuid' => 'deployment-uuid', 'docker_compose_location' => '/docker-compose.yaml', 'coolify_variables' => '', + 'docker_compose_custom_build_command' => $customBuildCommand, + 'generated_docker_compose' => $compose, ] as $property => $value) { $reflection->getProperty($property)->setValue($job, $value); } @@ -70,15 +91,54 @@ it('aborts the deployment when pulling a compose image fails', function () { })->throws(RuntimeException::class, 'Image pull failed.'); it('pulls compose images on the runtime server when using a build server', function () { - [$job, $reflection, $buildServer, $mainServer] = makeDockerComposeImagePullJob(useBuildServer: true); + [$job, $reflection, , $mainServer] = makeDockerComposeImagePullJob(useBuildServer: true); $reflection->getMethod('pull_docker_compose_images')->invoke($job); - $command = $job->recordedCommands[0][0][0]; + $command = last($job->recordedCommands)[0][0]; - expect($job->commandServers)->toHaveCount(1) - ->and($job->commandServers[0])->toBe($mainServer) - ->and($job->commandServers[0])->not->toBe($buildServer) + expect($job->commandServers)->each->toBe($mainServer) ->and($command)->toContain('pull --ignore-buildable') ->and($command)->toContain('--env-file /runtime/application/.env'); }); + +it('pulls only services whose image does not come from the build', function () { + [$job, $reflection] = makeDockerComposeImagePullJob(); + + $reflection->getMethod('pull_docker_compose_images')->invoke($job); + + expect($job->recordedCommands)->toHaveCount(1) + ->and($job->recordedCommands[0][0][0])->toEndWith("pull --ignore-buildable '\\''db'\\'' '\\''cache'\\'''"); +}); + +it('does not pull when all images come from the build', function () { + [$job, $reflection] = makeDockerComposeImagePullJob(compose: <<<'YAML' + services: + app: + build: . + image: 'myapp:latest' + worker: + image: 'myapp:latest' + YAML); + + $reflection->getMethod('pull_docker_compose_images')->invoke($job); + + expect($job->recordedCommands)->toBeEmpty(); +}); + +it('does not pull when a custom build command builds the images', function () { + [$job, $reflection] = makeDockerComposeImagePullJob(customBuildCommand: 'docker buildx bake -f docker-bake.prod.hcl'); + + $reflection->getMethod('pull_docker_compose_images')->invoke($job); + + expect($job->recordedCommands)->toBeEmpty(); +}); + +it('pulls on the runtime server when a custom build command runs on a build server', function () { + [$job, $reflection, , $mainServer] = makeDockerComposeImagePullJob(useBuildServer: true, customBuildCommand: 'docker buildx bake'); + + $reflection->getMethod('pull_docker_compose_images')->invoke($job); + + expect($job->commandServers)->each->toBe($mainServer) + ->and(last($job->recordedCommands)[0][0])->toEndWith("pull --ignore-buildable 'db' 'cache'"); +});