fix(compose): deploy large Compose files such as Sentry self-hosted

Sentry self-hosted (getsentry/self-hosted 26.8.0) could not be deployed
as a Docker Compose application. Three separate problems blocked it:

- YAML collection aliases: the first fix raised the limit to 256 only in
  Application::oldRawParser(). The safety check added in v4.4.0
  (validateDockerComposeForInjection) and all other Compose parse calls
  still used the Symfony default of 128, so the Sentry file (133 aliases)
  failed with "Maximum number of collection aliases (128) exceeded".
  Add parseDockerComposeYaml() and use it for every Compose parse.
- Argument list too long: the deployment job sent the generated Compose
  file as base64 inside the SSH command. Linux limits one argument to
  128 KiB, and the Sentry file is about 370 KB. execute_remote_command()
  now accepts an 'input' key that streams content over SSH stdin, and
  the job writes the Compose file with it.
- A list-style build arg without a value (build.args: [SENTRY_IMAGE])
  merged into a map environment kept its numeric key, so Docker Compose
  rejected the file with "non-string key in ...environment: 0". It now
  becomes NAME with no value, as in Compose.

Closes #11725
This commit is contained in:
Andras Bacsai
2026-10-07 13:30:42 +02:00
parent 38861e1394
commit 81045b2dc2
22 changed files with 125 additions and 85 deletions
+1 -2
View File
@@ -10,7 +10,6 @@ use App\Models\ServiceDatabase;
use Illuminate\Support\Collection;
use Lorisleiva\Actions\Concerns\AsAction;
use Lorisleiva\Actions\Decorators\JobDecorator;
use Symfony\Component\Yaml\Yaml;
class StartService
{
@@ -60,7 +59,7 @@ class StartService
if (data_get($service, 'connect_to_docker_network')) {
$compose = data_get($service, 'docker_compose', []);
$safeNetwork = escapeshellarg($service->destination->network);
$serviceNames = data_get(Yaml::parse($compose), 'services', []);
$serviceNames = data_get(parseDockerComposeYaml($compose), 'services', []);
foreach ($serviceNames as $serviceName => $serviceConfig) {
$containerName = escapeshellarg("{$serviceName}-{$service->uuid}");
$commands[] = "docker network connect --alias {$containerName} {$safeNetwork} {$containerName} >/dev/null 2>&1 || true";
@@ -35,7 +35,6 @@ use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use OpenApi\Attributes as OA;
use Spatie\Url\Url;
use Symfony\Component\Yaml\Yaml;
class ApplicationsController extends Controller
{
@@ -2764,7 +2763,7 @@ class ApplicationsController extends Controller
$dockerComposeDomainsResponse = null;
if ($isCompose) {
try {
$compose = Yaml::parse($application->docker_compose_raw ?? '');
$compose = parseDockerComposeYaml($application->docker_compose_raw ?? '');
} catch (\Throwable) {
return response()->json([
'message' => 'Validation failed.',
@@ -3484,7 +3483,7 @@ class ApplicationsController extends Controller
}
}
$yaml = Yaml::parse($application->docker_compose_raw);
$yaml = parseDockerComposeYaml($application->docker_compose_raw);
$services = data_get($yaml, 'services', []);
$existingDockerComposeDomains = json_decode($application->docker_compose_domains ?? '[]', true) ?? [];
$dockerComposeDomains->each(function ($domain) use ($services, $dockerComposeDomainsJson, $existingDockerComposeDomains) {
@@ -26,7 +26,6 @@ use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Validator;
use OpenApi\Attributes as OA;
use Symfony\Component\Yaml\Yaml;
class ServicesController extends Controller
{
@@ -729,7 +728,7 @@ class ServicesController extends Controller
],
], 422);
}
Yaml::parse($dockerComposeRaw);
parseDockerComposeYaml($dockerComposeRaw);
// Validate for command injection BEFORE saving to database
try {
@@ -1278,7 +1277,7 @@ class ServicesController extends Controller
],
], 422);
}
Yaml::parse($dockerComposeRaw);
parseDockerComposeYaml($dockerComposeRaw);
// Validate for command injection BEFORE saving to database
try {
+19 -15
View File
@@ -163,7 +163,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private $docker_compose;
private $docker_compose_base64;
private ?string $generated_docker_compose = null;
private ?string $nixpacks_plan = null;
@@ -831,11 +831,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$yaml = Yaml::dump(convertToArray($composeFile), 10);
}
$this->docker_compose_base64 = base64_encode($yaml);
$this->generated_docker_compose = $yaml;
$this->execute_remote_command([
executeInDocker($this->deployment_uuid, "echo '{$this->docker_compose_base64}' | base64 -d | tee {$this->workdir}{$this->docker_compose_location} > /dev/null"),
"docker exec -i {$this->deployment_uuid} tee {$this->workdir}{$this->docker_compose_location} > /dev/null",
'input' => $yaml,
'hidden' => true,
'skip_command_log' => true,
]);
// Modify Dockerfiles for ARGs and build secrets
@@ -1253,7 +1253,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->server = $this->build_server;
}
}
if (isset($this->docker_compose_base64)) {
if (isset($this->generated_docker_compose)) {
if ($this->use_build_server) {
$this->server = $this->mainServer;
}
@@ -1274,8 +1274,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
"mkdir -p $mainDir",
],
[
"echo '{$this->docker_compose_base64}' | base64 -d | tee $composeFileName > /dev/null",
'skip_command_log' => true,
"tee $composeFileName > /dev/null",
'input' => $this->generated_docker_compose,
],
[
"echo '{$readme}' | tee $mainDir/README.md > /dev/null",
@@ -1717,9 +1717,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
// Generate SERVICE_NAME for dockercompose services from processed compose
if ($this->application->settings->is_raw_compose_deployment_enabled) {
$dockerCompose = Yaml::parse($this->application->docker_compose_raw);
$dockerCompose = parseDockerComposeYaml($this->application->docker_compose_raw);
} else {
$dockerCompose = Yaml::parse($this->application->docker_compose);
$dockerCompose = parseDockerComposeYaml($this->application->docker_compose);
}
$services = data_get($dockerCompose, 'services', []);
foreach ($services as $serviceName => $_) {
@@ -1787,7 +1787,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
// Generate SERVICE_NAME for dockercompose services
$rawDockerCompose = Yaml::parse($this->application->docker_compose_raw);
$rawDockerCompose = parseDockerComposeYaml($this->application->docker_compose_raw);
$rawServices = data_get($rawDockerCompose, 'services', []);
foreach ($rawServices as $rawServiceName => $_) {
$envs->push('SERVICE_NAME_'.str($rawServiceName)->replace('-', '_')->replace('.', '_')->upper().'='.addPreviewDeploymentSuffix($rawServiceName, $this->pull_request_id));
@@ -2026,9 +2026,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
if ($this->pull_request_id === 0) {
// Generate SERVICE_NAME for dockercompose services from processed compose
if ($this->application->settings->is_raw_compose_deployment_enabled) {
$dockerCompose = Yaml::parse($this->application->docker_compose_raw);
$dockerCompose = parseDockerComposeYaml($this->application->docker_compose_raw);
} else {
$dockerCompose = Yaml::parse($this->application->docker_compose);
$dockerCompose = parseDockerComposeYaml($this->application->docker_compose);
}
$services = data_get($dockerCompose, 'services', []);
foreach ($services as $serviceName => $_) {
@@ -2052,7 +2052,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
} else {
// Generate SERVICE_NAME for preview deployments
$rawDockerCompose = Yaml::parse($this->application->docker_compose_raw);
$rawDockerCompose = parseDockerComposeYaml($this->application->docker_compose_raw);
$rawServices = data_get($rawDockerCompose, 'services', []);
foreach ($rawServices as $rawServiceName => $_) {
$envs_dict['SERVICE_NAME_'.str($rawServiceName)->replace('-', '_')->replace('.', '_')->upper()] = escapeBashEnvValue(addPreviewDeploymentSuffix($rawServiceName, $this->pull_request_id));
@@ -4100,8 +4100,12 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
}
$this->docker_compose = Yaml::dump($docker_compose, 10);
$this->docker_compose_base64 = base64_encode($this->docker_compose);
$this->execute_remote_command([executeInDocker($this->deployment_uuid, "echo '{$this->docker_compose_base64}' | base64 -d | tee {$this->workdir}/docker-compose.yaml > /dev/null"), 'hidden' => true, 'skip_command_log' => true]);
$this->generated_docker_compose = $this->docker_compose;
$this->execute_remote_command([
"docker exec -i {$this->deployment_uuid} tee {$this->workdir}/docker-compose.yaml > /dev/null",
'input' => $this->docker_compose,
'hidden' => true,
]);
}
private function generate_local_persistent_volumes()
@@ -241,7 +241,7 @@ class ConnectApplication extends Component
private function composeVolumeSnippet(Application $application): string
{
try {
$services = data_get(Yaml::parse((string) $application->docker_compose_raw), 'services');
$services = data_get(parseDockerComposeYaml((string) $application->docker_compose_raw), 'services');
} catch (\Throwable) {
$services = null;
}
+1 -2
View File
@@ -7,7 +7,6 @@ use App\Models\Project;
use App\Models\Service;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
use Symfony\Component\Yaml\Yaml;
class DockerCompose extends Component
{
@@ -38,7 +37,7 @@ class DockerCompose extends Component
$this->validate([
'dockerComposeRaw' => 'required',
]);
Yaml::parse($this->dockerComposeRaw);
parseDockerComposeYaml($this->dockerComposeRaw);
// Validate for command injection BEFORE saving to database
validateDockerComposeForInjection($this->dockerComposeRaw);
+1 -4
View File
@@ -2138,10 +2138,7 @@ class Application extends BaseModel
public function oldRawParser()
{
try {
$yaml = Yaml::parse(
$this->docker_compose_raw,
maxAliasesForCollections: self::MAX_DOCKER_COMPOSE_COLLECTION_ALIASES,
);
$yaml = parseDockerComposeYaml($this->docker_compose_raw);
} catch (\Exception $e) {
throw new RuntimeException($e->getMessage());
}
+2 -3
View File
@@ -11,7 +11,6 @@ use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Support\Collection;
use Illuminate\Support\Stringable;
use Symfony\Component\Yaml\Yaml;
class LocalFileVolume extends BaseModel
{
@@ -691,7 +690,7 @@ class LocalFileVolume extends BaseModel
}
try {
$services = data_get(Yaml::parse($compose), 'services', []);
$services = data_get(parseDockerComposeYaml($compose), 'services', []);
foreach ($services as $service) {
foreach (data_get($service, 'volumes', []) as $volume) {
if (is_string($volume)) {
@@ -777,7 +776,7 @@ class LocalFileVolume extends BaseModel
}
// Parse the docker-compose content
$compose = Yaml::parse($actualService->docker_compose_raw);
$compose = parseDockerComposeYaml($actualService->docker_compose_raw);
if (! isset($compose['services'])) {
return false;
}
+4 -5
View File
@@ -6,7 +6,6 @@ use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Str;
use Symfony\Component\Yaml\Yaml;
class LocalPersistentVolume extends BaseModel
{
@@ -191,7 +190,7 @@ class LocalPersistentVolume extends BaseModel
return true;
}
$compose = Yaml::parse($composeContent);
$compose = parseDockerComposeYaml($composeContent);
$services = data_get($compose, 'services', []);
$topLevelVolumes = collect(data_get($compose, 'volumes') ?? []);
@@ -246,7 +245,7 @@ class LocalPersistentVolume extends BaseModel
return null;
}
foreach (data_get(Yaml::parse($composeContent), 'volumes') ?? [] as $key => $declaration) {
foreach (data_get(parseDockerComposeYaml($composeContent), 'volumes') ?? [] as $key => $declaration) {
$key = (string) $key;
if (! isComposeExternalVolume($declaration)) {
continue;
@@ -293,7 +292,7 @@ class LocalPersistentVolume extends BaseModel
}
$resourceUuid = $resource instanceof Application ? $resource->uuid : data_get($resource, 'service.uuid');
foreach (data_get(Yaml::parse($composeContent), 'volumes') ?? [] as $key => $declaration) {
foreach (data_get(parseDockerComposeYaml($composeContent), 'volumes') ?? [] as $key => $declaration) {
if (! is_array($declaration) || isComposeExternalVolume($declaration)) {
continue;
}
@@ -334,7 +333,7 @@ class LocalPersistentVolume extends BaseModel
}
// Parse the docker-compose content
$compose = Yaml::parse($actualService->docker_compose_raw);
$compose = parseDockerComposeYaml($actualService->docker_compose_raw);
if (! isset($compose['services'])) {
return false;
}
+1 -2
View File
@@ -17,7 +17,6 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Collection;
use OpenApi\Attributes as OA;
use Symfony\Component\Yaml\Yaml;
#[OA\Schema(
description: 'Service model',
@@ -1598,7 +1597,7 @@ class Service extends BaseModel
// Generate SERVICE_NAME_* environment variables from docker-compose services
if ($this->docker_compose) {
try {
$dockerCompose = Yaml::parse($this->docker_compose);
$dockerCompose = parseDockerComposeYaml($this->docker_compose);
$services = data_get($dockerCompose, 'services', []);
foreach ($services as $serviceName => $_) {
$envs->push('SERVICE_NAME_'.str($serviceName)->replace('-', '_')->replace('.', '_')->upper().'='.$serviceName);
+2 -3
View File
@@ -10,7 +10,6 @@ use App\Traits\ReleasesManagedDnsRecords;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
use Symfony\Component\Yaml\Yaml;
class ServiceApplication extends BaseModel
{
@@ -367,7 +366,7 @@ class ServiceApplication extends BaseModel
return $this->service->getRequiredPort();
}
$dockerCompose = Yaml::parse($dockerComposeRaw);
$dockerCompose = parseDockerComposeYaml($dockerComposeRaw);
$serviceConfig = $dockerCompose['services'][$this->name] ?? null;
if (! $serviceConfig) {
return $this->service->getRequiredPort();
@@ -448,7 +447,7 @@ class ServiceApplication extends BaseModel
*/
private function getSavedLegacyRoutingPort(array $serviceConfig): ?int
{
$savedCompose = Yaml::parse($this->service->docker_compose ?? '');
$savedCompose = parseDockerComposeYaml($this->service->docker_compose ?? '');
$savedService = $savedCompose['services'][$this->name] ?? null;
$image = $serviceConfig['image'] ?? null;
if (! is_string($image) || $image === '' || ($savedService['image'] ?? null) !== $image) {
+1 -1
View File
@@ -112,7 +112,7 @@ trait CalculatesExcludedStatus
}
try {
$dockerCompose = \Symfony\Component\Yaml\Yaml::parse($dockerComposeRaw);
$dockerCompose = parseDockerComposeYaml($dockerComposeRaw);
// Validate structure
if (! is_array($dockerCompose)) {
+1 -1
View File
@@ -31,7 +31,7 @@ trait EnvironmentVariableProtection
}
try {
$dockerComposeData = Yaml::parse($dockerCompose);
$dockerComposeData = parseDockerComposeYaml($dockerCompose);
$dockerEnvVars = data_get($dockerComposeData, 'services.*.environment');
foreach ($dockerEnvVars as $serviceEnvs) {
+10 -5
View File
@@ -95,6 +95,7 @@ trait ExecuteRemoteCommand
$append = data_get($single_command, 'append', true);
$command_hidden = data_get($single_command, 'command_hidden', false);
$skip_command_log = data_get($single_command, 'skip_command_log', false);
$input = data_get($single_command, 'input');
$this->save = data_get($single_command, 'save');
if ($this->server->isNonRoot()) {
if (str($command)->startsWith('docker exec')) {
@@ -119,7 +120,7 @@ trait ExecuteRemoteCommand
while ($attempt < $maxRetries && ! $commandExecuted) {
try {
$this->executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors, $command_hidden, $skip_command_log);
$this->executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors, $command_hidden, $skip_command_log, $input);
$commandExecuted = true;
} catch (\RuntimeException|DeploymentException $e) {
$lastError = $e;
@@ -167,16 +168,20 @@ trait ExecuteRemoteCommand
}
/**
* Execute the actual command with process handling
* Execute the actual command with process handling. With $input, the content goes to the command over
* SSH stdin, because Linux limits one exec argument to 128 KiB and generateSshCommand() puts the whole
* script into one argument.
*/
private function executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors, $command_hidden = false, $skip_command_log = false)
private function executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors, $command_hidden = false, $skip_command_log = false, ?string $input = null)
{
if ($command_hidden && ! $skip_command_log && isset($this->application_deployment_queue)) {
$this->application_deployment_queue->addLogEntry('[CMD]: '.$this->redact_sensitive_info($command), hidden: true);
}
$remote_command = SshMultiplexingHelper::generateSshCommand($this->server, $command);
$process = Process::timeout(config('constants.ssh.command_timeout'))->idleTimeout(3600)->start($remote_command, function (string $type, string $output) use ($command, $hidden, $customType, $append, $command_hidden, $skip_command_log) {
$remote_command = $input === null
? SshMultiplexingHelper::generateSshCommand($this->server, $command)
: SshMultiplexingHelper::generateSshStdinCommand($this->server, $command);
$process = Process::timeout(config('constants.ssh.command_timeout'))->idleTimeout(3600)->input($input)->start($remote_command, function (string $type, string $output) use ($command, $hidden, $customType, $append, $command_hidden, $skip_command_log) {
// Sanitize output to ensure valid UTF-8 encoding before JSON encoding
$sanitized_output = sanitize_utf8_text($output);
$log_output = str($sanitized_output)->trim();
+2 -2
View File
@@ -893,7 +893,7 @@ function dockerComposeServicePorts(?string $compose, ?string $serviceName): arra
}
try {
$services = data_get(Yaml::parse($compose), 'services', []);
$services = data_get(parseDockerComposeYaml($compose), 'services', []);
} catch (Throwable) {
return [];
}
@@ -1782,7 +1782,7 @@ function validateComposeFile(string $compose, int $server_id): string|Throwable
if (! $server) {
throw new Exception('Server not found');
}
$yaml_compose = Yaml::parse($compose);
$yaml_compose = parseDockerComposeYaml($compose);
// Remove Coolify's custom fields before Docker validation
$yaml_compose = stripCoolifyCustomFields($yaml_compose);
+20 -8
View File
@@ -15,8 +15,20 @@ use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Illuminate\Support\Stringable;
use Spatie\Url\Url;
use Symfony\Component\Yaml\Exception\ParseException;
use Symfony\Component\Yaml\Yaml;
/**
* Parses Docker Compose YAML with a higher collection alias limit than the Symfony default (128),
* because large Compose files (for example Sentry self-hosted) reuse YAML anchors many times.
*
* @throws ParseException If the YAML is invalid or exceeds the alias limit
*/
function parseDockerComposeYaml(string $compose): mixed
{
return Yaml::parse($compose, maxAliasesForCollections: Application::MAX_DOCKER_COMPOSE_COLLECTION_ALIASES);
}
/**
* Validates a Docker Compose YAML string for command injection vulnerabilities.
* This should be called BEFORE saving to database to prevent malicious data from being stored.
@@ -28,7 +40,7 @@ use Symfony\Component\Yaml\Yaml;
function validateDockerComposeForInjection(string $composeYaml): void
{
try {
$parsed = Yaml::parse($composeYaml);
$parsed = parseDockerComposeYaml($composeYaml);
} catch (Exception $e) {
throw new Exception('Invalid YAML format: '.$e->getMessage(), 0, $e);
}
@@ -515,7 +527,7 @@ function composeExternalVolumeDockerNames(?string $compose): array
return [];
}
try {
$volumes = data_get(Yaml::parse($compose), 'volumes');
$volumes = data_get(parseDockerComposeYaml($compose), 'volumes');
} catch (Throwable) {
return [];
}
@@ -542,7 +554,7 @@ function composeExternalVolumeMounts(?string $compose): array
return [];
}
try {
$yaml = Yaml::parse($compose);
$yaml = parseDockerComposeYaml($compose);
} catch (Throwable) {
return [];
}
@@ -985,7 +997,7 @@ function removeComposeVolumeFieldsPreservingComments(string $source, array $clea
$candidate = implode('', $result);
try {
if (Yaml::parse($candidate) === $cleanedYaml) {
if (parseDockerComposeYaml($candidate) === $cleanedYaml) {
return $candidate;
}
} catch (Exception) {
@@ -1010,7 +1022,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
$isPullRequest = $pullRequestId == 0 ? false : true;
$server = data_get($resource, 'destination.server');
try {
$yaml = Yaml::parse($compose);
$yaml = parseDockerComposeYaml($compose);
} catch (Exception) {
return collect([]);
}
@@ -2150,7 +2162,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
// This keeps the original user input clean while preventing content reapplication
// Parse the original compose again to create a clean version without Coolify additions
try {
$originalYaml = Yaml::parse($originalCompose);
$originalYaml = parseDockerComposeYaml($originalCompose);
$originalYamlBeforeCleanup = $originalYaml;
// Remove content, isDirectory, and is_directory from all volume definitions
if (isset($originalYaml['services'])) {
@@ -2197,7 +2209,7 @@ function serviceParser(Service $resource): Collection
$server = data_get($resource, 'server');
try {
$yaml = Yaml::parse($compose);
$yaml = parseDockerComposeYaml($compose);
} catch (Exception) {
return collect([]);
}
@@ -3383,7 +3395,7 @@ function serviceParser(Service $resource): Collection
// This keeps the original user input clean while preventing content reapplication
// Parse the original compose again to create a clean version without Coolify additions
try {
$originalYaml = Yaml::parse($originalCompose);
$originalYaml = parseDockerComposeYaml($originalCompose);
$originalYamlBeforeCleanup = $originalYaml;
// Remove content, isDirectory, and is_directory from all volume definitions
if (isset($originalYaml['services'])) {
+1 -1
View File
@@ -269,7 +269,7 @@ function updateCompose(ServiceApplication|ServiceDatabase $resource)
if (! $dockerComposeRaw) {
throw new Exception('No compose file found or not a valid YAML file.');
}
$dockerCompose = Yaml::parse($dockerComposeRaw);
$dockerCompose = parseDockerComposeYaml($dockerComposeRaw);
// Switch Image
$updatedImage = data_get_str($resource, 'image');
+8 -7
View File
@@ -1736,7 +1736,7 @@ function getTopLevelNetworks(Service|Application $resource): Collection
if ($resource->getMorphClass() === Service::class) {
if ($resource->docker_compose_raw) {
try {
$yaml = Yaml::parse($resource->docker_compose_raw);
$yaml = parseDockerComposeYaml($resource->docker_compose_raw);
} catch (Exception $e) {
// If the docker-compose.yml file is not valid, we will return the network name as the key
$topLevelNetworks = collect([
@@ -1802,7 +1802,7 @@ function getTopLevelNetworks(Service|Application $resource): Collection
}
} elseif ($resource->getMorphClass() === Application::class) {
try {
$yaml = Yaml::parse($resource->docker_compose_raw);
$yaml = parseDockerComposeYaml($resource->docker_compose_raw);
} catch (Exception $e) {
// If the docker-compose.yml file is not valid, we will return the network name as the key
$topLevelNetworks = collect([
@@ -2742,7 +2742,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
$envComments = extractYamlEnvironmentComments($resource->docker_compose_raw);
try {
$yaml = Yaml::parse($resource->docker_compose_raw);
$yaml = parseDockerComposeYaml($resource->docker_compose_raw);
} catch (Exception $e) {
throw new RuntimeException($e->getMessage());
}
@@ -3534,7 +3534,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
}
} elseif ($resource->getMorphClass() === Application::class) {
try {
$yaml = Yaml::parse($resource->docker_compose_raw);
$yaml = parseDockerComposeYaml($resource->docker_compose_raw);
} catch (Exception) {
return;
}
@@ -4406,8 +4406,9 @@ function convertToKeyValueCollection($environment)
$key = $parts[0];
$realValue = $parts[1] ?? '';
$changedEnvironment->put($key, $realValue);
} else {
$changedEnvironment->put($key, $value);
} elseif (is_string($value) && $value !== '') {
// A bare name (for example a list-style build arg) is the same as `NAME:` without a value.
$changedEnvironment->put($value, $changedEnvironment->get($value));
}
} else {
$changedEnvironment->put($key, $value);
@@ -5052,7 +5053,7 @@ function extractHardcodedEnvironmentVariables(string $dockerComposeRaw): Collect
}
try {
$yaml = Yaml::parse($dockerComposeRaw);
$yaml = parseDockerComposeYaml($dockerComposeRaw);
} catch (Exception $e) {
// Malformed YAML - return empty collection
return collect([]);
@@ -373,7 +373,7 @@ test('the Compose deployment validates the repository file before any command us
->and($body)->not->toContain('$this->application->loadComposeFile(')
->and($loadPosition)->toBeLessThan(strpos($body, '$this->application->oldRawParser()'))
->and($loadPosition)->toBeLessThan(strpos($body, '$this->parseComposeFileForDeployment()'))
->and($loadPosition)->toBeLessThan(strpos($body, 'base64 -d'))
->and($loadPosition)->toBeLessThan(strpos($body, "'input' =>"))
->and($body)->toContain('"stat -c \'%F\' ".escapeshellarg($realPathInGit)');
});
@@ -411,7 +411,7 @@ it('hides the Nixpacks plan write command that contains build-time values', func
'not static' => [false],
]);
it('hides the compose file write command because compose files can contain secrets', function () {
it('streams the compose file over stdin so its content stays out of commands, errors, and logs', function () {
[$job, $reflection, $queue, $application] = makeSensitiveCommandFailureJob();
$server = readSensitiveFailureJobProperty($job, $reflection, 'server');
setSensitiveFailureJobProperties($job, $reflection, [
@@ -419,12 +419,10 @@ it('hides the compose file write command because compose files can contain secre
'production_image_name' => 'example/app:latest',
]);
$payloads = [];
Process::fake(function (PendingProcess $process) use (&$payloads) {
preg_match_all('~[A-Za-z0-9+/]{16,}={0,2}~', $process->command, $matches);
$composePayloads = array_filter($matches[0], fn (string $candidate): bool => str_contains((string) base64_decode($candidate, true), 'services:'));
if ($composePayloads !== []) {
$payloads = array_merge($payloads, $composePayloads);
$composeWrite = null;
Process::fake(function (PendingProcess $process) use (&$composeWrite) {
if (str_contains((string) $process->input, 'services:')) {
$composeWrite = $process;
return Process::result(errorOutput: 'tee: docker-compose.yaml: No space left on device', exitCode: 1);
}
@@ -434,15 +432,14 @@ it('hides the compose file write command because compose files can contain secre
$exception = captureDeploymentException(fn () => invokeSensitiveFailureJobMethod($job, $reflection, 'generate_compose_file'));
expect($payloads)->not->toBeEmpty();
expect($exception->getMessage())
->toContain('[command hidden because it contains sensitive data]')
->toContain('No space left on device');
$storedLogs = (string) $queue->fresh()->logs;
foreach ($payloads as $payload) {
expect($exception->getMessage())->not->toContain($payload);
expect($storedLogs)->not->toContain($payload);
}
$compose = (string) readSensitiveFailureJobProperty($job, $reflection, 'docker_compose');
expect($composeWrite)->not->toBeNull()
->and($composeWrite->input)->toBe($compose)
->and($composeWrite->command)->toEndWith(escapeshellarg('docker exec -i sensitive-failure-deployment tee /artifacts/sensitive-failure/docker-compose.yaml > /dev/null'))
->and($composeWrite->command)->not->toContain('services:')
->and($exception->getMessage())->toContain('No space left on device')
->and($exception->getMessage())->not->toContain('services:')
->and((string) $queue->fresh()->logs)->not->toContain('services:');
});
function readSensitiveFailureJobProperty(object $job, ReflectionClass $reflection, string $property): mixed
@@ -0,0 +1,13 @@
<?php
it('keeps bare list-style build args as named keys when merged into a map environment', function () {
$environment = collect(['SENTRY_CONF' => '/etc/sentry', 'SENTRY_IMAGE' => 'getsentry/sentry'])
->merge(['SENTRY_IMAGE', 'DOCKER_PLATFORM', 'FOO=bar']);
expect(convertToKeyValueCollection($environment)->all())->toBe([
'SENTRY_CONF' => '/etc/sentry',
'SENTRY_IMAGE' => 'getsentry/sentry',
'DOCKER_PLATFORM' => null,
'FOO' => 'bar',
]);
});
@@ -49,3 +49,22 @@ it('keeps a finite collection alias limit for Docker Compose files', function ()
$application->oldRawParser();
})->throws(RuntimeException::class, 'Maximum number of collection aliases (256) exceeded');
it('accepts Docker Compose files with more than 128 collection aliases during the safety check', function () {
$services = collect(range(1, 129))
->map(fn (int $index): string => " service-{$index}:\n <<: *defaults\n")
->implode('');
validateDockerComposeForInjection("x-defaults: &defaults\n image: alpine:latest\nservices:\n{$services}");
expect(parseDockerComposeYaml("x-defaults: &defaults\n image: alpine:latest\nservices:\n{$services}"))
->toHaveKey('services.service-129.image', 'alpine:latest');
});
it('keeps a finite collection alias limit during the Docker Compose safety check', function () {
$services = collect(range(1, Application::MAX_DOCKER_COMPOSE_COLLECTION_ALIASES + 1))
->map(fn (int $index): string => " service-{$index}:\n <<: *defaults\n")
->implode('');
validateDockerComposeForInjection("x-defaults: &defaults\n image: alpine:latest\nservices:\n{$services}");
})->throws(Exception::class, 'Maximum number of collection aliases (256) exceeded');