diff --git a/app/Models/Application.php b/app/Models/Application.php
index f8f86d1f9..378161602 100644
--- a/app/Models/Application.php
+++ b/app/Models/Application.php
@@ -998,8 +998,8 @@ class Application extends BaseModel
$git_clone_command = "{$git_clone_command} sed -i \"s#git@\(.*\):#https://\\1/#g\" {$baseDir}/.gitmodules || true &&";
}
// Add shallow submodules flag if shallow clone is enabled
- $submoduleFlags = $isShallowCloneEnabled ? '--shallow-submodules' : '';
- $git_clone_command = "{$git_clone_command} GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git submodule update --init --recursive {$submoduleFlags}; fi";
+ $submoduleFlags = $isShallowCloneEnabled ? '--depth=1' : '';
+ $git_clone_command = "{$git_clone_command} git submodule sync && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git submodule update --init --recursive {$submoduleFlags}; fi";
}
if ($this->settings->is_git_lfs_enabled) {
$git_clone_command = "{$git_clone_command} && cd {$baseDir} && GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git lfs pull";
@@ -1139,9 +1139,17 @@ class Application extends BaseModel
$isShallowCloneEnabled = $this->settings?->is_git_shallow_clone_enabled ?? false;
$depthFlag = $isShallowCloneEnabled ? ' --depth=1' : '';
- $git_clone_command = "git clone{$depthFlag} -b {$escapedBranch}";
+ $submoduleFlags = '';
+ if ($this->settings->is_git_submodules_enabled) {
+ $submoduleFlags = ' --recurse-submodules';
+ if ($isShallowCloneEnabled) {
+ $submoduleFlags .= ' --shallow-submodules';
+ }
+ }
+
+ $git_clone_command = "git clone{$depthFlag}{$submoduleFlags} -b {$escapedBranch}";
if ($only_checkout) {
- $git_clone_command = "git clone{$depthFlag} --no-checkout -b {$escapedBranch}";
+ $git_clone_command = "git clone{$depthFlag}{$submoduleFlags} --no-checkout -b {$escapedBranch}";
}
if ($pull_request_id !== 0) {
$pr_branch_name = "pr-{$pull_request_id}-coolify";
diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php
index 1737ca714..ac707f7ab 100644
--- a/bootstrap/helpers/docker.php
+++ b/bootstrap/helpers/docker.php
@@ -256,12 +256,12 @@ function generateServiceSpecificFqdns(ServiceApplication|Application $resource)
if (str($MINIO_BROWSER_REDIRECT_URL->value ?? '')->isEmpty()) {
$MINIO_BROWSER_REDIRECT_URL->update([
- 'value' => generateFqdn(server: $server, random: 'console-'.$uuid, parserVersion: $resource->compose_parsing_version, forceHttps: true),
+ 'value' => generateFqdn(server: $server, random: 'console-'.$uuid, parserVersion: $resource->service->compose_parsing_version, forceHttps: true),
]);
}
if (str($MINIO_SERVER_URL->value ?? '')->isEmpty()) {
$MINIO_SERVER_URL->update([
- 'value' => generateFqdn(server: $server, random: 'minio-'.$uuid, parserVersion: $resource->compose_parsing_version, forceHttps: true),
+ 'value' => generateFqdn(server: $server, random: 'minio-'.$uuid, parserVersion: $resource->service->compose_parsing_version, forceHttps: true),
]);
}
$payload = collect([
@@ -279,12 +279,12 @@ function generateServiceSpecificFqdns(ServiceApplication|Application $resource)
if (str($LOGTO_ENDPOINT->value ?? '')->isEmpty()) {
$LOGTO_ENDPOINT->update([
- 'value' => generateFqdn(server: $server, random: 'logto-'.$uuid, parserVersion: $resource->compose_parsing_version),
+ 'value' => generateFqdn(server: $server, random: 'logto-'.$uuid, parserVersion: $resource->service->compose_parsing_version),
]);
}
if (str($LOGTO_ADMIN_ENDPOINT->value ?? '')->isEmpty()) {
$LOGTO_ADMIN_ENDPOINT->update([
- 'value' => generateFqdn(server: $server, random: 'logto-admin-'.$uuid, parserVersion: $resource->compose_parsing_version),
+ 'value' => generateFqdn(server: $server, random: 'logto-admin-'.$uuid, parserVersion: $resource->service->compose_parsing_version),
]);
}
$payload = collect([
diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php
index f35e73390..649b87212 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');
@@ -346,8 +551,14 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
if ($type->value() === 'bind') {
if ($source->value() === '/var/run/docker.sock') {
$volume = $source->value().':'.$target->value();
+ if (isset($parsed['mode']) && $parsed['mode']) {
+ $volume .= ':'.$parsed['mode']->value();
+ }
} elseif ($source->value() === '/tmp' || $source->value() === '/tmp/') {
$volume = $source->value().':'.$target->value();
+ if (isset($parsed['mode']) && $parsed['mode']) {
+ $volume .= ':'.$parsed['mode']->value();
+ }
} else {
if ((int) $resource->compose_parsing_version >= 4) {
$mainDirectory = str(base_configuration_dir().'/applications/'.$uuid);
@@ -381,6 +592,9 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
}
}
$volume = "$source:$target";
+ if (isset($parsed['mode']) && $parsed['mode']) {
+ $volume .= ':'.$parsed['mode']->value();
+ }
}
} elseif ($type->value() === 'volume') {
if ($topLevel->get('volumes')->has($source->value())) {
@@ -399,10 +613,14 @@ 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";
+ if (isset($parsed['mode']) && $parsed['mode']) {
+ $volume .= ':'.$parsed['mode']->value();
+ }
} elseif (is_array($volume)) {
data_set($volume, 'source', $name);
}
@@ -640,6 +858,7 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
if ($resource->build_pack !== 'dockercompose') {
$domains = collect([]);
}
+ $serviceName = str($serviceName)->replace('-', '_')->value();
$fqdns = data_get($domains, "$serviceName.domain");
// Generate SERVICE_FQDN & SERVICE_URL for dockercompose
if ($resource->build_pack === 'dockercompose') {
@@ -1311,8 +1530,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');
@@ -1353,8 +1574,14 @@ function serviceParser(Service $resource): Collection
if ($type->value() === 'bind') {
if ($source->value() === '/var/run/docker.sock') {
$volume = $source->value().':'.$target->value();
+ if (isset($parsed['mode']) && $parsed['mode']) {
+ $volume .= ':'.$parsed['mode']->value();
+ }
} elseif ($source->value() === '/tmp' || $source->value() === '/tmp/') {
$volume = $source->value().':'.$target->value();
+ if (isset($parsed['mode']) && $parsed['mode']) {
+ $volume .= ':'.$parsed['mode']->value();
+ }
} else {
if ((int) $resource->compose_parsing_version >= 4) {
$mainDirectory = str(base_configuration_dir().'/services/'.$uuid);
@@ -1385,6 +1612,9 @@ function serviceParser(Service $resource): Collection
}
}
$volume = "$source:$target";
+ if (isset($parsed['mode']) && $parsed['mode']) {
+ $volume .= ':'.$parsed['mode']->value();
+ }
}
} elseif ($type->value() === 'volume') {
if ($topLevel->get('volumes')->has($source->value())) {
@@ -1400,10 +1630,14 @@ 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";
+ if (isset($parsed['mode']) && $parsed['mode']) {
+ $volume .= ':'.$parsed['mode']->value();
+ }
} elseif (is_array($volume)) {
data_set($volume, 'source', $name);
}
@@ -1655,6 +1889,7 @@ function serviceParser(Service $resource): Collection
});
}
}
+ $serviceName = str($serviceName)->replace('-', '_')->value();
if (! $isDatabase && $fqdns instanceof Collection && $fqdns->count() > 0) {
$shouldGenerateLabelsExactly = $resource->server->settings->generate_exact_labels;
$uuid = $resource->uuid;
diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php
index 88bcd5538..7a9b5df80 100644
--- a/bootstrap/helpers/shared.php
+++ b/bootstrap/helpers/shared.php
@@ -418,8 +418,9 @@ function generateUrl(Server $server, string $random, bool $forceHttps = false):
return "$scheme://{$random}.$host$path";
}
-function generateFqdn(Server $server, string $random, bool $forceHttps = false, int $parserVersion = 4): string
+function generateFqdn(Server $server, string $random, bool $forceHttps = false, int $parserVersion = 5): string
{
+
$wildcard = data_get($server, 'settings.wildcard_domain');
if (is_null($wildcard) || $wildcard === '') {
$wildcard = sslip($server);
diff --git a/config/constants.php b/config/constants.php
index c932241d4..628be0ea2 100644
--- a/config/constants.php
+++ b/config/constants.php
@@ -2,7 +2,7 @@
return [
'coolify' => [
- 'version' => '4.0.0-beta.421',
+ 'version' => '4.0.0-beta.422',
'helper_version' => '1.0.10',
'realtime_version' => '1.0.10',
'self_hosted' => env('SELF_HOSTED', true),
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index 3fadd914c..e8402b7af 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -59,7 +59,7 @@ services:
SOKETI_DEFAULT_APP_SECRET: "${PUSHER_APP_SECRET:-coolify}"
entrypoint: ["/bin/sh", "/soketi-entrypoint.sh"]
vite:
- image: node:20-alpine
+ image: node:24-alpine
pull_policy: always
working_dir: /var/www/html
environment:
diff --git a/other/nightly/versions.json b/other/nightly/versions.json
index 21a9b6d70..30f1c226e 100644
--- a/other/nightly/versions.json
+++ b/other/nightly/versions.json
@@ -1,10 +1,10 @@
{
"coolify": {
"v4": {
- "version": "4.0.0-beta.421"
+ "version": "4.0.0-beta.422"
},
"nightly": {
- "version": "4.0.0-beta.422"
+ "version": "4.0.0-beta.423"
},
"helper": {
"version": "1.0.10"
diff --git a/resources/views/livewire/project/shared/storages/all.blade.php b/resources/views/livewire/project/shared/storages/all.blade.php
index f7bd7bdce..bd377e3b2 100644
--- a/resources/views/livewire/project/shared/storages/all.blade.php
+++ b/resources/views/livewire/project/shared/storages/all.blade.php
@@ -6,7 +6,8 @@
:resource="$resource" :isFirst="$loop->first" isReadOnly='true' isService='true' />
@else
@endif
@endforeach
diff --git a/templates/compose/matrix.yaml b/templates/compose/matrix.yaml
index a8178d058..09bd81b54 100644
--- a/templates/compose/matrix.yaml
+++ b/templates/compose/matrix.yaml
@@ -10,12 +10,12 @@ services:
image: matrixdotorg/synapse:latest
environment:
- SERVICE_URL_MATRIX_8008
- - SYNAPSE_SERVER_NAME=${SERVICE_URL_MATRIX}
+ - SYNAPSE_SERVER_NAME=${SERVICE_FQDN_MATRIX}
- SYNAPSE_REPORT_STATS=${SYNAPSE_REPORT_STATS:-no}
- ENABLE_REGISTRATION=${ENABLE_REGISTRATION:-false}
- RECAPTCHA_PUBLIC_KEY=${RECAPTCHA_PUBLIC_KEY}
- RECAPTCHA_PRIVATE_KEY=${RECAPTCHA_PRIVATE_KEY}
- - _SERVER_NAME=${SERVICE_URL_MATRIX}
+ - _SERVER_NAME=${SERVICE_FQDN_MATRIX}
- _ADMIN_NAME=${SERVICE_USER_ADMIN}
- _ADMIN_PASS=${SERVICE_PASSWORD_ADMIN}
volumes:
@@ -44,7 +44,7 @@ services:
# #
##########################
cat < /data/homeserver.yaml
- server_name: "${SERVICE_URL_MATRIX}"
+ server_name: "${SERVICE_FQDN_MATRIX}"
pid_file: /data/homeserver.pid
# server
@@ -64,7 +64,7 @@ services:
database: /data/homeserver.db
# general
- log_config: "/data/${SERVICE_URL_MATRIX}.log.config"
+ log_config: "/data/${SERVICE_FQDN_MATRIX}.log.config"
media_store_path: /data/media_store
report_stats: false
@@ -72,11 +72,11 @@ services:
registration_shared_secret: $(<./registration_shared_secret)
macaroon_secret_key: $(<./macaroon_secret_key)
form_secret: $(<./form_secret)
- signing_key_path: "/data/${SERVICE_URL_MATRIX}.signing.key"
+ signing_key_path: "/data/${SERVICE_FQDN_MATRIX}.signing.key"
#rooms
auto_join_rooms:
- - "#general:${SERVICE_URL_MATRIX}"
+ - "#general:${SERVICE_FQDN_MATRIX}"
# federation
trusted_key_servers:
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();
+});
+
+test('preserves mode when reconstructing volume strings', function () {
+ // Test cases that specifically verify mode preservation
+ $testCases = [
+ '/var/run/docker.sock:/var/run/docker.sock:ro' => ['source' => '/var/run/docker.sock', 'target' => '/var/run/docker.sock', 'mode' => 'ro'],
+ '/etc/localtime:/etc/localtime:ro' => ['source' => '/etc/localtime', 'target' => '/etc/localtime', 'mode' => 'ro'],
+ '/tmp:/tmp:rw' => ['source' => '/tmp', 'target' => '/tmp', 'mode' => 'rw'],
+ 'gitea-data:/data:ro' => ['source' => 'gitea-data', 'target' => '/data', 'mode' => 'ro'],
+ './config:/app/config:cached' => ['source' => './config', 'target' => '/app/config', 'mode' => 'cached'],
+ 'volume:/data:delegated' => ['source' => 'volume', 'target' => '/data', 'mode' => 'delegated'],
+ ];
+
+ foreach ($testCases as $input => $expected) {
+ $result = parseDockerVolumeString($input);
+
+ // Verify parsing
+ expect($result['source']->value())->toBe($expected['source']);
+ expect($result['target']->value())->toBe($expected['target']);
+ expect($result['mode']->value())->toBe($expected['mode']);
+
+ // Verify reconstruction would preserve the mode
+ $reconstructed = $result['source']->value().':'.$result['target']->value();
+ if ($result['mode']) {
+ $reconstructed .= ':'.$result['mode']->value();
+ }
+ expect($reconstructed)->toBe($input);
+ }
+});
diff --git a/versions.json b/versions.json
index 21a9b6d70..30f1c226e 100644
--- a/versions.json
+++ b/versions.json
@@ -1,10 +1,10 @@
{
"coolify": {
"v4": {
- "version": "4.0.0-beta.421"
+ "version": "4.0.0-beta.422"
},
"nightly": {
- "version": "4.0.0-beta.422"
+ "version": "4.0.0-beta.423"
},
"helper": {
"version": "1.0.10"