diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 5737488c0..0fadaf7c7 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -16,6 +16,209 @@ use Spatie\Url\Url; use Symfony\Component\Yaml\Yaml; use Visus\Cuid2\Cuid2; +function parseDockerVolumeString(string $volumeString): array +{ + $volumeString = trim($volumeString); + $source = null; + $target = null; + $mode = null; + + // First, check if the source contains an environment variable with default value + // This needs to be done before counting colons because ${VAR:-value} contains a colon + $envVarPattern = '/^\$\{[^}]+:-[^}]*\}/'; + $hasEnvVarWithDefault = false; + $envVarEndPos = 0; + + if (preg_match($envVarPattern, $volumeString, $matches)) { + $hasEnvVarWithDefault = true; + $envVarEndPos = strlen($matches[0]); + } + + // Count colons, but exclude those inside environment variables + $effectiveVolumeString = $volumeString; + if ($hasEnvVarWithDefault) { + // Temporarily replace the env var to count colons correctly + $effectiveVolumeString = substr($volumeString, $envVarEndPos); + $colonCount = substr_count($effectiveVolumeString, ':'); + } else { + $colonCount = substr_count($volumeString, ':'); + } + + if ($colonCount === 0) { + // Named volume without target (unusual but valid) + // Example: "myvolume" + $source = $volumeString; + $target = $volumeString; + } elseif ($colonCount === 1) { + // Simple volume mapping + // Examples: "gitea:/data" or "./data:/app/data" or "${VAR:-default}:/data" + if ($hasEnvVarWithDefault) { + $source = substr($volumeString, 0, $envVarEndPos); + $remaining = substr($volumeString, $envVarEndPos); + if (strlen($remaining) > 0 && $remaining[0] === ':') { + $target = substr($remaining, 1); + } else { + $target = $remaining; + } + } else { + $parts = explode(':', $volumeString); + $source = $parts[0]; + $target = $parts[1]; + } + } elseif ($colonCount === 2) { + // Volume with mode OR Windows path OR env var with mode + // Handle env var with mode first + if ($hasEnvVarWithDefault) { + // ${VAR:-default}:/path:mode + $source = substr($volumeString, 0, $envVarEndPos); + $remaining = substr($volumeString, $envVarEndPos); + + if (strlen($remaining) > 0 && $remaining[0] === ':') { + $remaining = substr($remaining, 1); + $lastColon = strrpos($remaining, ':'); + + if ($lastColon !== false) { + $possibleMode = substr($remaining, $lastColon + 1); + $validModes = ['ro', 'rw', 'z', 'Z', 'rslave', 'rprivate', 'rshared', 'slave', 'private', 'shared', 'cached', 'delegated', 'consistent']; + + if (in_array($possibleMode, $validModes)) { + $mode = $possibleMode; + $target = substr($remaining, 0, $lastColon); + } else { + $target = $remaining; + } + } else { + $target = $remaining; + } + } + } elseif (preg_match('/^[A-Za-z]:/', $volumeString)) { + // Windows path as source (C:/, D:/, etc.) + // Find the second colon which is the real separator + $secondColon = strpos($volumeString, ':', 2); + if ($secondColon !== false) { + $source = substr($volumeString, 0, $secondColon); + $target = substr($volumeString, $secondColon + 1); + } else { + // Malformed, treat as is + $source = $volumeString; + $target = $volumeString; + } + } else { + // Not a Windows path, check for mode + $lastColon = strrpos($volumeString, ':'); + $possibleMode = substr($volumeString, $lastColon + 1); + + // Check if the last part is a valid Docker volume mode + $validModes = ['ro', 'rw', 'z', 'Z', 'rslave', 'rprivate', 'rshared', 'slave', 'private', 'shared', 'cached', 'delegated', 'consistent']; + + if (in_array($possibleMode, $validModes)) { + // It's a mode + // Examples: "gitea:/data:ro" or "./data:/app/data:rw" + $mode = $possibleMode; + $volumeWithoutMode = substr($volumeString, 0, $lastColon); + $colonPos = strpos($volumeWithoutMode, ':'); + + if ($colonPos !== false) { + $source = substr($volumeWithoutMode, 0, $colonPos); + $target = substr($volumeWithoutMode, $colonPos + 1); + } else { + // Shouldn't happen for valid volume strings + $source = $volumeWithoutMode; + $target = $volumeWithoutMode; + } + } else { + // The last colon is part of the path + // For now, treat the first occurrence of : as the separator + $firstColon = strpos($volumeString, ':'); + $source = substr($volumeString, 0, $firstColon); + $target = substr($volumeString, $firstColon + 1); + } + } + } else { + // More than 2 colons - likely Windows paths or complex cases + // Use a heuristic: find the most likely separator colon + // Look for patterns like "C:" at the beginning (Windows drive) + if (preg_match('/^[A-Za-z]:/', $volumeString)) { + // Windows path as source + // Find the next colon after the drive letter + $secondColon = strpos($volumeString, ':', 2); + if ($secondColon !== false) { + $source = substr($volumeString, 0, $secondColon); + $remaining = substr($volumeString, $secondColon + 1); + + // Check if there's a mode at the end + $lastColon = strrpos($remaining, ':'); + if ($lastColon !== false) { + $possibleMode = substr($remaining, $lastColon + 1); + $validModes = ['ro', 'rw', 'z', 'Z', 'rslave', 'rprivate', 'rshared', 'slave', 'private', 'shared', 'cached', 'delegated', 'consistent']; + + if (in_array($possibleMode, $validModes)) { + $mode = $possibleMode; + $target = substr($remaining, 0, $lastColon); + } else { + $target = $remaining; + } + } else { + $target = $remaining; + } + } else { + // Malformed, treat as is + $source = $volumeString; + $target = $volumeString; + } + } else { + // Try to parse normally, treating first : as separator + $firstColon = strpos($volumeString, ':'); + $source = substr($volumeString, 0, $firstColon); + $remaining = substr($volumeString, $firstColon + 1); + + // Check for mode at the end + $lastColon = strrpos($remaining, ':'); + if ($lastColon !== false) { + $possibleMode = substr($remaining, $lastColon + 1); + $validModes = ['ro', 'rw', 'z', 'Z', 'rslave', 'rprivate', 'rshared', 'slave', 'private', 'shared', 'cached', 'delegated', 'consistent']; + + if (in_array($possibleMode, $validModes)) { + $mode = $possibleMode; + $target = substr($remaining, 0, $lastColon); + } else { + $target = $remaining; + } + } else { + $target = $remaining; + } + } + } + + // Handle environment variable expansion in source + // Example: ${VOLUME_DB_PATH:-db} should extract default value if present + if ($source && preg_match('/^\$\{([^}]+)\}$/', $source, $matches)) { + $varContent = $matches[1]; + + // Check if there's a default value with :- + if (strpos($varContent, ':-') !== false) { + $parts = explode(':-', $varContent, 2); + $varName = $parts[0]; + $defaultValue = isset($parts[1]) ? $parts[1] : ''; + + // If there's a non-empty default value, use it for source + if ($defaultValue !== '') { + $source = $defaultValue; + } else { + // Empty default value, keep the variable reference for env resolution + $source = '${'.$varName.'}'; + } + } + // Otherwise keep the variable as-is for later expansion (no default value) + } + + return [ + 'source' => $source !== null ? str($source) : null, + 'target' => $target !== null ? str($target) : null, + 'mode' => $mode !== null ? str($mode) : null, + ]; +} + function applicationParser(Application $resource, int $pull_request_id = 0, ?int $preview_id = null): Collection { $uuid = data_get($resource, 'uuid'); @@ -304,8 +507,10 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $content = null; $isDirectory = false; if (is_string($volume)) { - $source = str($volume)->beforeLast(':'); - $target = str($volume)->afterLast(':'); + $parsed = parseDockerVolumeString($volume); + $source = $parsed['source']; + $target = $parsed['target']; + // Mode is available in $parsed['mode'] if needed $foundConfig = $fileStorages->whereMountPath($target)->first(); if (sourceIsLocal($source)) { $type = str('bind'); @@ -399,8 +604,9 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $name = "{$name}-pr-$pullRequestId"; } if (is_string($volume)) { - $source = str($volume)->beforeLast(':'); - $target = str($volume)->afterLast(':'); + $parsed = parseDockerVolumeString($volume); + $source = $parsed['source']; + $target = $parsed['target']; $source = $name; $volume = "$source:$target"; } elseif (is_array($volume)) { @@ -1312,8 +1518,10 @@ function serviceParser(Service $resource): Collection $content = null; $isDirectory = false; if (is_string($volume)) { - $source = str($volume)->beforeLast(':'); - $target = str($volume)->afterLast(':'); + $parsed = parseDockerVolumeString($volume); + $source = $parsed['source']; + $target = $parsed['target']; + // Mode is available in $parsed['mode'] if needed $foundConfig = $fileStorages->whereMountPath($target)->first(); if (sourceIsLocal($source)) { $type = str('bind'); @@ -1401,8 +1609,9 @@ function serviceParser(Service $resource): Collection $name = "{$uuid}_{$slugWithoutUuid}"; if (is_string($volume)) { - $source = str($volume)->beforeLast(':'); - $target = str($volume)->afterLast(':'); + $parsed = parseDockerVolumeString($volume); + $source = $parsed['source']; + $target = $parsed['target']; $source = $name; $volume = "$source:$target"; } elseif (is_array($volume)) { diff --git a/tests/Unit/Services/DockerImageParserTest.php b/tests/Unit/DockerImageParserTest.php similarity index 98% rename from tests/Unit/Services/DockerImageParserTest.php rename to tests/Unit/DockerImageParserTest.php index 876d68ef0..35dffbab4 100644 --- a/tests/Unit/Services/DockerImageParserTest.php +++ b/tests/Unit/DockerImageParserTest.php @@ -1,6 +1,6 @@ value())->toBe('gitea'); + expect($result['target']->value())->toBe('/data'); + expect($result['mode'])->toBeNull(); + + // Simple bind mount + $result = parseDockerVolumeString('./data:/app/data'); + expect($result['source']->value())->toBe('./data'); + expect($result['target']->value())->toBe('/app/data'); + expect($result['mode'])->toBeNull(); + + // Absolute path bind mount + $result = parseDockerVolumeString('/var/lib/data:/data'); + expect($result['source']->value())->toBe('/var/lib/data'); + expect($result['target']->value())->toBe('/data'); + expect($result['mode'])->toBeNull(); +}); + +test('parses volumes with read-only mode', function () { + // Named volume with ro mode + $result = parseDockerVolumeString('gitea-localtime:/etc/localtime:ro'); + expect($result['source']->value())->toBe('gitea-localtime'); + expect($result['target']->value())->toBe('/etc/localtime'); + expect($result['mode']->value())->toBe('ro'); + + // Bind mount with ro mode + $result = parseDockerVolumeString('/etc/localtime:/etc/localtime:ro'); + expect($result['source']->value())->toBe('/etc/localtime'); + expect($result['target']->value())->toBe('/etc/localtime'); + expect($result['mode']->value())->toBe('ro'); +}); + +test('parses volumes with other modes', function () { + // Read-write mode + $result = parseDockerVolumeString('data:/var/data:rw'); + expect($result['source']->value())->toBe('data'); + expect($result['target']->value())->toBe('/var/data'); + expect($result['mode']->value())->toBe('rw'); + + // Z mode (SELinux) + $result = parseDockerVolumeString('config:/etc/config:z'); + expect($result['source']->value())->toBe('config'); + expect($result['target']->value())->toBe('/etc/config'); + expect($result['mode']->value())->toBe('z'); + + // Cached mode (macOS) + $result = parseDockerVolumeString('./src:/app/src:cached'); + expect($result['source']->value())->toBe('./src'); + expect($result['target']->value())->toBe('/app/src'); + expect($result['mode']->value())->toBe('cached'); + + // Delegated mode (macOS) + $result = parseDockerVolumeString('./node_modules:/app/node_modules:delegated'); + expect($result['source']->value())->toBe('./node_modules'); + expect($result['target']->value())->toBe('/app/node_modules'); + expect($result['mode']->value())->toBe('delegated'); +}); + +test('parses volumes with environment variables', function () { + // Variable with default value + $result = parseDockerVolumeString('${VOLUME_DB_PATH:-db}:/data/db'); + expect($result['source']->value())->toBe('db'); + expect($result['target']->value())->toBe('/data/db'); + expect($result['mode'])->toBeNull(); + + // Variable without default value + $result = parseDockerVolumeString('${VOLUME_PATH}:/data'); + expect($result['source']->value())->toBe('${VOLUME_PATH}'); + expect($result['target']->value())->toBe('/data'); + expect($result['mode'])->toBeNull(); + + // Variable with empty default - keeps variable reference for env resolution + $result = parseDockerVolumeString('${VOLUME_PATH:-}:/data'); + expect($result['source']->value())->toBe('${VOLUME_PATH}'); + expect($result['target']->value())->toBe('/data'); + expect($result['mode'])->toBeNull(); + + // Variable with mode + $result = parseDockerVolumeString('${DATA_PATH:-./data}:/app/data:ro'); + expect($result['source']->value())->toBe('./data'); + expect($result['target']->value())->toBe('/app/data'); + expect($result['mode']->value())->toBe('ro'); +}); + +test('parses Windows paths', function () { + // Windows absolute path + $result = parseDockerVolumeString('C:/Users/data:/data'); + expect($result['source']->value())->toBe('C:/Users/data'); + expect($result['target']->value())->toBe('/data'); + expect($result['mode'])->toBeNull(); + + // Windows path with mode + $result = parseDockerVolumeString('D:/projects/app:/app:rw'); + expect($result['source']->value())->toBe('D:/projects/app'); + expect($result['target']->value())->toBe('/app'); + expect($result['mode']->value())->toBe('rw'); + + // Windows path with spaces (should be quoted in real use) + $result = parseDockerVolumeString('C:/Program Files/data:/data'); + expect($result['source']->value())->toBe('C:/Program Files/data'); + expect($result['target']->value())->toBe('/data'); + expect($result['mode'])->toBeNull(); +}); + +test('parses edge cases', function () { + // Volume name only (unusual but valid) + $result = parseDockerVolumeString('myvolume'); + expect($result['source']->value())->toBe('myvolume'); + expect($result['target']->value())->toBe('myvolume'); + expect($result['mode'])->toBeNull(); + + // Path with colon in target (not a mode) + $result = parseDockerVolumeString('source:/path:8080'); + expect($result['source']->value())->toBe('source'); + expect($result['target']->value())->toBe('/path:8080'); + expect($result['mode'])->toBeNull(); + + // Multiple colons in path (not Windows) + $result = parseDockerVolumeString('data:/var/lib/docker:data:backup'); + expect($result['source']->value())->toBe('data'); + expect($result['target']->value())->toBe('/var/lib/docker:data:backup'); + expect($result['mode'])->toBeNull(); +}); + +test('parses tmpfs and other special cases', function () { + // Docker socket binding + $result = parseDockerVolumeString('/var/run/docker.sock:/var/run/docker.sock'); + expect($result['source']->value())->toBe('/var/run/docker.sock'); + expect($result['target']->value())->toBe('/var/run/docker.sock'); + expect($result['mode'])->toBeNull(); + + // Docker socket with mode + $result = parseDockerVolumeString('/var/run/docker.sock:/var/run/docker.sock:ro'); + expect($result['source']->value())->toBe('/var/run/docker.sock'); + expect($result['target']->value())->toBe('/var/run/docker.sock'); + expect($result['mode']->value())->toBe('ro'); + + // Tmp mount + $result = parseDockerVolumeString('/tmp:/tmp'); + expect($result['source']->value())->toBe('/tmp'); + expect($result['target']->value())->toBe('/tmp'); + expect($result['mode'])->toBeNull(); +}); + +test('handles whitespace correctly', function () { + // Leading/trailing whitespace + $result = parseDockerVolumeString(' data:/app/data '); + expect($result['source']->value())->toBe('data'); + expect($result['target']->value())->toBe('/app/data'); + expect($result['mode'])->toBeNull(); + + // Whitespace with mode + $result = parseDockerVolumeString(' ./config:/etc/config:ro '); + expect($result['source']->value())->toBe('./config'); + expect($result['target']->value())->toBe('/etc/config'); + expect($result['mode']->value())->toBe('ro'); +}); + +test('parses all valid Docker volume modes', function () { + $validModes = ['ro', 'rw', 'z', 'Z', 'rslave', 'rprivate', 'rshared', + 'slave', 'private', 'shared', 'cached', 'delegated', 'consistent']; + + foreach ($validModes as $mode) { + $result = parseDockerVolumeString("volume:/data:$mode"); + expect($result['source']->value())->toBe('volume'); + expect($result['target']->value())->toBe('/data'); + expect($result['mode']->value())->toBe($mode); + } +}); + +test('parses complex real-world examples', function () { + // MongoDB volume with environment variable + $result = parseDockerVolumeString('${VOLUME_DB_PATH:-./data/db}:/data/db'); + expect($result['source']->value())->toBe('./data/db'); + expect($result['target']->value())->toBe('/data/db'); + expect($result['mode'])->toBeNull(); + + // Config file mount with read-only + $result = parseDockerVolumeString('/home/user/app/config.yml:/app/config.yml:ro'); + expect($result['source']->value())->toBe('/home/user/app/config.yml'); + expect($result['target']->value())->toBe('/app/config.yml'); + expect($result['mode']->value())->toBe('ro'); + + // Named volume with hyphens and underscores + $result = parseDockerVolumeString('my-app_data_v2:/var/lib/app-data'); + expect($result['source']->value())->toBe('my-app_data_v2'); + expect($result['target']->value())->toBe('/var/lib/app-data'); + expect($result['mode'])->toBeNull(); +});