mirror of
https://github.com/coollabsio/coolify.git
synced 2026-03-11 08:55:47 +00:00
Merge 3ca72315a9 into 0256043ca5
This commit is contained in:
commit
d25bd74e52
4 changed files with 666 additions and 46 deletions
|
|
@ -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)
|
public function loadComposeFile($isInit = false, ?string $restoreBaseDirectory = null, ?string $restoreDockerComposeLocation = null)
|
||||||
{
|
{
|
||||||
// Use provided restore values or capture current values as fallback
|
// 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: '.');
|
['commands' => $cloneCommand] = $this->generateGitImportCommands(deployment_uuid: $uuid, only_checkout: true, exec_in_docker: false, custom_base_dir: '.');
|
||||||
$workdir = rtrim($this->base_directory, '/');
|
$workdir = rtrim($this->base_directory, '/');
|
||||||
$composeFile = $this->docker_compose_location;
|
$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);
|
$gitRemoteStatus = $this->getGitRemoteStatus(deployment_uuid: $uuid);
|
||||||
if (! $gitRemoteStatus['is_accessible']) {
|
if (! $gitRemoteStatus['is_accessible']) {
|
||||||
throw new \RuntimeException("Failed to read Git source:\n\n{$gitRemoteStatus['error']}");
|
throw new \RuntimeException("Failed to read Git source:\n\n{$gitRemoteStatus['error']}");
|
||||||
}
|
}
|
||||||
$getGitVersion = instant_remote_process(['git --version'], $this->destination->server, false);
|
$commands = collect([
|
||||||
$gitVersion = str($getGitVersion)->explode(' ')->last();
|
"rm -rf /tmp/{$uuid}",
|
||||||
|
"mkdir -p /tmp/{$uuid}",
|
||||||
if (version_compare($gitVersion, '2.35.1', '<')) {
|
"cd /tmp/{$uuid}",
|
||||||
$fileList = $fileList->map(function ($file) {
|
$cloneCommand,
|
||||||
$parts = explode('/', trim($file, '.'));
|
'git show '.escapeshellarg("HEAD:{$gitComposePath}"),
|
||||||
$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",
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
$composeFileContent = instant_remote_process($commands, $this->destination->server);
|
$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) {
|
} catch (\Exception $e) {
|
||||||
// Restore original values on failure only
|
// Restore original values on failure only
|
||||||
$this->docker_compose_location = $initialDockerComposeLocation;
|
$this->docker_compose_location = $initialDockerComposeLocation;
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,199 @@ use Spatie\Url\Url;
|
||||||
use Symfony\Component\Yaml\Yaml;
|
use Symfony\Component\Yaml\Yaml;
|
||||||
use Visus\Cuid2\Cuid2;
|
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<string, mixed> $yaml Parsed compose YAML
|
||||||
|
* @param string $composeDir Directory of the main compose file (for resolving relative paths)
|
||||||
|
* @return array<int, string> 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<string, string> 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<string, mixed> $mainYaml Parsed main compose YAML
|
||||||
|
* @param array<string, string> $includedFiles Map of path => content for each included file
|
||||||
|
* @return array<string, mixed> 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<string, mixed> $compose Parsed compose structure
|
||||||
|
* @return array<string, mixed> 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.
|
* 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.
|
* 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([]);
|
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;
|
$pullRequestId = $pull_request_id;
|
||||||
$isPullRequest = $pullRequestId == 0 ? false : true;
|
$isPullRequest = $pullRequestId == 0 ? false : true;
|
||||||
$server = data_get($resource, 'destination.server');
|
$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) {
|
} catch (\Exception $e) {
|
||||||
// If parsing fails, keep the original docker_compose_raw unchanged
|
// If parsing fails, keep the original docker_compose_raw unchanged
|
||||||
ray('Failed to update docker_compose_raw in applicationParser: '.$e->getMessage());
|
ray('Failed to update docker_compose_raw in applicationParser: '.$e->getMessage());
|
||||||
|
|
@ -2709,7 +2908,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) {
|
} catch (\Exception $e) {
|
||||||
// If parsing fails, keep the original docker_compose_raw unchanged
|
// If parsing fails, keep the original docker_compose_raw unchanged
|
||||||
ray('Failed to update docker_compose_raw in serviceParser: '.$e->getMessage());
|
ray('Failed to update docker_compose_raw in serviceParser: '.$e->getMessage());
|
||||||
|
|
|
||||||
351
tests/Unit/DockerComposeIncludeSupportTest.php
Normal file
351
tests/Unit/DockerComposeIncludeSupportTest.php
Normal file
|
|
@ -0,0 +1,351 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
test('extractComposeIncludePaths returns empty array when no include section', function () {
|
||||||
|
$yaml = [
|
||||||
|
'services' => [
|
||||||
|
'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');
|
||||||
|
});
|
||||||
|
|
@ -35,10 +35,10 @@ it('ensures applicationParser updates docker_compose_raw from original compose,
|
||||||
// Read the applicationParser function from parsers.php
|
// Read the applicationParser function from parsers.php
|
||||||
$parsersFile = file_get_contents(__DIR__.'/../../bootstrap/helpers/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)
|
expect($parsersFile)
|
||||||
->toContain('$originalYaml = Yaml::parse($originalCompose);')
|
->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;');
|
->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');
|
$serviceParserStart = strpos($parsersFile, 'function serviceParser(Service $resource): Collection');
|
||||||
$serviceParserContent = substr($parsersFile, $serviceParserStart);
|
$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)
|
expect($serviceParserContent)
|
||||||
->toContain('$originalYaml = Yaml::parse($originalCompose);')
|
->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;');
|
->not->toContain('$resource->docker_compose_raw = $cleanedCompose;');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue