From 361d5a3c8cfa5d43c9a9ea2f9d2dee07aac07cae Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Thu, 10 Sep 2026 09:03:28 +0200 Subject: [PATCH] feat(deploy): pull compose images before stopping containers Pull image-based Docker Compose services with --ignore-buildable before stopping the current deployment so image-only services are ready first. Fail the deploy if pull fails, and run the pull on the runtime server when a build server is used. --- app/Jobs/ApplicationDeploymentJob.php | 22 +++++ .../Unit/DockerComposeImagePullOrderTest.php | 98 +++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 tests/Unit/DockerComposeImagePullOrderTest.php diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 01a365a0c..f260e619a 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -808,6 +808,8 @@ private function deploy_docker_compose_buildpack() // This overwrites the build-time .env with ALL variables (build-time + runtime) $this->save_runtime_environment_variables(); + $this->pull_docker_compose_images(); + $this->stop_running_container(force: true); $this->application_deployment_queue->addLogEntry('Starting new application.'); $networkId = $this->application->uuid; @@ -911,6 +913,26 @@ private function deploy_docker_compose_buildpack() $this->application_deployment_queue->addLogEntry('New container started.'); } + private function pull_docker_compose_images(): void + { + $this->application_deployment_queue->addLogEntry('Pulling image-based services before stopping the current deployment.'); + + 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"; + } 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"); + } + + $this->execute_remote_command([ + $command, + 'hidden' => true, + ]); + } + private function deploy_dockerfile_buildpack() { $this->application_deployment_queue->addLogEntry("Starting deployment of {$this->customRepository}:{$this->application->git_branch} to {$this->server->name}."); diff --git a/tests/Unit/DockerComposeImagePullOrderTest.php b/tests/Unit/DockerComposeImagePullOrderTest.php new file mode 100644 index 000000000..87b11892a --- /dev/null +++ b/tests/Unit/DockerComposeImagePullOrderTest.php @@ -0,0 +1,98 @@ +commandServers[] = $reflection->getProperty('server')->getValue($this); + $this->recordedCommands[] = $commands; + + if ($this->failCommands) { + throw new RuntimeException('Image pull failed.'); + } + } +} + +function makeDockerComposeImagePullJob(bool $useBuildServer = false): array +{ + $job = new TestableDockerComposeImagePullDeploymentJob; + $reflection = new ReflectionClass(ApplicationDeploymentJob::class); + + $application = Mockery::mock(Application::class)->makePartial(); + $application->uuid = 'application-uuid'; + $application->shouldReceive('workdir')->andReturn('/runtime/application'); + + $queue = Mockery::mock(ApplicationDeploymentQueue::class)->makePartial(); + $queue->shouldReceive('addLogEntry')->once()->andReturnNull(); + + $buildServer = new Server; + $buildServer->id = 1; + $mainServer = new Server; + $mainServer->id = 2; + + foreach ([ + 'application' => $application, + 'application_deployment_queue' => $queue, + 'server' => $buildServer, + 'build_server' => $buildServer, + 'mainServer' => $mainServer, + 'use_build_server' => $useBuildServer, + 'workdir' => '/artifacts/application', + 'deployment_uuid' => 'deployment-uuid', + 'docker_compose_location' => '/docker-compose.yaml', + 'coolify_variables' => '', + ] as $property => $value) { + $reflection->getProperty($property)->setValue($job, $value); + } + + return [$job, $reflection, $buildServer, $mainServer]; +} + +it('pulls image-only compose services before removing running containers', function () { + $source = file_get_contents(__DIR__.'/../../app/Jobs/ApplicationDeploymentJob.php'); + $methodStart = strpos($source, 'private function deploy_docker_compose_buildpack()'); + $methodEnd = strpos($source, 'private function pull_docker_compose_images()', $methodStart); + $deploymentMethod = substr($source, $methodStart, $methodEnd - $methodStart); + + $pullPosition = strpos($deploymentMethod, '$this->pull_docker_compose_images();'); + $stopPosition = strpos($deploymentMethod, '$this->stop_running_container(force: true);'); + + expect($pullPosition)->not->toBeFalse() + ->and($stopPosition)->not->toBeFalse() + ->and($pullPosition)->toBeLessThan($stopPosition); +}); + +it('aborts the deployment when pulling a compose image fails', function () { + [$job, $reflection] = makeDockerComposeImagePullJob(); + $job->failCommands = true; + + $reflection->getMethod('pull_docker_compose_images')->invoke($job); +})->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); + + $reflection->getMethod('pull_docker_compose_images')->invoke($job); + + $command = $job->recordedCommands[0][0][0]; + + expect($job->commandServers)->toHaveCount(1) + ->and($job->commandServers[0])->toBe($mainServer) + ->and($job->commandServers[0])->not->toBe($buildServer) + ->and($command)->toContain('pull --ignore-buildable') + ->and($command)->toContain('--env-file /runtime/application/.env'); +});