From 8d1c75ebff3ce772432dd500c0dc9a01a78a81ec Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Wed, 27 Aug 2025 15:18:22 +0200 Subject: [PATCH 1/9] chore: update version --- config/constants.php | 2 +- other/nightly/versions.json | 4 ++-- versions.json | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) 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/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/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" From b119d4f95c4f5b07a9a0a708948f5e4094f99209 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Wed, 27 Aug 2025 15:18:52 +0200 Subject: [PATCH 2/9] chore: update development node version --- docker-compose.dev.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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: From 4a1eb064a46ecb6c3e32958ad5f3b614e09a013b Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 27 Aug 2025 16:22:37 +0200 Subject: [PATCH 3/9] fix(parsers): replace hyphens with underscores in service names for consistency. this allows to properly parse custom domains in docker compose based applications --- bootstrap/helpers/parsers.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index f35e73390..5737488c0 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -640,6 +640,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') { @@ -1655,6 +1656,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; From 5fbad08e4545788c113d13adbb51288e0e10fbd1 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 27 Aug 2025 16:39:38 +0200 Subject: [PATCH 4/9] fix(parsers): implement parseDockerVolumeString function to handle various Docker volume formats and modes, including environment variables and Windows paths. Add unit tests for comprehensive coverage. --- bootstrap/helpers/parsers.php | 225 +++++++++++++++++- .../{Services => }/DockerImageParserTest.php | 2 +- tests/Unit/ParseDockerVolumeStringTest.php | 193 +++++++++++++++ 3 files changed, 411 insertions(+), 9 deletions(-) rename tests/Unit/{Services => }/DockerImageParserTest.php (98%) create mode 100644 tests/Unit/ParseDockerVolumeStringTest.php 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(); +}); From c2ff9eae0d2809d1be37b0f89964c1b334a9a2a6 Mon Sep 17 00:00:00 2001 From: broesch <908081+broesch@users.noreply.github.com> Date: Wed, 27 Aug 2025 16:51:08 +0200 Subject: [PATCH 5/9] fix(git): submodule update command uses an unsupported option (#6454) --- CHANGELOG.md | 42 ++++++++++++++++++++++++++++++++++---- app/Models/Application.php | 2 +- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63c4554fe..153776f1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,16 +6,44 @@ All notable changes to this project will be documented in this file. ### 🚀 Features -- *(docs)* Expand Backlog.md guidelines with comprehensive usage instructions, CLI commands, and best practices for task management to enhance project organization and collaboration +- *(policies)* Add EnvironmentVariablePolicy for managing environment variables ( it was missing ) + +### 🐛 Bug Fixes + +- *(backups)* Rollback helper update for now + +### 📚 Documentation + +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- *(core)* Update version +- *(versions)* Update coolify version to 4.0.0-beta.421 and nightly version to 4.0.0-beta.422 + +## [4.0.0-beta.420.9] - 2025-08-26 + +### 🐛 Bug Fixes + +- *(backups)* S3 backup upload is failing + +### 📚 Documentation + +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- *(core)* Update version + +## [4.0.0-beta.420.8] - 2025-08-26 ### 🚜 Refactor - *(policies)* Remove Response type hint from update methods in ApplicationPreviewPolicy and DatabasePolicy for improved flexibility -### ⚙️ Miscellaneous Tasks +### 📚 Documentation -- *(versions)* Update coolify version to 4.0.0-beta.420.8 and nightly version to 4.0.0-beta.420.9 in versions.json and constants.php -- *(docker)* Update soketi image version to 1.0.10 in production and Windows configurations +- Update changelog ## [4.0.0-beta.420.7] - 2025-08-26 @@ -81,6 +109,7 @@ All notable changes to this project will be documented in this file. - *(docs)* Add Backlog.md guidelines and project manager backlog agent; enhance CLAUDE.md with new links for task management - *(docs)* Add tasks for implementing Docker build caching and optimizing staging builds; include detailed acceptance criteria and implementation plans - *(docker)* Implement Docker cleanup processing in ScheduledJobManager; refactor server task scheduling to streamline cleanup job dispatching +- *(docs)* Expand Backlog.md guidelines with comprehensive usage instructions, CLI commands, and best practices for task management to enhance project organization and collaboration ### 🐛 Bug Fixes @@ -158,12 +187,15 @@ All notable changes to this project will be documented in this file. - *(validation)* Implement centralized validation patterns across components - *(jobs)* Rename job classes to indicate deprecation status - Update check frequency logic for cloud and self-hosted environments; streamline server task scheduling and timezone handling +- *(policies)* Remove Response type hint from update methods in ApplicationPreviewPolicy and DatabasePolicy for improved flexibility ### 📚 Documentation - *(claude)* Clarify that artisan commands should only be run inside the "coolify" container during development - Add AGENTS.md for project guidance and development instructions - Update changelog +- Update changelog +- Update changelog ### ⚙️ Miscellaneous Tasks @@ -186,6 +218,8 @@ All notable changes to this project will be documented in this file. - *(cleanup)* Remove unused GitLab view files for change, new, and show pages - *(workflows)* Add backlog directory to build triggers for production and staging workflows - *(config)* Disable auto_commit in backlog configuration to prevent automatic commits +- *(versions)* Update coolify version to 4.0.0-beta.420.8 and nightly version to 4.0.0-beta.420.9 in versions.json and constants.php +- *(docker)* Update soketi image version to 1.0.10 in production and Windows configurations ### ◀️ Revert diff --git a/app/Models/Application.php b/app/Models/Application.php index f8f86d1f9..3db6e8eb7 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -998,7 +998,7 @@ 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' : ''; + $submoduleFlags = $isShallowCloneEnabled ? '--depth=1' : ''; $git_clone_command = "{$git_clone_command} 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) { From 115a03947f67bb07478d467f50a9fea62926faa1 Mon Sep 17 00:00:00 2001 From: Nicanor Alexander de la Cruz Caba Date: Wed, 27 Aug 2025 10:53:22 -0400 Subject: [PATCH 6/9] fix(service): swap URL for FQDN on matrix template (#6466) --- templates/compose/matrix.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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: From cde528bf5eb3c1cf01c27ea9ed14a566d2b8bc31 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 27 Aug 2025 16:54:44 +0200 Subject: [PATCH 7/9] fix(parsers): enhance volume string handling by preserving mode in application and service parsers. Update related unit tests for validation. --- bootstrap/helpers/parsers.php | 24 ++++++++++++++++ .../project/shared/storages/all.blade.php | 3 +- tests/Unit/ParseDockerVolumeStringTest.php | 28 +++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 0fadaf7c7..649b87212 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -551,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); @@ -586,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())) { @@ -609,6 +618,9 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int $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); } @@ -1562,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); @@ -1594,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())) { @@ -1614,6 +1635,9 @@ function serviceParser(Service $resource): Collection $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); } 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/tests/Unit/ParseDockerVolumeStringTest.php b/tests/Unit/ParseDockerVolumeStringTest.php index 3b993efb5..6d31725e3 100644 --- a/tests/Unit/ParseDockerVolumeStringTest.php +++ b/tests/Unit/ParseDockerVolumeStringTest.php @@ -191,3 +191,31 @@ test('parses complex real-world examples', function () { 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); + } +}); From 21fc1ba254ca60f3d32497421ec230931e167b77 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 27 Aug 2025 17:02:38 +0200 Subject: [PATCH 8/9] fix(docker): update parser version in FQDN generation for service-specific URLs --- bootstrap/helpers/docker.php | 8 ++++---- bootstrap/helpers/shared.php | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) 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/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); From 6c560261f21f9bb29edd375f857553f6d1ec901a Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Wed, 27 Aug 2025 17:13:28 +0200 Subject: [PATCH 9/9] refactor(git): improve submodule cloning --- app/Models/Application.php | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/app/Models/Application.php b/app/Models/Application.php index 3db6e8eb7..378161602 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -999,7 +999,7 @@ class Application extends BaseModel } // Add shallow submodules flag if shallow clone is enabled $submoduleFlags = $isShallowCloneEnabled ? '--depth=1' : ''; - $git_clone_command = "{$git_clone_command} GIT_SSH_COMMAND=\"ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git submodule update --init --recursive {$submoduleFlags}; fi"; + $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";