From 3ca72315a903bb4d23ed88710aecae199b2b8be4 Mon Sep 17 00:00:00 2001 From: Christoph Tupi Date: Tue, 10 Mar 2026 13:19:00 +0100 Subject: [PATCH] feat: add docker compose include directive support --- app/Models/Application.php | 150 ++++++-- bootstrap/helpers/parsers.php | 203 +++++++++- .../Unit/DockerComposeIncludeSupportTest.php | 351 ++++++++++++++++++ .../DockerComposeRawContentRemovalTest.php | 8 +- 4 files changed, 666 insertions(+), 46 deletions(-) create mode 100644 tests/Unit/DockerComposeIncludeSupportTest.php diff --git a/app/Models/Application.php b/app/Models/Application.php index a4f51780e..df93d2472 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -1525,6 +1525,74 @@ class Application extends BaseModel } } + /** + * Resolves docker compose include directives by fetching included files from Git and merging. + * Returns merged YAML string, or null if resolution fails (caller should use original content). + */ + public function resolveComposeIncludes(string $composeContent): ?string + { + if (! $this->destination?->server) { + return null; + } + + try { + $mainYaml = Yaml::parse($composeContent); + if (! is_array($mainYaml)) { + return null; + } + + $workdir = rtrim($this->base_directory, '/'); + $composeFile = $this->docker_compose_location; + $fullComposePath = $workdir.$composeFile; + $composeDir = dirname($fullComposePath); + if ($composeDir === '/' || $composeDir === '.') { + $composeDir = ''; + } + + $includePaths = extractComposeIncludePaths($mainYaml, $composeDir); + if (empty($includePaths)) { + return $composeContent; + } + + $uuid = new Cuid2; + ['commands' => $cloneCommand] = $this->generateGitImportCommands(deployment_uuid: $uuid, only_checkout: true, exec_in_docker: false, custom_base_dir: '.'); + + $gitRemoteStatus = $this->getGitRemoteStatus(deployment_uuid: $uuid); + if (! $gitRemoteStatus['is_accessible']) { + return null; + } + + $fetchIncludeCommands = collect([ + "rm -rf /tmp/{$uuid}", + "mkdir -p /tmp/{$uuid}", + "cd /tmp/{$uuid}", + $cloneCommand, + ]); + foreach ($includePaths as $path) { + $includeMarker = escapeshellarg("===INCLUDE:{$path}==="); + $gitPath = escapeshellarg('HEAD:'.ltrim($path, '/')); + $fetchIncludeCommands->push("printf '%s\n' {$includeMarker} && git show {$gitPath}"); + } + + $includedContent = instant_remote_process($fetchIncludeCommands->toArray(), $this->destination->server); + + instant_remote_process(["rm -rf /tmp/{$uuid}"], $this->destination->server, false); + + $includedFiles = parseComposeIncludeFiles($includedContent); + if (count($includedFiles) !== count($includePaths)) { + return null; + } + + $merged = mergeComposeWithIncludes($mainYaml, $includedFiles); + + return Yaml::dump($merged, 10, 2); + } catch (\Throwable $e) { + \Log::debug('Failed to resolve Docker Compose include: '.$e->getMessage()); + + return null; + } + } + public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory = null, ?string $restoreDockerComposeLocation = null) { // Use provided restore values or capture current values as fallback @@ -1537,52 +1605,54 @@ class Application extends BaseModel ['commands' => $cloneCommand] = $this->generateGitImportCommands(deployment_uuid: $uuid, only_checkout: true, exec_in_docker: false, custom_base_dir: '.'); $workdir = rtrim($this->base_directory, '/'); $composeFile = $this->docker_compose_location; - $fileList = collect([".$workdir$composeFile"]); + $gitComposePath = ltrim(normalizeComposePath($composeFile), '/'); + $fullComposePath = $workdir.$composeFile; + $composeDir = dirname($fullComposePath); + if ($composeDir === '/' || $composeDir === '.') { + $composeDir = ''; + } $gitRemoteStatus = $this->getGitRemoteStatus(deployment_uuid: $uuid); if (! $gitRemoteStatus['is_accessible']) { throw new \RuntimeException("Failed to read Git source:\n\n{$gitRemoteStatus['error']}"); } - $getGitVersion = instant_remote_process(['git --version'], $this->destination->server, false); - $gitVersion = str($getGitVersion)->explode(' ')->last(); - - if (version_compare($gitVersion, '2.35.1', '<')) { - $fileList = $fileList->map(function ($file) { - $parts = explode('/', trim($file, '.')); - $paths = collect(); - $currentPath = ''; - foreach ($parts as $part) { - $currentPath .= ($currentPath ? '/' : '').$part; - if (str($currentPath)->isNotEmpty()) { - $paths->push($currentPath); - } - } - - return $paths; - })->flatten()->unique()->values(); - $commands = collect([ - "rm -rf /tmp/{$uuid}", - "mkdir -p /tmp/{$uuid}", - "cd /tmp/{$uuid}", - $cloneCommand, - 'git sparse-checkout init', - "git sparse-checkout set {$fileList->implode(' ')}", - 'git read-tree -mu HEAD', - "cat .$workdir$composeFile", - ]); - } else { - $commands = collect([ - "rm -rf /tmp/{$uuid}", - "mkdir -p /tmp/{$uuid}", - "cd /tmp/{$uuid}", - $cloneCommand, - 'git sparse-checkout init --cone', - "git sparse-checkout set {$fileList->implode(' ')}", - 'git read-tree -mu HEAD', - "cat .$workdir$composeFile", - ]); - } + $commands = collect([ + "rm -rf /tmp/{$uuid}", + "mkdir -p /tmp/{$uuid}", + "cd /tmp/{$uuid}", + $cloneCommand, + 'git show '.escapeshellarg("HEAD:{$gitComposePath}"), + ]); try { $composeFileContent = instant_remote_process($commands, $this->destination->server); + + if ($composeFileContent) { + try { + $mainYaml = \Symfony\Component\Yaml\Yaml::parse($composeFileContent); + $includePaths = extractComposeIncludePaths($mainYaml, $composeDir); + + if (! empty($includePaths)) { + $fetchIncludeCommands = collect([ + "cd /tmp/{$uuid}", + ]); + foreach ($includePaths as $path) { + $includeMarker = escapeshellarg("===INCLUDE:{$path}==="); + $gitPath = escapeshellarg('HEAD:'.ltrim($path, '/')); + $fetchIncludeCommands->push("printf '%s\n' {$includeMarker} && git show {$gitPath}"); + } + $includedContent = instant_remote_process($fetchIncludeCommands->toArray(), $this->destination->server); + $includedFiles = parseComposeIncludeFiles($includedContent); + + if (count($includedFiles) !== count($includePaths)) { + throw new \RuntimeException('Failed to read every Docker Compose include file from Git.'); + } + + $merged = mergeComposeWithIncludes($mainYaml, $includedFiles); + $composeFileContent = \Symfony\Component\Yaml\Yaml::dump($merged, 10, 2); + } + } catch (\Exception $e) { + \Log::warning('Failed to resolve Docker Compose include, using main file only: '.$e->getMessage()); + } + } } catch (\Exception $e) { // Restore original values on failure only $this->docker_compose_location = $initialDockerComposeLocation; diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index fa40857ac..49d84a1d7 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -16,6 +16,199 @@ use Spatie\Url\Url; use Symfony\Component\Yaml\Yaml; use Visus\Cuid2\Cuid2; +/** + * Normalizes a Compose path by collapsing "." and ".." segments. + * + * @param string $path Repo-relative or absolute path + * @return string Normalized absolute path rooted at repo root + */ +function normalizeComposePath(string $path): string +{ + $segments = []; + + foreach (explode('/', str_replace('\\', '/', $path)) as $segment) { + if ($segment === '' || $segment === '.') { + continue; + } + + if ($segment === '..') { + array_pop($segments); + + continue; + } + + $segments[] = $segment; + } + + return '/'.implode('/', $segments); +} + +/** + * Extracts local file paths from the Docker Compose `include` section. + * Only supports local file paths (relative or absolute). Skips remote URLs (oci://, https://, etc.). + * Has time complexity of O(n^2) because of the nested foreach loops. This is acceptable here because the number of includes is usually very small. + * + * @param array $yaml Parsed compose YAML + * @param string $composeDir Directory of the main compose file (for resolving relative paths) + * @return array List of resolved include paths relative to repo root + */ +function extractComposeIncludePaths(array $yaml, string $composeDir): array +{ + $include = data_get($yaml, 'include'); + if (! $include) { + return []; + } + + $paths = []; + $includeList = is_array($include) ? $include : [$include]; + + foreach ($includeList as $item) { + if (is_string($item)) { + $path = resolveComposeIncludePath($item, $composeDir); + if ($path !== null) { + $paths[] = $path; + } + } elseif (is_array($item) && isset($item['path'])) { + $pathItems = is_array($item['path']) ? $item['path'] : [$item['path']]; + foreach ($pathItems as $pathItem) { + if (is_string($pathItem)) { + $path = resolveComposeIncludePath($pathItem, $composeDir); + if ($path !== null) { + $paths[] = $path; + } + } + } + } + } + + return array_values(array_unique($paths)); +} + +/** + * Resolves an include path relative to the compose file directory. + * Returns null for remote URLs (oci://, https://, etc.) which we don't support. + * + * @param string $includePath Path from include section (e.g. ./backend/docker-compose.yml) + * @param string $composeDir Directory of the including compose file + * @return string|null Resolved path from repo root, or null for unsupported remote paths + */ +function resolveComposeIncludePath(string $includePath, string $composeDir): ?string +{ + $path = trim($includePath); + if ($path === '') { + return null; + } + + if (str_starts_with($path, 'oci://') || str_starts_with($path, 'https://') || str_starts_with($path, 'http://')) { + return null; + } + + // resolve relative path + $path = str_replace('\\', '/', $path); + if (str_starts_with($path, '/')) { + return normalizeComposePath($path); + } + + $composeDir = trim(normalizeComposePath($composeDir), '/'); + $resolved = $composeDir === '' ? $path : $composeDir.'/'.ltrim($path, '/'); + + return normalizeComposePath($resolved); +} + +/** + * Parses marker-delimited include file content fetched from Git commands. + * + * @return array Map of normalized include path => raw file content + */ +function parseComposeIncludeFiles(string $includedContent): array +{ + preg_match_all('/===INCLUDE:(.+?)===\R?(.*?)(?=(?:===INCLUDE:.+?===)|\z)/s', $includedContent, $matches, PREG_SET_ORDER); + + $includedFiles = []; + + foreach ($matches as $match) { + $path = trim($match[1] ?? ''); + $content = ltrim($match[2] ?? '', "\r\n"); + $content = rtrim($content); + + if ($path === '' || $content === '') { + continue; + } + + $includedFiles[$path] = $content; + } + + return $includedFiles; +} + +/** + * Merges Docker Compose files when the main file uses `include`. + * Merges services, volumes, networks, configs, and secrets from included files. + * The main file takes precedence for conflicts (later in merge order). + * + * @param array $mainYaml Parsed main compose YAML + * @param array $includedFiles Map of path => content for each included file + * @return array Merged compose structure (include section removed) + */ +function mergeComposeWithIncludes(array $mainYaml, array $includedFiles): array +{ + $merged = $mainYaml; + unset($merged['include']); + + $topLevelKeys = ['services', 'volumes', 'networks', 'configs', 'secrets']; + $mergedSections = collect($topLevelKeys)->mapWithKeys(fn ($key) => [$key => collect(data_get($merged, $key, []))]); + + foreach ($includedFiles as $content) { + try { + $included = Yaml::parse($content); + } catch (\Exception) { + continue; + } + + if (! is_array($included)) { + continue; + } + + foreach ($topLevelKeys as $key) { + $section = $mergedSections->get($key); + foreach (data_get($included, $key, []) as $name => $value) { + if (! $section->has($name)) { + $section->put($name, $value); + } + } + } + } + + foreach ($topLevelKeys as $key) { + $merged[$key] = $mergedSections->get($key)->toArray(); + } + + return filterEmptyComposeSections($merged); +} + +/** + * Removes empty top-level sections (volumes, networks, configs, secrets) from a compose array. + * Empty sections like "volumes: { }" are not valid/clean YAML and should be omitted. + * + * @param array $compose Parsed compose structure + * @return array Compose with empty sections removed + */ +function filterEmptyComposeSections(array $compose): array +{ + $emptyKeys = ['volumes', 'networks', 'configs', 'secrets']; + + foreach ($emptyKeys as $key) { + if (isset($compose[$key]) && (is_array($compose[$key]) || $compose[$key] instanceof \Countable)) { + $count = is_array($compose[$key]) ? count($compose[$key]) : $compose[$key]->count(); + if ($count === 0) { + unset($compose[$key]); + } + } + } + + return $compose; +} + /** * 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. @@ -368,6 +561,12 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int return collect([]); } + // Resolve include directives so deployable compose has all services from included files + $resolved = $resource->resolveComposeIncludes($compose); + if ($resolved !== null) { + $compose = $resolved; + } + $pullRequestId = $pull_request_id; $isPullRequest = $pullRequestId == 0 ? false : true; $server = data_get($resource, 'destination.server'); @@ -1474,7 +1673,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int } } } - $resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2); + $resource->docker_compose_raw = Yaml::dump(filterEmptyComposeSections($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()); @@ -2697,7 +2896,7 @@ function serviceParser(Service $resource): Collection } } } - $resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2); + $resource->docker_compose_raw = Yaml::dump(filterEmptyComposeSections($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()); diff --git a/tests/Unit/DockerComposeIncludeSupportTest.php b/tests/Unit/DockerComposeIncludeSupportTest.php new file mode 100644 index 000000000..ed68bab8c --- /dev/null +++ b/tests/Unit/DockerComposeIncludeSupportTest.php @@ -0,0 +1,351 @@ + [ + 'web' => ['image' => 'nginx'], + ], + ]; + + expect(extractComposeIncludePaths($yaml, ''))->toBe([]); +}); + +test('extractComposeIncludePaths extracts simple include paths', function () { + $yaml = [ + 'include' => [ + './backend/docker-compose.yml', + './frontend/docker-compose.yml', + ], + 'services' => [ + 'nginx' => ['image' => 'nginx:alpine'], + ], + ]; + + $paths = extractComposeIncludePaths($yaml, ''); + + expect($paths)->toContain('/backend/docker-compose.yml') + ->and($paths)->toContain('/frontend/docker-compose.yml') + ->and($paths)->toHaveCount(2); +}); + +test('extractComposeIncludePaths resolves relative paths from compose directory', function () { + $yaml = [ + 'include' => [ + './backend/docker-compose.yml', + ], + 'services' => [], + ]; + + $paths = extractComposeIncludePaths($yaml, '/app'); + + expect($paths)->toBe(['/app/backend/docker-compose.yml']); +}); + +test('extractComposeIncludePaths skips remote URLs', function () { + $yaml = [ + 'include' => [ + './backend/docker-compose.yml', + 'oci://docker.io/username/compose:latest', + 'https://example.com/compose.yaml', + ], + 'services' => [], + ]; + + $paths = extractComposeIncludePaths($yaml, ''); + + expect($paths)->toBe(['/backend/docker-compose.yml']); +}); + +test('extractComposeIncludePaths handles path object format with overrides', function () { + $yaml = [ + 'include' => [ + [ + 'path' => [ + 'third-party/compose.yaml', + 'override.yaml', + ], + ], + ], + 'services' => [], + ]; + + $paths = extractComposeIncludePaths($yaml, '/app'); + + expect($paths)->toContain('/app/third-party/compose.yaml') + ->and($paths)->toContain('/app/override.yaml'); +}); + +test('resolveComposeIncludePath resolves relative paths', function () { + expect(resolveComposeIncludePath('./backend/docker-compose.yml', '')) + ->toBe('/backend/docker-compose.yml'); + + expect(resolveComposeIncludePath('./backend/docker-compose.yml', '/app')) + ->toBe('/app/backend/docker-compose.yml'); +}); + +test('resolveComposeIncludePath normalizes dot segments', function () { + expect(resolveComposeIncludePath('../shared/docker-compose.yml', '/apps/backend')) + ->toBe('/apps/shared/docker-compose.yml'); + + expect(resolveComposeIncludePath('./services/../worker/docker-compose.yml', '/apps/backend')) + ->toBe('/apps/backend/worker/docker-compose.yml'); +}); + +test('resolveComposeIncludePath returns null for remote URLs', function () { + expect(resolveComposeIncludePath('oci://docker.io/compose:latest', ''))->toBeNull(); + expect(resolveComposeIncludePath('https://example.com/compose.yaml', ''))->toBeNull(); + expect(resolveComposeIncludePath('http://example.com/compose.yaml', ''))->toBeNull(); +}); + +test('resolveComposeIncludePath returns null for empty or whitespace paths', function () { + expect(resolveComposeIncludePath('', ''))->toBeNull(); + expect(resolveComposeIncludePath(' ', ''))->toBeNull(); +}); + +test('resolveComposeIncludePath normalizes Windows backslashes to forward slashes', function () { + expect(resolveComposeIncludePath('.\\backend\\docker-compose.yml', '/app')) + ->toBe('/app/backend/docker-compose.yml'); +}); + +test('extractComposeIncludePaths handles include as single string', function () { + $yaml = [ + 'include' => './single/docker-compose.yml', + 'services' => [], + ]; + + $paths = extractComposeIncludePaths($yaml, '/app'); + + expect($paths)->toBe(['/app/single/docker-compose.yml']); +}); + +test('parseComposeIncludeFiles parses marker output without leading command output', function () { + $includedContent = <<<'TEXT' +===INCLUDE:/backend/docker-compose.yml=== +services: + api: + image: php:8.2-fpm +===INCLUDE:/frontend/docker-compose.yml=== +services: + website: + image: node:20-alpine +TEXT; + + expect(parseComposeIncludeFiles($includedContent))->toBe([ + '/backend/docker-compose.yml' => "services:\n api:\n image: php:8.2-fpm", + '/frontend/docker-compose.yml' => "services:\n website:\n image: node:20-alpine", + ]); +}); + +test('parseComposeIncludeFiles ignores non-marker output before includes', function () { + $includedContent = <<<'TEXT' +Cloning into '.'... +===INCLUDE:/backend/docker-compose.yml=== +services: + api: + image: php:8.2-fpm +TEXT; + + expect(parseComposeIncludeFiles($includedContent))->toBe([ + '/backend/docker-compose.yml' => "services:\n api:\n image: php:8.2-fpm", + ]); +}); + +test('mergeComposeWithIncludes merges services from included files', function () { + $mainYaml = [ + 'include' => ['./backend/docker-compose.yml', './frontend/docker-compose.yml'], + 'services' => [ + 'nginx' => [ + 'image' => 'nginx:alpine', + 'depends_on' => ['api', 'website'], + ], + ], + 'networks' => [ + 'bridge' => ['driver' => 'bridge'], + ], + ]; + + $backendCompose = <<<'YAML' +services: + api: + image: php:8.2-fpm + build: + context: ./backend +YAML; + + $frontendCompose = <<<'YAML' +services: + website: + image: node:20-alpine + build: + context: ./frontend +YAML; + + $includedFiles = [ + '/backend/docker-compose.yml' => $backendCompose, + '/frontend/docker-compose.yml' => $frontendCompose, + ]; + + $merged = mergeComposeWithIncludes($mainYaml, $includedFiles); + + expect($merged)->not->toHaveKey('include') + ->and($merged['services'])->toHaveKeys(['nginx', 'api', 'website']) + ->and($merged['services']['nginx']['depends_on'])->toBe(['api', 'website']) + ->and($merged['services']['api']['image'])->toBe('php:8.2-fpm') + ->and($merged['services']['website']['image'])->toBe('node:20-alpine'); +}); + +test('mergeComposeWithIncludes main file services take precedence', function () { + $mainYaml = [ + 'include' => ['./backend/docker-compose.yml'], + 'services' => [ + 'api' => [ + 'image' => 'custom-api:latest', + ], + ], + ]; + + $backendCompose = <<<'YAML' +services: + api: + image: php:8.2-fpm +YAML; + + $merged = mergeComposeWithIncludes($mainYaml, ['/backend/docker-compose.yml' => $backendCompose]); + + expect($merged['services']['api']['image'])->toBe('custom-api:latest'); +}); + +test('mergeComposeWithIncludes merges volumes and networks', function () { + $mainYaml = [ + 'include' => ['./backend/docker-compose.yml'], + 'services' => [ + 'web' => ['image' => 'nginx'], + ], + 'volumes' => [ + 'main-data' => null, + ], + ]; + + $backendCompose = <<<'YAML' +services: + api: + image: php:8.2 +volumes: + backend-data: null +networks: + backend-net: + driver: bridge +YAML; + + $merged = mergeComposeWithIncludes($mainYaml, ['/backend/docker-compose.yml' => $backendCompose]); + + expect($merged['volumes'])->toHaveKeys(['main-data', 'backend-data']) + ->and($merged['networks'])->toHaveKey('backend-net'); +}); + +test('mergeComposeWithIncludes handles empty included files', function () { + $mainYaml = [ + 'include' => ['./backend/docker-compose.yml'], + 'services' => [ + 'web' => ['image' => 'nginx'], + ], + ]; + + $merged = mergeComposeWithIncludes($mainYaml, []); + + expect($merged)->not->toHaveKey('include') + ->and($merged['services'])->toHaveKey('web') + ->and($merged['services']['web']['image'])->toBe('nginx'); +}); + +test('mergeComposeWithIncludes handles invalid YAML in included file', function () { + $mainYaml = [ + 'include' => ['./backend/docker-compose.yml'], + 'services' => [ + 'web' => ['image' => 'nginx'], + ], + ]; + + $includedFiles = [ + '/backend/docker-compose.yml' => 'invalid: yaml: content: [', + ]; + + $merged = mergeComposeWithIncludes($mainYaml, $includedFiles); + + expect($merged['services'])->toHaveKey('web'); +}); + +test('mergeComposeWithIncludes merges configs and secrets', function () { + $mainYaml = [ + 'include' => ['./backend/docker-compose.yml'], + 'services' => [ + 'web' => ['image' => 'nginx'], + ], + 'configs' => [ + 'main-config' => ['file' => './main.conf'], + ], + ]; + + $backendCompose = <<<'YAML' +services: + api: + image: php:8.2 +configs: + backend-config: + file: ./backend.conf +secrets: + backend-secret: + file: ./secret.txt +YAML; + + $merged = mergeComposeWithIncludes($mainYaml, ['/backend/docker-compose.yml' => $backendCompose]); + + expect($merged['configs'])->toHaveKeys(['main-config', 'backend-config']) + ->and($merged['secrets'])->toHaveKey('backend-secret'); +}); + +test('extractComposeIncludePaths deduplicates paths', function () { + $yaml = [ + 'include' => [ + './backend/docker-compose.yml', + './backend/docker-compose.yml', + ], + 'services' => [], + ]; + + $paths = extractComposeIncludePaths($yaml, '/app'); + + expect($paths)->toBe(['/app/backend/docker-compose.yml']); +}); + +test('mergeComposeWithIncludes filters empty top-level sections', function () { + $mainYaml = [ + 'include' => ['./backend/docker-compose.yml'], + 'services' => [ + 'nginx' => ['image' => 'nginx:alpine'], + ], + ]; + + $backendCompose = <<<'YAML' +services: + api: + image: php:8.2-fpm +YAML; + + $merged = mergeComposeWithIncludes($mainYaml, ['/backend/docker-compose.yml' => $backendCompose]); + + expect($merged)->toHaveKeys(['services']) + ->and($merged)->not->toHaveKey('volumes') + ->and($merged)->not->toHaveKey('networks') + ->and($merged)->not->toHaveKey('configs') + ->and($merged)->not->toHaveKey('secrets') + ->and($merged['services'])->toHaveKeys(['nginx', 'api']); +}); + +test('applicationParser calls resolveComposeIncludes when parsing compose with include', function () { + $parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/parsers.php'); + + expect($parsersFile) + ->toContain('resolveComposeIncludes') + ->toContain('Resolve include directives so deployable compose has all services from included files'); +}); diff --git a/tests/Unit/DockerComposeRawContentRemovalTest.php b/tests/Unit/DockerComposeRawContentRemovalTest.php index 159acb366..bead9c776 100644 --- a/tests/Unit/DockerComposeRawContentRemovalTest.php +++ b/tests/Unit/DockerComposeRawContentRemovalTest.php @@ -35,10 +35,10 @@ it('ensures applicationParser updates docker_compose_raw from original compose, // 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 + // Check that docker_compose_raw is set from originalCompose (with empty sections filtered), not cleanedCompose expect($parsersFile) ->toContain('$originalYaml = Yaml::parse($originalCompose);') - ->toContain('$resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2);') + ->toContain('$resource->docker_compose_raw = Yaml::dump(filterEmptyComposeSections($originalYaml), 10, 2);') ->not->toContain('$resource->docker_compose_raw = $cleanedCompose;'); }); @@ -50,10 +50,10 @@ it('ensures serviceParser updates docker_compose_raw from original compose, not $serviceParserStart = strpos($parsersFile, 'function serviceParser(Service $resource): Collection'); $serviceParserContent = substr($parsersFile, $serviceParserStart); - // Check that docker_compose_raw is set from originalCompose within serviceParser + // Check that docker_compose_raw is set from originalCompose (with empty sections filtered) within serviceParser expect($serviceParserContent) ->toContain('$originalYaml = Yaml::parse($originalCompose);') - ->toContain('$resource->docker_compose_raw = Yaml::dump($originalYaml, 10, 2);') + ->toContain('$resource->docker_compose_raw = Yaml::dump(filterEmptyComposeSections($originalYaml), 10, 2);') ->not->toContain('$resource->docker_compose_raw = $cleanedCompose;'); });