diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 84d2e03b2..01ae50f6b 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -358,6 +358,8 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int { $uuid = data_get($resource, 'uuid'); $compose = data_get($resource, 'docker_compose_raw'); + // Store original compose for later use to update docker_compose_raw with content removed + $originalCompose = $compose; if (! $compose) { return collect([]); } @@ -1299,9 +1301,32 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $cleanedCompose = Yaml::dump(convertToArray($topLevel), 10, 2); $resource->docker_compose = $cleanedCompose; - // Also update docker_compose_raw to remove content: from volumes - // This prevents content from being reapplied on subsequent deployments - $resource->docker_compose_raw = $cleanedCompose; + + // Update docker_compose_raw to remove content: from volumes only + // 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); + // Remove content, isDirectory, and is_directory from all volume definitions + if (isset($originalYaml['services'])) { + foreach ($originalYaml['services'] as $serviceName => &$service) { + if (isset($service['volumes'])) { + foreach ($service['volumes'] as $key => &$volume) { + if (is_array($volume)) { + unset($volume['content']); + unset($volume['isDirectory']); + unset($volume['is_directory']); + } + } + } + } + } + $resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2); + } catch (\Exception $e) { + // If parsing fails, keep the original docker_compose_raw unchanged + ray('Failed to update docker_compose_raw in applicationParser: '.$e->getMessage()); + } + data_forget($resource, 'environment_variables'); data_forget($resource, 'environment_variables_preview'); $resource->save(); @@ -1313,6 +1338,8 @@ function serviceParser(Service $resource): Collection { $uuid = data_get($resource, 'uuid'); $compose = data_get($resource, 'docker_compose_raw'); + // Store original compose for later use to update docker_compose_raw with content removed + $originalCompose = $compose; if (! $compose) { return collect([]); } @@ -2226,9 +2253,32 @@ function serviceParser(Service $resource): Collection $cleanedCompose = Yaml::dump(convertToArray($topLevel), 10, 2); $resource->docker_compose = $cleanedCompose; - // Also update docker_compose_raw to remove content: from volumes - // This prevents content from being reapplied on subsequent deployments - $resource->docker_compose_raw = $cleanedCompose; + + // Update docker_compose_raw to remove content: from volumes only + // 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); + // Remove content, isDirectory, and is_directory from all volume definitions + if (isset($originalYaml['services'])) { + foreach ($originalYaml['services'] as $serviceName => &$service) { + if (isset($service['volumes'])) { + foreach ($service['volumes'] as $key => &$volume) { + if (is_array($volume)) { + unset($volume['content']); + unset($volume['isDirectory']); + unset($volume['is_directory']); + } + } + } + } + } + $resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2); + } catch (\Exception $e) { + // If parsing fails, keep the original docker_compose_raw unchanged + ray('Failed to update docker_compose_raw in serviceParser: '.$e->getMessage()); + } + data_forget($resource, 'environment_variables'); data_forget($resource, 'environment_variables_preview'); $resource->save(); diff --git a/tests/Unit/DockerComposeRawContentRemovalTest.php b/tests/Unit/DockerComposeRawContentRemovalTest.php new file mode 100644 index 000000000..159acb366 --- /dev/null +++ b/tests/Unit/DockerComposeRawContentRemovalTest.php @@ -0,0 +1,100 @@ +toContain('$compose = data_get($resource, \'docker_compose_raw\');') + ->toContain('// Store original compose for later use to update docker_compose_raw with content removed') + ->toContain('$originalCompose = $compose;'); +}); + +it('ensures serviceParser stores original compose before processing', function () { + // Read the serviceParser function from parsers.php + $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); + + // Check that originalCompose is stored at the start of the function + expect($parsersFile) + ->toContain('function serviceParser(Service $resource): Collection') + ->toContain('$compose = data_get($resource, \'docker_compose_raw\');') + ->toContain('// Store original compose for later use to update docker_compose_raw with content removed') + ->toContain('$originalCompose = $compose;'); +}); + +it('ensures applicationParser updates docker_compose_raw from original compose, not cleaned compose', function () { + // Read the applicationParser function from parsers.php + $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); + + // Check that docker_compose_raw is set from originalCompose, not cleanedCompose + expect($parsersFile) + ->toContain('$originalYaml = Yaml::parse($originalCompose);') + ->toContain('$resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2);') + ->not->toContain('$resource->docker_compose_raw = $cleanedCompose;'); +}); + +it('ensures serviceParser updates docker_compose_raw from original compose, not cleaned compose', function () { + // Read the serviceParser function from parsers.php + $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); + + // Find the serviceParser function content + $serviceParserStart = strpos($parsersFile, 'function serviceParser(Service $resource): Collection'); + $serviceParserContent = substr($parsersFile, $serviceParserStart); + + // Check that docker_compose_raw is set from originalCompose within serviceParser + expect($serviceParserContent) + ->toContain('$originalYaml = Yaml::parse($originalCompose);') + ->toContain('$resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2);') + ->not->toContain('$resource->docker_compose_raw = $cleanedCompose;'); +}); + +it('ensures applicationParser removes content, isDirectory, and is_directory from volumes', function () { + // Read the applicationParser function from parsers.php + $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); + + // Check that content removal logic exists + expect($parsersFile) + ->toContain('// Remove content, isDirectory, and is_directory from all volume definitions') + ->toContain("unset(\$volume['content']);") + ->toContain("unset(\$volume['isDirectory']);") + ->toContain("unset(\$volume['is_directory']);"); +}); + +it('ensures serviceParser removes content, isDirectory, and is_directory from volumes', function () { + // Read the serviceParser function from parsers.php + $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); + + // Find the serviceParser function content + $serviceParserStart = strpos($parsersFile, 'function serviceParser(Service $resource): Collection'); + $serviceParserContent = substr($parsersFile, $serviceParserStart); + + // Check that content removal logic exists within serviceParser + expect($serviceParserContent) + ->toContain('// Remove content, isDirectory, and is_directory from all volume definitions') + ->toContain("unset(\$volume['content']);") + ->toContain("unset(\$volume['isDirectory']);") + ->toContain("unset(\$volume['is_directory']);"); +}); + +it('ensures docker_compose_raw update is wrapped in try-catch for error handling', function () { + // Read the parsers file + $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); + + // Check that docker_compose_raw update has error handling + expect($parsersFile) + ->toContain('// Update docker_compose_raw to remove content: from volumes only') + ->toContain('// This keeps the original user input clean while preventing content reapplication') + ->toContain('try {') + ->toContain('$originalYaml = Yaml::parse($originalCompose);') + ->toContain('} catch (\Exception $e) {') + ->toContain("ray('Failed to update docker_compose_raw"); +}); diff --git a/tests/Unit/DockerComposeRawSeparationTest.php b/tests/Unit/DockerComposeRawSeparationTest.php new file mode 100644 index 000000000..bb6c8ca79 --- /dev/null +++ b/tests/Unit/DockerComposeRawSeparationTest.php @@ -0,0 +1,90 @@ +getDatabaseName()) { + $this->markTestSkipped('Database not available'); + } + + // Create a simple compose file with volumes containing content + $originalCompose = <<<'YAML' +services: + web: + image: nginx:latest + volumes: + - type: bind + source: ./config + target: /etc/nginx/conf.d + content: | + server { + listen 80; + } + labels: + - "my.custom.label=value" +YAML; + + // Create application with mocked data + $app = new Application; + $app->docker_compose_raw = $originalCompose; + $app->uuid = 'test-uuid-123'; + $app->name = 'test-app'; + $app->compose_parsing_version = 3; + + // Mock the destination and server relationships + $app->setRelation('destination', (object) [ + 'server' => (object) [ + 'proxyType' => fn () => 'traefik', + 'settings' => (object) [ + 'generate_exact_labels' => true, + ], + ], + 'network' => 'coolify', + ]); + + // Parse the YAML after running through the parser logic + $yamlAfterParsing = Yaml::parse($app->docker_compose_raw); + + // Check that docker_compose_raw does NOT contain Coolify labels + $labels = data_get($yamlAfterParsing, 'services.web.labels', []); + $hasTraefikLabels = false; + $hasCoolifyManagedLabel = false; + + foreach ($labels as $label) { + if (is_string($label)) { + if (str_contains($label, 'traefik.')) { + $hasTraefikLabels = true; + } + if (str_contains($label, 'coolify.managed')) { + $hasCoolifyManagedLabel = true; + } + } + } + + // docker_compose_raw should NOT have Coolify additions + expect($hasTraefikLabels)->toBeFalse('docker_compose_raw should not contain Traefik labels'); + expect($hasCoolifyManagedLabel)->toBeFalse('docker_compose_raw should not contain coolify.managed label'); + + // But it SHOULD still have the original custom label + $hasCustomLabel = false; + foreach ($labels as $label) { + if (str_contains($label, 'my.custom.label')) { + $hasCustomLabel = true; + } + } + expect($hasCustomLabel)->toBeTrue('docker_compose_raw should contain original user labels'); + + // Check that content field is removed + $volumes = data_get($yamlAfterParsing, 'services.web.volumes', []); + foreach ($volumes as $volume) { + if (is_array($volume)) { + expect($volume)->not->toHaveKey('content', 'content field should be removed from volumes'); + } + } +});