diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 70931ece2e..a502ef7dc1 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -38,6 +38,7 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Queue\TimeoutExceededException; +use Illuminate\Support\Arr; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Sleep; @@ -919,7 +920,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->application_deployment_queue->addLogEntry('Pulling & building required images.'); // Save build-time .env file BEFORE the build - $this->save_buildtime_environment_variables($buildEnvironment); + $buildEnvironment ??= $this->generate_buildtime_environment_variables(); + $this->save_buildtime_environment_variables($buildEnvironment->merge($this->compose_build_interpolation_variables($composeFile, $buildEnvironment))); if ($this->docker_compose_custom_build_command) { // Auto-inject -f (compose file) and --env-file flags using helper function @@ -985,6 +987,46 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } } + /** + * `docker compose build` interpolates the whole Compose file, so a required runtime-only variable + * (`${KEY:?}` in `environment:`) fails the build when the build-time .env file does not define it. + * This gives the build the runtime .env lines of the variables that the file uses outside its build + * sections. A variable that a build section or a build secret can read is left out, so a + * runtime-only value never becomes a build argument or part of an image. + * + * @return Collection + */ + private function compose_build_interpolation_variables(mixed $composeFile, Collection $buildEnvironment): Collection + { + $compose = is_string($composeFile) ? parseDockerComposeYaml($composeFile) : convertToArray($composeFile); + $references = fn (array $values): Collection => collect(Arr::flatten($values)) + ->flatMap(fn (mixed $value): Collection => extractDockerComposeEnvironmentVariableReferences($value)); + + $readByBuilds = collect(data_get($compose, 'secrets', []))->pluck('environment')->filter(); + foreach (data_get($compose, 'services', []) as $serviceName => $service) { + $build = data_get($service, 'build'); + if ($build === null) { + continue; + } + $args = data_get($build, 'args', []); + $argNames = is_array($args) && array_is_list($args) + ? array_map(fn (mixed $arg): string => explode('=', (string) $arg, 2)[0], $args) + : array_keys((array) $args); + $readByBuilds = $readByBuilds->merge($argNames)->merge($references((array) $build)); + unset($compose['services'][$serviceName]['build']); + } + + $buildKeys = $buildEnvironment->map(fn (string $line): string => explode('=', $line, 2)[0]); + $keys = $references($compose)->unique()->diff($readByBuilds)->diff($buildKeys); + if ($keys->isEmpty()) { + return collect(); + } + + return $this->generate_runtime_environment_variables() + ->filter(fn (string $line): bool => $keys->contains(explode('=', $line, 2)[0])) + ->values(); + } + /** * Parses the Compose file for this deployment and writes the volume warnings of the parser * (for example an external volume that the resource does not use yet) to the deployment log. diff --git a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php index 4abe239679..3d6707505f 100644 --- a/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php +++ b/tests/Feature/ApplicationDeploymentControlVarFilteringTest.php @@ -2076,3 +2076,70 @@ it('gives a preview from before v4.5.1 the production values it had', function ( expect($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('DB_PASSWORD=') && str($env)->contains('production-secret')))->toBeTrue() ->and($runtimeEnvs->contains(fn (string $env) => str($env)->startsWith('APP_NAME=') && str($env)->contains('coolify')))->toBeTrue(); }); + +it('lets the Compose build interpolate runtime-only variables without passing them to the build', function (int $pullRequestId) { + $compose = <<<'YAML' +services: + app: + build: + context: . + args: + MAPPED_ARG: '${RUNTIME_IN_BUILD_ARGS}' + LISTED_FROM_ENV: null + environment: + FERNET_KEY: '${FERNET_KEY:?}' + OPTIONAL_REQUIRED: '${OPTIONAL_REQUIRED?}' + BUILD_VALUE: '${BUILD_VALUE}' + worker: + build: + context: ./worker + args: + - LISTED_ARG + environment: + FERNET_KEY: '${FERNET_KEY:?}' +YAML; + + [$application, $server] = makeDeploymentControlVarFixture([ + 'build_pack' => 'dockercompose', + 'docker_compose_raw' => $compose, + 'docker_compose' => $compose, + 'docker_compose_domains' => '[]', + ]); + $application->settings()->update(['inject_build_args_to_dockerfile' => false]); + + createApplicationEnvironmentVariable($application, ['key' => 'FERNET_KEY', 'value' => 'runtime-fernet-key', 'is_buildtime' => false]); + createApplicationEnvironmentVariable($application, ['key' => 'OPTIONAL_REQUIRED', 'value' => '', 'is_buildtime' => false]); + createApplicationEnvironmentVariable($application, ['key' => 'RUNTIME_IN_BUILD_ARGS', 'value' => 'runtime-mapped-arg', 'is_buildtime' => false]); + createApplicationEnvironmentVariable($application, ['key' => 'LISTED_FROM_ENV', 'value' => 'runtime-null-arg', 'is_buildtime' => false]); + createApplicationEnvironmentVariable($application, ['key' => 'LISTED_ARG', 'value' => 'runtime-listed-arg', 'is_buildtime' => false]); + createApplicationEnvironmentVariable($application, ['key' => 'BUILD_VALUE', 'value' => 'build-value']); + + [$job, $reflection] = makeControlVarFilteringJob($application->fresh(), $server, [ + 'pull_request_id' => $pullRequestId, + 'remote_secrets_cache' => [], + 'force_rebuild' => false, + 'docker_compose_location' => '/docker-compose.yaml', + ]); + + invokeDeploymentJobMethod($job, $reflection, 'generate_build_env_variables'); + $buildEnvironment = invokeDeploymentJobMethod($job, $reflection, 'generate_buildtime_environment_variables'); + invokeDeploymentJobMethod($job, $reflection, 'build_docker_compose_images', parseDockerComposeYaml($compose), $buildEnvironment); + + $buildEnvFile = $job->writtenArtifacts[ApplicationDeploymentJob::BUILD_TIME_ENV_PATH]; + $buildCommand = collect($job->recordedCommands)->flatten()->first(fn (mixed $command): bool => is_string($command) && str_contains($command, ' build --pull')); + $buildArgs = readDeploymentJobProperty($job, $reflection, 'build_args')->implode(' '); + + expect($buildEnvFile)->toContain('FERNET_KEY="runtime-fernet-key"') + ->toContain('OPTIONAL_REQUIRED=""') + ->toContain('BUILD_VALUE=') + ->not->toContain('runtime-mapped-arg') + ->not->toContain('runtime-null-arg') + ->not->toContain('runtime-listed-arg') + ->and($buildCommand)->toContain('--env-file '.ApplicationDeploymentJob::BUILD_TIME_ENV_PATH) + ->and($buildArgs)->toContain('BUILD_VALUE') + ->not->toContain('FERNET_KEY') + ->not->toContain('runtime-'); +})->with([ + 'production' => 0, + 'preview' => 42, +]);