diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 0603b971ea..0eb4b3cd00 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -849,12 +849,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue return $service; }); $composeFile['services'] = $services->toArray(); - if (empty($composeFile)) { - $this->application_deployment_queue->addLogEntry('Failed to parse docker-compose file.'); - $this->fail('Failed to parse docker-compose file.'); - - return; - } // Add build secrets to compose file if enabled and BuildKit is supported if ($this->dockerSecretsSupported && ! empty($this->build_secrets)) { @@ -997,12 +991,19 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue * A deployment of the application also queues one sync of the file storages with the server, which * loads the content of the files that come from the repository. A preview uses its own copies of the * files, so its deployment does not sync the storages of the application. + * The parser gives no services when it cannot parse the file. The deployment stops then, because + * an empty Compose file fails later with the unclear Docker error "no service selected". * * @return Collection + * + * @throws DeploymentException When the parsed Compose file has no services */ private function parseComposeFileForDeployment(): Collection { $composeFile = collect($this->application->parse(pull_request_id: $this->pull_request_id, preview_id: data_get($this->preview, 'id'), commit: $this->commit)); + if (blank(data_get($composeFile, 'services'))) { + throw new DeploymentException("The Docker Compose file has no services, or Coolify could not parse it. Check that {$this->application->docker_compose_location} is valid YAML and has a services section."); + } if ($this->pull_request_id === 0) { ServerFilesFromServerJob::dispatch($this->application); } diff --git a/tests/Feature/ApplicationDeploymentComposeParseTest.php b/tests/Feature/ApplicationDeploymentComposeParseTest.php new file mode 100644 index 0000000000..d6a7006be5 --- /dev/null +++ b/tests/Feature/ApplicationDeploymentComposeParseTest.php @@ -0,0 +1,103 @@ + InstanceSettings::firstOrCreate(['id' => 0])); + config(['constants.ssh.mux_enabled' => false]); + Bus::fake(); + Queue::fake(); + Process::fake(['*' => Process::result(output: '')]); + + $team = Team::factory()->create(); + $privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => $privateKey->id, + ]); + $this->destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $team->id]); + $this->environment = $project->environments()->first() + ?? Environment::factory()->create(['project_id' => $project->id]); +}); + +function composeParseDeploymentApplication(string $compose): Application +{ + return Application::factory()->create([ + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => $compose, + 'environment_id' => test()->environment->id, + 'destination_id' => test()->destination->id, + 'destination_type' => test()->destination->getMorphClass(), + ])->fresh(); +} + +/** + * Runs the Compose parse step of a deployment job. + * + * @return Collection + */ +function runComposeParseDeploymentStep(Application $application): Collection +{ + $job = (new ReflectionClass(ApplicationDeploymentJob::class))->newInstanceWithoutConstructor(); + $queue = Mockery::mock(ApplicationDeploymentQueue::class)->makePartial(); + $queue->shouldReceive('addLogEntry'); + + foreach ([ + 'application' => $application, + 'application_deployment_queue' => $queue, + 'pull_request_id' => 0, + 'preview' => null, + 'commit' => 'HEAD', + ] as $property => $value) { + (new ReflectionProperty(ApplicationDeploymentJob::class, $property))->setValue($job, $value); + } + + return (new ReflectionMethod(ApplicationDeploymentJob::class, 'parseComposeFileForDeployment'))->invoke($job); +} + +it('stops the deployment when the Compose file gives no services, instead of writing an empty Compose file', function (string $compose) { + $application = composeParseDeploymentApplication($compose); + + expect(fn () => runComposeParseDeploymentStep($application)) + ->toThrow(DeploymentException::class, 'The Docker Compose file has no services'); + + Queue::assertNotPushed(ServerFilesFromServerJob::class); +})->with([ + 'more collection aliases than the limit' => fn () => "x-defaults: &defaults\n image: alpine:latest\nservices:\n" + .collect(range(1, Application::MAX_DOCKER_COMPOSE_COLLECTION_ALIASES + 1)) + ->map(fn (int $index): string => " service-{$index}:\n <<: *defaults\n") + ->implode(''), + 'invalid YAML' => "services:\n web:\n image: [nginx\n", + 'an empty services section' => "services: {}\n", +]); + +it('deploys a Compose file that reuses YAML anchors more than 128 times', function () { + $compose = "x-defaults: &defaults\n image: alpine:latest\nservices:\n" + .collect(range(1, 160))->map(fn (int $index): string => " service-{$index}:\n <<: *defaults\n")->implode(''); + $application = composeParseDeploymentApplication($compose); + + $composeFile = runComposeParseDeploymentStep($application); + + expect(data_get($composeFile, 'services'))->toHaveCount(160) + ->and(data_get($composeFile, 'services.service-160.image'))->toBe('alpine:latest'); +}); diff --git a/tests/Feature/Proxy/TraefikApplicationRouterServiceLabelsTest.php b/tests/Feature/Proxy/TraefikApplicationRouterServiceLabelsTest.php new file mode 100644 index 0000000000..62014bf66c --- /dev/null +++ b/tests/Feature/Proxy/TraefikApplicationRouterServiceLabelsTest.php @@ -0,0 +1,68 @@ + 0]); + + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id, + 'proxy' => ['type' => ProxyTypes::TRAEFIK->value], + ]); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + $destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail(); + + $this->application = Application::factory()->createOne([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'fqdn' => 'https://app.example.com,http://plain.example.com', + 'ports_exposes' => '3000', + ]); +}); + +/** + * Traefik answers 404 for a router whose service is not declared (#10546). + * + * @param array $labels + * @return array + */ +function routersWithUndeclaredServices(array $labels): array +{ + $declared = collect($labels) + ->map(fn (string $label) => preg_match('/^traefik\.http\.services\.([^.]+)\.loadbalancer\.server\.port=\d+$/', $label, $m) ? $m[1] : null) + ->filter(); + + return collect($labels) + ->mapWithKeys(fn (string $label) => preg_match('/^traefik\.http\.routers\.([^.]+)\.service=(.+)$/', $label, $m) ? [$m[1] => $m[2]] : []) + ->reject(fn (string $service) => $declared->contains($service)) + ->all(); +} + +test('every generated application router points to a declared service', function (bool $forceHttps) { + $this->application->settings->update(['is_force_https_enabled' => $forceHttps]); + + $labels = generateLabelsApplication($this->application->fresh()); + + expect($labels) + ->toContain('traefik.http.routers.http-0-'.$this->application->uuid.'.service=http-0-'.$this->application->uuid) + ->toContain('traefik.http.services.http-0-'.$this->application->uuid.'.loadbalancer.server.port=3000') + ->and(routersWithUndeclaredServices($labels))->toBe([]); +})->with(['force https' => true, 'no force https' => false]);