fix(compose): let the build interpolate required runtime-only variables

Closes #12363

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-10-28 14:16:22 +01:00
co-authored by Claude Opus 5.5
parent e7a46bd790
commit bd9708d79b
2 changed files with 110 additions and 1 deletions
+43 -1
View File
@@ -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<int, string>
*/
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.
@@ -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,
]);