diff --git a/.ai/lessons.md b/.ai/lessons.md index 79bac6eff3..06d5343213 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -57,6 +57,7 @@ - Use the database as the correctness source for dynamic cron occurrences shared by multiple scheduler and Horizon nodes; Redis locks are load controls, not a durable execution ledger. - Give each schedule occurrence a unique database identity and make queue consumers claim it atomically before external work. - Keep pending occurrences recoverable across publisher interruptions, and define an explicit bounded policy for late or offline schedules. +- Horizon workers are long-lived: flush every static or `once()` cache (for example `Server::flushIdentityMap()`) in `Queue::before`, or later jobs decide with stale state. ## Fail closed at public webhook boundaries - Reject missing or blank secrets before signature verification, and return generic errors without logging secrets, signatures, or payloads. diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 6ff61ad9c1..d886c905b9 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -1031,7 +1031,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue /** * The queued ServerStorageSaveJob writes new content files, but it can run after the containers * start. Preserve-repository deployments write all file storages in write_deployment_configurations(). - * Each deployment writes the files on its own server, so additional servers also get them. + * Each deployment writes the missing files on its own server before the containers start, so + * additional servers also get them. */ private function write_missing_content_files(): void { @@ -4602,8 +4603,14 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); ->values(); } + /** + * `docker compose up` creates a missing bind source as an empty directory, so content files are + * written first. This also gives a server that was added later the files it does not have. + */ private function start_by_compose_file() { + $this->write_missing_content_files(); + try { // Ensure .env file exists before docker compose tries to load it (defensive programming) $this->execute_remote_command( diff --git a/tests/Feature/ContentFilesBeforeComposeUpTest.php b/tests/Feature/ContentFilesBeforeComposeUpTest.php index b812ed68f8..2086f9ce88 100644 --- a/tests/Feature/ContentFilesBeforeComposeUpTest.php +++ b/tests/Feature/ContentFilesBeforeComposeUpTest.php @@ -361,6 +361,49 @@ test('a deployment to an additional server writes missing content files on that ->and(ContentFilesUpRecorder::ssh()[$write])->not->toContain("@'{$this->server->ip}'"); }); +test('a Dockerfile deployment to an additional server writes missing content files before the container starts', function () { + $this->application->update(['build_pack' => 'dockerfile', 'docker_compose_location' => null]); + $volume = contentFilesVolume($this->application, 'config/app.conf'); + $otherServer = Server::factory()->create([ + 'team_id' => $this->team->id, + 'private_key_id' => $this->server->private_key_id, + ])->fresh(); + fakeContentFilesServer([$volume->fs_path => 'missing']); + + $job = new ContentFilesUpDeploymentJob; + $queue = Mockery::mock(ApplicationDeploymentQueue::class)->makePartial(); + $queue->shouldReceive('addLogEntry')->andReturnUsing(function (string $message, string $type = 'stdout') { + $this->logEntries[] = [$message, $type]; + }); + foreach ([ + 'application' => $this->application->fresh(), + 'application_deployment_queue' => $queue, + 'server' => $otherServer, + 'mainServer' => $otherServer, + 'deployment_uuid' => 'deployment-uuid', + 'workdir' => '/artifacts/deployment-uuid', + 'configuration_dir' => $this->application->workdir(), + 'docker_compose_location' => '/docker-compose.yaml', + 'coolify_variables' => '', + 'pull_request_id' => 0, + 'preserveRepository' => false, + 'use_build_server' => false, + 'saved_outputs' => collect(), + ] as $property => $value) { + (new ReflectionProperty(ApplicationDeploymentJob::class, $property))->setValue($job, $value); + } + + (new ReflectionMethod(ApplicationDeploymentJob::class, 'start_by_compose_file'))->invoke($job); + + $write = ContentFilesUpRecorder::indexOf('ssh', 'base64 -d | tee '.escapeshellarg($volume->fs_path)); + $up = ContentFilesUpRecorder::indexOf('deployment', ' up --build -d'); + expect($write)->not->toBeNull() + ->and($up)->not->toBeNull() + ->and($write)->toBeLessThan($up) + ->and(ContentFilesUpRecorder::ssh()[$write])->toContain("@'{$otherServer->ip}'") + ->and($this->logEntries)->toContain(['Writing 1 missing configuration file.', 'stdout']); +}); + test('the content file check and write are safe for non-root servers', function () { $this->server->update(['user' => 'ubuntu']); $volume = contentFilesVolume($this->application, 'config/app.conf');