diff --git a/CHANGELOG.md b/CHANGELOG.md index 3447b223b..aefabfd29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,78 @@ # Changelog All notable changes to this project will be documented in this file. -## [unreleased] +## [4.0.0-beta.434] - 2025-10-03 + +### 🚀 Features + +- *(deployments)* Enhance Docker build argument handling for multiline variables +- *(deployments)* Add log copying functionality to clipboard in dev +- *(deployments)* Generate SERVICE_NAME environment variables from Docker Compose services + +### 🐛 Bug Fixes + +- *(deployments)* Enhance builder container management and environment variable handling + +### 📚 Documentation + +- Update changelog +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- *(versions)* Update version numbers for Coolify releases +- *(versions)* Bump Coolify stable version to 4.0.0-beta.434 + +## [4.0.0-beta.433] - 2025-10-01 + +### 🚀 Features + +- *(user-deletion)* Implement file locking to prevent concurrent user deletions and enhance error handling +- *(ui)* Enhance resource operations interface with dynamic selection for cloning and moving resources +- *(global-search)* Integrate projects and environments into global search functionality +- *(storage)* Consolidate storage management into a single component with enhanced UI +- *(deployments)* Add support for Coolify variables in Dockerfile + +### 🐛 Bug Fixes + +- *(workflows)* Update CLAUDE API key reference in GitHub Actions workflow +- *(ui)* Update docker registry image helper text for clarity +- *(ui)* Correct HTML structure and improve clarity in Docker cleanup options +- *(workflows)* Update CLAUDE API key reference in GitHub Actions workflow +- *(api)* Correct OpenAPI schema annotations for array items +- *(ui)* Improve queued deployment status readability in dark mode +- *(git)* Handle additional repository URL cases for 'tangled' and improve branch assignment logic +- *(git)* Enhance error handling for missing branch information during deployment +- *(git)* Trim whitespace from repository, branch, and commit SHA fields +- *(deployments)* Order deployments by ID for consistent retrieval + +### 💼 Other + +- *(storage)* Enhance file storage management with new properties and UI improvements +- *(core)* Update projects property type and enhance UI styling +- *(components)* Adjust SVG icon sizes for consistency across applications and services +- *(components)* Auto-focus first input in modal on open +- *(styles)* Enhance focus styles for buttons and links +- *(components)* Enhance close button accessibility in modal + +### 🚜 Refactor + +- *(global-search)* Change event listener to window level for global search modal +- *(dashboard)* Remove deployment loading logic and introduce DeploymentsIndicator component for better UI management +- *(dashboard)* Replace project navigation method with direct link in UI +- *(global-search)* Improve event handling and cleanup in global search component + +### 📚 Documentation + +- Update changelog +- Update changelog +- Update changelog + +### ⚙️ Miscellaneous Tasks + +- *(versions)* Update coolify version to 4.0.0-beta.433 and nightly version to 4.0.0-beta.434 in configuration files + +## [4.0.0-beta.432] - 2025-09-29 ### 🚀 Features @@ -188,6 +259,7 @@ ## [4.0.0-beta.427] - 2025-09-15 ### 🚀 Features +- Add Ente Photos service template - *(command)* Add option to sync GitHub releases to BunnyCDN and refactor sync logic - *(ui)* Display current version in settings dropdown and update UI accordingly - *(settings)* Add option to restrict PR deployments to repository members and contributors diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index ce9e723d4..065d7f767 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -1512,9 +1512,32 @@ private function create_application(Request $request, $type) if ($return instanceof \Illuminate\Http\JsonResponse) { return $return; } - if (! $request->docker_registry_image_tag) { - $request->offsetSet('docker_registry_image_tag', 'latest'); + // Process docker image name and tag for SHA256 digests + $dockerImageName = $request->docker_registry_image_name; + $dockerImageTag = $request->docker_registry_image_tag; + + // Strip 'sha256:' prefix if user provided it in the tag + if ($dockerImageTag) { + $dockerImageTag = preg_replace('/^sha256:/i', '', trim($dockerImageTag)); } + + // Remove @sha256 from image name if user added it + if ($dockerImageName) { + $dockerImageName = preg_replace('/@sha256$/i', '', trim($dockerImageName)); + } + + // Check if tag is a valid SHA256 hash (64 hex characters) + $isSha256Hash = $dockerImageTag && preg_match('/^[a-f0-9]{64}$/i', $dockerImageTag); + + // Append @sha256 to image name if using digest and not already present + if ($isSha256Hash && ! str_ends_with($dockerImageName, '@sha256')) { + $dockerImageName .= '@sha256'; + } + + // Set processed values back to request + $request->offsetSet('docker_registry_image_name', $dockerImageName); + $request->offsetSet('docker_registry_image_tag', $dockerImageTag ?: 'latest'); + $application = new Application; removeUnnecessaryFieldsFromRequest($request); diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 04b11b9b4..2a3f20f37 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -116,16 +116,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private $env_args; - private $environment_variables; - private $env_nixpacks_args; private $docker_compose; private $docker_compose_base64; - private ?string $env_filename = null; - private ?string $nixpacks_plan = null; private Collection $nixpacks_plan_json; @@ -503,7 +499,12 @@ private function deploy_dockerimage_buildpack() } else { $this->dockerImageTag = $this->application->docker_registry_image_tag; } - $this->application_deployment_queue->addLogEntry("Starting deployment of {$this->dockerImage}:{$this->dockerImageTag} to {$this->server->name}."); + + // Check if this is an image hash deployment + $isImageHash = str($this->dockerImageTag)->startsWith('sha256-'); + $displayName = $isImageHash ? "{$this->dockerImage}@sha256:".str($this->dockerImageTag)->after('sha256-') : "{$this->dockerImage}:{$this->dockerImageTag}"; + + $this->application_deployment_queue->addLogEntry("Starting deployment of {$displayName} to {$this->server->name}."); $this->generate_image_names(); $this->prepare_builder_image(); $this->generate_compose_file(); @@ -571,7 +572,6 @@ private function deploy_docker_compose_buildpack() if ($this->application->settings->is_raw_compose_deployment_enabled) { $this->application->oldRawParser(); $yaml = $composeFile = $this->application->docker_compose_raw; - $this->generate_runtime_environment_variables(); // For raw compose, we cannot automatically add secrets configuration // User must define it manually in their docker-compose file @@ -580,16 +580,14 @@ private function deploy_docker_compose_buildpack() } } else { $composeFile = $this->application->parse(pull_request_id: $this->pull_request_id, preview_id: data_get($this->preview, 'id')); - $this->generate_runtime_environment_variables(); - if (filled($this->env_filename)) { - $services = collect(data_get($composeFile, 'services', [])); - $services = $services->map(function ($service, $name) { - $service['env_file'] = [$this->env_filename]; + // Always add .env file to services + $services = collect(data_get($composeFile, 'services', [])); + $services = $services->map(function ($service, $name) { + $service['env_file'] = ['.env']; - return $service; - }); - $composeFile['services'] = $services->toArray(); - } + return $service; + }); + $composeFile['services'] = $services->toArray(); if (empty($composeFile)) { $this->application_deployment_queue->addLogEntry('Failed to parse docker-compose file.'); $this->fail('Failed to parse docker-compose file.'); @@ -615,6 +613,9 @@ private function deploy_docker_compose_buildpack() // Build new container to limit downtime. $this->application_deployment_queue->addLogEntry('Pulling & building required images.'); + // Save build-time .env file BEFORE the build + $this->save_buildtime_environment_variables(); + if ($this->docker_compose_custom_build_command) { // Prepend DOCKER_BUILDKIT=1 if BuildKit is supported $build_command = $this->docker_compose_custom_build_command; @@ -630,9 +631,8 @@ private function deploy_docker_compose_buildpack() if ($this->dockerBuildkitSupported) { $command = "DOCKER_BUILDKIT=1 {$command}"; } - if (filled($this->env_filename)) { - $command .= " --env-file {$this->workdir}/{$this->env_filename}"; - } + // Always use .env file + $command .= " --env-file {$this->workdir}/.env"; if ($this->force_rebuild) { $command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} build --pull --no-cache"; } else { @@ -652,6 +652,10 @@ private function deploy_docker_compose_buildpack() ); } + // Save runtime environment variables AFTER the build + // This overwrites the build-time .env with ALL variables (build-time + runtime) + $this->save_runtime_environment_variables(); + $this->stop_running_container(force: true); $this->application_deployment_queue->addLogEntry('Starting new application.'); $networkId = $this->application->uuid; @@ -685,9 +689,8 @@ private function deploy_docker_compose_buildpack() $this->docker_compose_location = '/docker-compose.yaml'; $command = "{$this->coolify_variables} docker compose"; - if (filled($this->env_filename)) { - $command .= " --env-file {$server_workdir}/{$this->env_filename}"; - } + // Always use .env file + $command .= " --env-file {$server_workdir}/.env"; $command .= " --project-directory {$server_workdir} -f {$server_workdir}{$this->docker_compose_location} up -d"; $this->execute_remote_command( ['command' => $command, 'hidden' => true], @@ -702,9 +705,8 @@ private function deploy_docker_compose_buildpack() } else { $command = "{$this->coolify_variables} docker compose"; if ($this->preserveRepository) { - if (filled($this->env_filename)) { - $command .= " --env-file {$server_workdir}/{$this->env_filename}"; - } + // Always use .env file + $command .= " --env-file {$server_workdir}/.env"; $command .= " --project-name {$this->application->uuid} --project-directory {$server_workdir} -f {$server_workdir}{$this->docker_compose_location} up -d"; $this->write_deployment_configurations(); @@ -712,9 +714,8 @@ private function deploy_docker_compose_buildpack() ['command' => $command, 'hidden' => true], ); } else { - if (filled($this->env_filename)) { - $command .= " --env-file {$this->workdir}/{$this->env_filename}"; - } + // Always use .env file + $command .= " --env-file {$this->workdir}/.env"; $command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} up -d"; $this->execute_remote_command( [executeInDocker($this->deployment_uuid, $command), 'hidden' => true], @@ -748,9 +749,18 @@ private function deploy_dockerfile_buildpack() } $this->cleanup_git(); $this->generate_compose_file(); + + // Save build-time .env file BEFORE the build + $this->save_buildtime_environment_variables(); + $this->generate_build_env_variables(); $this->add_build_env_variables_to_dockerfile(); $this->build_image(); + + // Save runtime environment variables AFTER the build + // This overwrites the build-time .env with ALL variables (build-time + runtime) + $this->save_runtime_environment_variables(); + $this->push_to_docker_registry(); $this->rolling_update(); } @@ -774,11 +784,15 @@ private function deploy_nixpacks_buildpack() $this->cleanup_git(); $this->generate_nixpacks_confs(); $this->generate_compose_file(); + + // Save build-time .env file BEFORE the build for Nixpacks + $this->save_buildtime_environment_variables(); + $this->generate_build_env_variables(); $this->build_image(); // For Nixpacks, save runtime environment variables AFTER the build - // to prevent them from being accessible during the build process + // This overwrites the build-time .env with ALL variables (build-time + runtime) $this->save_runtime_environment_variables(); $this->push_to_docker_registry(); $this->rolling_update(); @@ -802,7 +816,16 @@ private function deploy_static_buildpack() $this->clone_repository(); $this->cleanup_git(); $this->generate_compose_file(); + + // Save build-time .env file BEFORE the build + $this->save_buildtime_environment_variables(); + $this->build_static_image(); + + // Save runtime environment variables AFTER the build + // This overwrites the build-time .env with ALL variables (build-time + runtime) + $this->save_runtime_environment_variables(); + $this->push_to_docker_registry(); $this->rolling_update(); } @@ -934,7 +957,13 @@ private function generate_image_names() $this->production_image_name = "{$this->application->uuid}:latest"; } } elseif ($this->application->build_pack === 'dockerimage') { - $this->production_image_name = "{$this->dockerImage}:{$this->dockerImageTag}"; + // Check if this is an image hash deployment + if (str($this->dockerImageTag)->startsWith('sha256-')) { + $hash = str($this->dockerImageTag)->after('sha256-'); + $this->production_image_name = "{$this->dockerImage}@sha256:{$hash}"; + } else { + $this->production_image_name = "{$this->dockerImage}:{$this->dockerImageTag}"; + } } elseif ($this->pull_request_id !== 0) { if ($this->application->docker_registry_image_name) { $this->build_image_name = "{$this->application->docker_registry_image_name}:pr-{$this->pull_request_id}-build"; @@ -1049,8 +1078,6 @@ private function generate_runtime_environment_variables() $envs->push($key.'='.$item); }); if ($this->pull_request_id === 0) { - $this->env_filename = '.env'; - // Generate SERVICE_ variables first for dockercompose if ($this->build_pack === 'dockercompose') { $domains = collect(json_decode($this->application->docker_compose_domains)) ?? collect([]); @@ -1109,8 +1136,6 @@ private function generate_runtime_environment_variables() $envs->push('HOST=0.0.0.0'); } } else { - $this->env_filename = '.env'; - // Generate SERVICE_ variables first for dockercompose preview if ($this->build_pack === 'dockercompose') { $domains = collect(json_decode(data_get($this->preview, 'docker_compose_domains'))) ?? collect([]); @@ -1165,99 +1190,250 @@ private function generate_runtime_environment_variables() $envs->push('HOST=0.0.0.0'); } } - if ($envs->isEmpty()) { - if ($this->env_filename) { - if ($this->use_build_server) { - $this->server = $this->original_server; - $this->execute_remote_command( - [ - 'command' => "rm -f $this->configuration_dir/{$this->env_filename}", - 'hidden' => true, - 'ignore_errors' => true, - ] - ); - $this->server = $this->build_server; - $this->execute_remote_command( - [ - 'command' => "rm -f $this->configuration_dir/{$this->env_filename}", - 'hidden' => true, - 'ignore_errors' => true, - ] - ); - } else { - $this->execute_remote_command( - [ - 'command' => "rm -f $this->configuration_dir/{$this->env_filename}", - 'hidden' => true, - 'ignore_errors' => true, - ] - ); - } - } - $this->env_filename = null; - } else { - // For Nixpacks builds, we save the .env file AFTER the build to prevent - // runtime-only variables from being accessible during the build process - if ($this->application->build_pack !== 'nixpacks' || $this->skip_build) { - $envs_base64 = base64_encode($envs->implode("\n")); - $this->execute_remote_command( - [ - executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/{$this->env_filename} > /dev/null"), - ], - ); - if ($this->use_build_server) { - $this->server = $this->original_server; - $this->execute_remote_command( - [ - "echo '$envs_base64' | base64 -d | tee $this->configuration_dir/{$this->env_filename} > /dev/null", - ] - ); - $this->server = $this->build_server; - } else { - $this->execute_remote_command( - [ - "echo '$envs_base64' | base64 -d | tee $this->configuration_dir/{$this->env_filename} > /dev/null", - ] - ); - } - } - } - $this->environment_variables = $envs; + // Return the generated environment variables instead of storing them globally + return $envs; } private function save_runtime_environment_variables() { - // This method saves the .env file with runtime variables - // It should be called AFTER the build for Nixpacks to prevent runtime-only variables - // from being accessible during the build process + // This method saves the .env file with ALL runtime variables + // For builds, it should be called AFTER the build to include runtime-only variables - if ($this->environment_variables && $this->environment_variables->isNotEmpty() && $this->env_filename) { - $envs_base64 = base64_encode($this->environment_variables->implode("\n")); + // Generate runtime environment variables locally + $environment_variables = $this->generate_runtime_environment_variables(); - // Write .env file to workdir (for container runtime) + // Handle empty environment variables + if ($environment_variables->isEmpty()) { + // For Docker Compose, we need to create an empty .env file + // because we always reference it in the compose file + if ($this->build_pack === 'dockercompose') { + $this->application_deployment_queue->addLogEntry('Creating empty .env file (no environment variables defined).'); + + // Create empty .env file + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, "touch $this->workdir/.env"), + ] + ); + + // Also create in configuration directory + if ($this->use_build_server) { + $this->server = $this->original_server; + $this->execute_remote_command( + [ + "touch $this->configuration_dir/.env", + ] + ); + $this->server = $this->build_server; + } else { + $this->execute_remote_command( + [ + "touch $this->configuration_dir/.env", + ] + ); + } + } else { + // For non-Docker Compose deployments, clean up any existing .env files + if ($this->use_build_server) { + $this->server = $this->original_server; + $this->execute_remote_command( + [ + 'command' => "rm -f $this->configuration_dir/.env", + 'hidden' => true, + 'ignore_errors' => true, + ] + ); + $this->server = $this->build_server; + $this->execute_remote_command( + [ + 'command' => "rm -f $this->configuration_dir/.env", + 'hidden' => true, + 'ignore_errors' => true, + ] + ); + } else { + $this->execute_remote_command( + [ + 'command' => "rm -f $this->configuration_dir/.env", + 'hidden' => true, + 'ignore_errors' => true, + ] + ); + } + } + + return; + } + + // Write the environment variables to file + $envs_base64 = base64_encode($environment_variables->implode("\n")); + + // Write .env file to workdir (for container runtime) + $this->application_deployment_queue->addLogEntry('Creating .env file with runtime variables for build phase.', hidden: true); + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/.env > /dev/null"), + ], + [ + executeInDocker($this->deployment_uuid, "cat $this->workdir/.env"), + 'hidden' => true, + + ] + ); + + // Write .env file to configuration directory + if ($this->use_build_server) { + $this->server = $this->original_server; $this->execute_remote_command( [ - executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/{$this->env_filename} > /dev/null"), + "echo '$envs_base64' | base64 -d | tee $this->configuration_dir/.env > /dev/null", + ] + ); + $this->server = $this->build_server; + } else { + $this->execute_remote_command( + [ + "echo '$envs_base64' | base64 -d | tee $this->configuration_dir/.env > /dev/null", + ] + ); + } + } + + private function generate_buildtime_environment_variables() + { + $envs = collect([]); + $coolify_envs = $this->generate_coolify_env_variables(); + + // Add COOLIFY variables + $coolify_envs->each(function ($item, $key) use ($envs) { + $envs->push($key.'='.$item); + }); + + // Add SERVICE_NAME variables for Docker Compose builds + if ($this->build_pack === 'dockercompose') { + if ($this->pull_request_id === 0) { + // Generate SERVICE_NAME for dockercompose services from processed compose + if ($this->application->settings->is_raw_compose_deployment_enabled) { + $dockerCompose = Yaml::parse($this->application->docker_compose_raw); + } else { + $dockerCompose = Yaml::parse($this->application->docker_compose); + } + $services = data_get($dockerCompose, 'services', []); + foreach ($services as $serviceName => $_) { + $envs->push('SERVICE_NAME_'.str($serviceName)->upper().'='.$serviceName); + } + + // Generate SERVICE_FQDN & SERVICE_URL for non-PR deployments + $domains = collect(json_decode($this->application->docker_compose_domains)) ?? collect([]); + foreach ($domains as $forServiceName => $domain) { + $parsedDomain = data_get($domain, 'domain'); + if (filled($parsedDomain)) { + $parsedDomain = str($parsedDomain)->explode(',')->first(); + $coolifyUrl = Url::fromString($parsedDomain); + $coolifyScheme = $coolifyUrl->getScheme(); + $coolifyFqdn = $coolifyUrl->getHost(); + $coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null); + $envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.$coolifyUrl->__toString()); + $envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.$coolifyFqdn); + } + } + } else { + // Generate SERVICE_NAME for preview deployments + $rawDockerCompose = Yaml::parse($this->application->docker_compose_raw); + $rawServices = data_get($rawDockerCompose, 'services', []); + foreach ($rawServices as $rawServiceName => $_) { + $envs->push('SERVICE_NAME_'.str($rawServiceName)->upper().'='.addPreviewDeploymentSuffix($rawServiceName, $this->pull_request_id)); + } + + // Generate SERVICE_FQDN & SERVICE_URL for preview deployments with PR-specific domains + $domains = collect(json_decode(data_get($this->preview, 'docker_compose_domains'))) ?? collect([]); + foreach ($domains as $forServiceName => $domain) { + $parsedDomain = data_get($domain, 'domain'); + if (filled($parsedDomain)) { + $parsedDomain = str($parsedDomain)->explode(',')->first(); + $coolifyUrl = Url::fromString($parsedDomain); + $coolifyScheme = $coolifyUrl->getScheme(); + $coolifyFqdn = $coolifyUrl->getHost(); + $coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null); + $envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.$coolifyUrl->__toString()); + $envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.$coolifyFqdn); + } + } + } + } + + // Add build-time user variables only + if ($this->pull_request_id === 0) { + $sorted_environment_variables = $this->application->environment_variables() + ->where('key', 'not like', 'NIXPACKS_%') + ->where('is_buildtime', true) // ONLY build-time variables + ->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id') + ->get(); + + // For Docker Compose, filter out SERVICE_FQDN and SERVICE_URL as we generate these + if ($this->build_pack === 'dockercompose') { + $sorted_environment_variables = $sorted_environment_variables->filter(function ($env) { + return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_'); + }); + } + + foreach ($sorted_environment_variables as $env) { + $envs->push($env->key.'='.$env->real_value); + } + } else { + $sorted_environment_variables = $this->application->environment_variables_preview() + ->where('key', 'not like', 'NIXPACKS_%') + ->where('is_buildtime', true) // ONLY build-time variables + ->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id') + ->get(); + + // For Docker Compose, filter out SERVICE_FQDN and SERVICE_URL as we generate these with PR-specific values + if ($this->build_pack === 'dockercompose') { + $sorted_environment_variables = $sorted_environment_variables->filter(function ($env) { + return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_'); + }); + } + + foreach ($sorted_environment_variables as $env) { + $envs->push($env->key.'='.$env->real_value); + } + } + + // Return the generated environment variables + return $envs; + } + + private function save_buildtime_environment_variables() + { + // Generate build-time environment variables locally + $environment_variables = $this->generate_buildtime_environment_variables(); + + // Save .env file for build phase + if ($environment_variables->isNotEmpty()) { + $envs_base64 = base64_encode($environment_variables->implode("\n")); + + $this->application_deployment_queue->addLogEntry('Creating .env file with build-time variables for build phase.', hidden: true); + + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/.env > /dev/null"), + ], + [ + executeInDocker($this->deployment_uuid, "cat $this->workdir/.env"), + 'hidden' => true, ], ); + } elseif ($this->build_pack === 'dockercompose') { + // For Docker Compose, create an empty .env file even if there are no build-time variables + // This ensures the file exists when referenced in docker-compose commands + $this->application_deployment_queue->addLogEntry('Creating empty .env file for build phase (no build-time variables defined).', hidden: true); - // Write .env file to configuration directory - if ($this->use_build_server) { - $this->server = $this->original_server; - $this->execute_remote_command( - [ - "echo '$envs_base64' | base64 -d | tee $this->configuration_dir/{$this->env_filename} > /dev/null", - ] - ); - $this->server = $this->build_server; - } else { - $this->execute_remote_command( - [ - "echo '$envs_base64' | base64 -d | tee $this->configuration_dir/{$this->env_filename} > /dev/null", - ] - ); - } + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, "touch $this->workdir/.env"), + ] + ); } } @@ -1472,15 +1648,18 @@ private function deploy_pull_request() $this->generate_nixpacks_confs(); } $this->generate_compose_file(); + + // Save build-time .env file BEFORE the build + $this->save_buildtime_environment_variables(); + $this->generate_build_env_variables(); if ($this->application->build_pack === 'dockerfile') { $this->add_build_env_variables_to_dockerfile(); } $this->build_image(); - // For Nixpacks, save runtime environment variables AFTER the build - if ($this->application->build_pack === 'nixpacks') { - $this->save_runtime_environment_variables(); - } + + // This overwrites the build-time .env with ALL variables (build-time + runtime) + $this->save_runtime_environment_variables(); $this->push_to_docker_registry(); $this->rolling_update(); } @@ -1526,7 +1705,6 @@ private function prepare_builder_image() $this->dockerConfigFileExists = instant_remote_process(["test -f {$this->serverUserHomeDir}/.docker/config.json && echo 'OK' || echo 'NOK'"], $this->server); $env_flags = $this->generate_docker_env_flags_for_secrets(); - if ($this->use_build_server) { if ($this->dockerConfigFileExists === 'NOK') { throw new RuntimeException('Docker config file (~/.docker/config.json) not found on the build server. Please run "docker login" to login to the docker registry on the server.'); @@ -1553,6 +1731,18 @@ private function prepare_builder_image() $this->run_pre_deployment_command(); } + private function restart_builder_container_with_actual_commit() + { + // Stop and remove the current helper container + $this->graceful_shutdown_container($this->deployment_uuid); + + // Clear cached env_args to force regeneration with actual SOURCE_COMMIT value + $this->env_args = null; + + // Restart the helper container with updated environment variables (including actual SOURCE_COMMIT) + $this->prepare_builder_image(); + } + private function deploy_to_additional_destinations() { if ($this->application->additional_networks->count() === 0) { @@ -1621,6 +1811,8 @@ private function set_coolify_variables() if (isset($this->application->git_branch)) { $this->coolify_variables .= "COOLIFY_BRANCH={$this->application->git_branch} "; } + $this->coolify_variables .= "COOLIFY_RESOURCE_UUID={$this->application->uuid} "; + $this->coolify_variables .= "COOLIFY_CONTAINER_NAME={$this->container_name} "; } private function check_git_if_build_needed() @@ -1688,6 +1880,12 @@ private function check_git_if_build_needed() $this->application_deployment_queue->save(); } $this->set_coolify_variables(); + + // Restart helper container with actual SOURCE_COMMIT value + if ($this->application->settings->use_build_secrets && $this->commit !== 'HEAD') { + $this->application_deployment_queue->addLogEntry('Restarting helper container with actual SOURCE_COMMIT value.'); + $this->restart_builder_container_with_actual_commit(); + } } private function clone_repository() @@ -1935,11 +2133,14 @@ private function generate_env_variables() { $this->env_args = collect([]); $this->env_args->put('SOURCE_COMMIT', $this->commit); + $coolify_envs = $this->generate_coolify_env_variables(); + $coolify_envs->each(function ($value, $key) { + $this->env_args->put($key, $value); + }); // For build process, include only environment variables where is_buildtime = true if ($this->pull_request_id === 0) { - // Get environment variables that are marked as available during build $envs = $this->application->environment_variables() ->where('key', 'not like', 'NIXPACKS_%') ->where('is_buildtime', true) @@ -1948,24 +2149,9 @@ private function generate_env_variables() foreach ($envs as $env) { if (! is_null($env->real_value)) { $this->env_args->put($env->key, $env->real_value); - if (str($env->real_value)->startsWith('$')) { - $variable_key = str($env->real_value)->after('$'); - if ($variable_key->startsWith('COOLIFY_')) { - $variable = $coolify_envs->get($variable_key->value()); - if (filled($variable)) { - $this->env_args->prepend($variable, $variable_key->value()); - } - } else { - $variable = $this->application->environment_variables()->where('key', $variable_key)->first(); - if ($variable) { - $this->env_args->prepend($variable->real_value, $env->key); - } - } - } } } } else { - // Get preview environment variables that are marked as available during build $envs = $this->application->environment_variables_preview() ->where('key', 'not like', 'NIXPACKS_%') ->where('is_buildtime', true) @@ -1974,20 +2160,6 @@ private function generate_env_variables() foreach ($envs as $env) { if (! is_null($env->real_value)) { $this->env_args->put($env->key, $env->real_value); - if (str($env->real_value)->startsWith('$')) { - $variable_key = str($env->real_value)->after('$'); - if ($variable_key->startsWith('COOLIFY_')) { - $variable = $coolify_envs->get($variable_key->value()); - if (filled($variable)) { - $this->env_args->prepend($variable, $variable_key->value()); - } - } else { - $variable = $this->application->environment_variables_preview()->where('key', $variable_key)->first(); - if ($variable) { - $this->env_args->prepend($variable->real_value, $env->key); - } - } - } } } } @@ -2001,7 +2173,6 @@ private function generate_compose_file() $persistent_storages = $this->generate_local_persistent_volumes(); $persistent_file_volumes = $this->application->fileStorages()->get(); $volume_names = $this->generate_local_persistent_volumes_only_volume_names(); - $this->generate_runtime_environment_variables(); if (data_get($this->application, 'custom_labels')) { $this->application->parseContainerLabels(); $labels = collect(preg_split("/\r\n|\n|\r/", base64_decode($this->application->custom_labels))); @@ -2070,9 +2241,8 @@ private function generate_compose_file() ], ], ]; - if (filled($this->env_filename)) { - $docker_compose['services'][$this->container_name]['env_file'] = [$this->env_filename]; - } + // Always use .env file + $docker_compose['services'][$this->container_name]['env_file'] = ['.env']; $docker_compose['services'][$this->container_name]['healthcheck'] = [ 'test' => [ 'CMD-SHELL', @@ -2364,13 +2534,12 @@ private function build_image() // Coolify variables are already included in the secrets from generate_build_env_variables // build_secrets is already a string at this point } else { - // Traditional build args approach - $this->environment_variables->filter(function ($key, $value) { - return str($key)->startsWith('COOLIFY_'); - })->each(function ($key, $value) { + // Traditional build args approach - generate COOLIFY_ variables locally + // Generate COOLIFY_ variables locally for build args + $coolify_envs = $this->generate_coolify_env_variables(); + $coolify_envs->each(function ($value, $key) { $this->build_args->push("--build-arg '{$key}'"); }); - $this->build_args = $this->build_args instanceof \Illuminate\Support\Collection ? $this->build_args->implode(' ') : (string) $this->build_args; @@ -2610,6 +2779,8 @@ private function build_image() } else { // Dockerfile buildpack if ($this->dockerBuildkitSupported) { + // Modify the Dockerfile to use build secrets + $this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}"); // Use BuildKit with secrets $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; if ($this->force_rebuild) { @@ -2764,7 +2935,6 @@ private function generate_build_env_variables() $this->generate_env_variables(); $variables = collect([])->merge($this->env_args); } - // Analyze build variables for potential issues if ($variables->isNotEmpty()) { $this->analyzeBuildTimeVariables($variables); @@ -2809,9 +2979,13 @@ private function generate_docker_env_flags_for_secrets() return ''; } - $variables = $this->pull_request_id === 0 - ? $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->where('is_buildtime', true)->get() - : $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->where('is_buildtime', true)->get(); + // Generate env variables if not already done + // This populates $this->env_args with both user-defined and COOLIFY_* variables + if (! $this->env_args || $this->env_args->isEmpty()) { + $this->generate_env_variables(); + } + + $variables = $this->env_args; if ($variables->isEmpty()) { return ''; @@ -2819,12 +2993,19 @@ private function generate_docker_env_flags_for_secrets() $secrets_hash = $this->generate_secrets_hash($variables); + // Get database env vars to check for multiline flag + $env_vars = $this->pull_request_id === 0 + ? $this->application->environment_variables()->where('is_buildtime', true)->get() + : $this->application->environment_variables_preview()->where('is_buildtime', true)->get(); + // Map to simple array format for the helper function - $vars_array = $variables->map(function ($env) { + $vars_array = $variables->map(function ($value, $key) use ($env_vars) { + $env = $env_vars->firstWhere('key', $key); + return [ - 'key' => $env->key, - 'value' => $env->real_value, - 'is_multiline' => $env->is_multiline, + 'key' => $key, + 'value' => $value, + 'is_multiline' => $env ? $env->is_multiline : false, ]; }); @@ -2888,9 +3069,9 @@ private function add_build_env_variables_to_dockerfile() executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"), 'hidden' => true, 'save' => 'dockerfile', + 'ignore_errors' => true, ]); $dockerfile = collect(str($this->saved_outputs->get('dockerfile'))->trim()->explode("\n")); - if ($this->pull_request_id === 0) { // Only add environment variables that are available during build $envs = $this->application->environment_variables() @@ -2947,10 +3128,17 @@ private function add_build_env_variables_to_dockerfile() } $dockerfile_base64 = base64_encode($dockerfile->implode("\n")); - $this->execute_remote_command([ - executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"), - 'hidden' => true, - ]); + $this->application_deployment_queue->addLogEntry('Final Dockerfile:', type: 'info'); + $this->execute_remote_command( + [ + executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"), + 'hidden' => true, + ], + [ + executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"), + 'hidden' => true, + 'ignore_errors' => true, + ]); } } @@ -2975,16 +3163,19 @@ private function modify_dockerfile_for_secrets($dockerfile_path) $dockerfile->prepend('# syntax=docker/dockerfile:1'); } - // Get environment variables for secrets - $variables = $this->pull_request_id === 0 - ? $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->where('is_buildtime', true)->get() - : $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->where('is_buildtime', true)->get(); + // Generate env variables if not already done + // This populates $this->env_args with both user-defined and COOLIFY_* variables + if (! $this->env_args || $this->env_args->isEmpty()) { + $this->generate_env_variables(); + } + + $variables = $this->env_args; if ($variables->isEmpty()) { return; } // Generate mount strings for all secrets - $mountStrings = $variables->map(fn ($env) => "--mount=type=secret,id={$env->key},env={$env->key}")->implode(' '); + $mountStrings = $variables->map(fn ($value, $key) => "--mount=type=secret,id={$key},env={$key}")->implode(' '); // Add mount for the secrets hash to ensure cache invalidation $mountStrings .= ' --mount=type=secret,id=COOLIFY_BUILD_SECRETS_HASH,env=COOLIFY_BUILD_SECRETS_HASH'; @@ -3012,8 +3203,6 @@ private function modify_dockerfile_for_secrets($dockerfile_path) executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$dockerfile_path} > /dev/null"), 'hidden' => true, ]); - - $this->application_deployment_queue->addLogEntry('Modified Dockerfile to use build secrets.'); } } @@ -3023,15 +3212,13 @@ private function modify_dockerfiles_for_compose($composeFile) return; } - $variables = $this->pull_request_id === 0 - ? $this->application->environment_variables() - ->where('key', 'not like', 'NIXPACKS_%') - ->where('is_buildtime', true) - ->get() - : $this->application->environment_variables_preview() - ->where('key', 'not like', 'NIXPACKS_%') - ->where('is_buildtime', true) - ->get(); + // Generate env variables if not already done + // This populates $this->env_args with both user-defined and COOLIFY_* variables + if (! $this->env_args || $this->env_args->isEmpty()) { + $this->generate_env_variables(); + } + + $variables = $this->env_args; if ($variables->isEmpty()) { $this->application_deployment_queue->addLogEntry('No build-time variables to add to Dockerfiles.'); @@ -3105,11 +3292,10 @@ private function modify_dockerfiles_for_compose($composeFile) $isMultiStage = count($fromIndices) > 1; $argsToAdd = collect([]); - foreach ($variables as $env) { - $argsToAdd->push("ARG {$env->key}"); + foreach ($variables as $key => $value) { + $argsToAdd->push("ARG {$key}"); } - ray($argsToAdd); if ($argsToAdd->isEmpty()) { $this->application_deployment_queue->addLogEntry("Service {$serviceName}: No build-time variables to add."); @@ -3177,19 +3363,22 @@ private function modify_dockerfiles_for_compose($composeFile) private function add_build_secrets_to_compose($composeFile) { - // Get environment variables for secrets - $variables = $this->pull_request_id === 0 - ? $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->get() - : $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->get(); + // Generate env variables if not already done + // This populates $this->env_args with both user-defined and COOLIFY_* variables + if (! $this->env_args || $this->env_args->isEmpty()) { + $this->generate_env_variables(); + } + + $variables = $this->env_args; if ($variables->isEmpty()) { return $composeFile; } $secrets = []; - foreach ($variables as $env) { - $secrets[$env->key] = [ - 'environment' => $env->key, + foreach ($variables as $key => $value) { + $secrets[$key] = [ + 'environment' => $key, ]; } @@ -3204,9 +3393,9 @@ private function add_build_secrets_to_compose($composeFile) if (! isset($service['build']['secrets'])) { $service['build']['secrets'] = []; } - foreach ($variables as $env) { - if (! in_array($env->key, $service['build']['secrets'])) { - $service['build']['secrets'][] = $env->key; + foreach ($variables as $key => $value) { + if (! in_array($key, $service['build']['secrets'])) { + $service['build']['secrets'][] = $key; } } } @@ -3325,7 +3514,6 @@ private function next(string $status) queue_next_deployment($this->application); if ($status === ApplicationDeploymentStatus::FINISHED->value) { - ray($this->application->team()->id); event(new ApplicationConfigurationChanged($this->application->team()->id)); if (! $this->only_this_server) { diff --git a/app/Livewire/DeploymentsIndicator.php b/app/Livewire/DeploymentsIndicator.php index 0293ad6c6..ac9cfd1c2 100644 --- a/app/Livewire/DeploymentsIndicator.php +++ b/app/Livewire/DeploymentsIndicator.php @@ -16,7 +16,8 @@ public function deployments() { $servers = Server::ownedByCurrentTeam()->get(); - return ApplicationDeploymentQueue::whereIn('status', ['in_progress', 'queued']) + return ApplicationDeploymentQueue::with(['application.environment.project']) + ->whereIn('status', ['in_progress', 'queued']) ->whereIn('server_id', $servers->pluck('id')) ->orderBy('id') ->get([ diff --git a/app/Livewire/Project/Application/DeploymentNavbar.php b/app/Livewire/Project/Application/DeploymentNavbar.php index dccd1e499..ebdc014ae 100644 --- a/app/Livewire/Project/Application/DeploymentNavbar.php +++ b/app/Livewire/Project/Application/DeploymentNavbar.php @@ -50,6 +50,28 @@ public function force_start() } } + public function copyLogsToClipboard(): string + { + $logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR); + + if (! $logs) { + return ''; + } + + $markdown = "# Deployment Logs\n\n"; + $markdown .= "```\n"; + + foreach ($logs as $log) { + if (isset($log['output'])) { + $markdown .= $log['output']."\n"; + } + } + + $markdown .= "```\n"; + + return $markdown; + } + public function cancel() { $deployment_uuid = $this->application_deployment_queue->deployment_uuid; diff --git a/app/Livewire/Project/New/DockerImage.php b/app/Livewire/Project/New/DockerImage.php index dbb223de2..e105c956a 100644 --- a/app/Livewire/Project/New/DockerImage.php +++ b/app/Livewire/Project/New/DockerImage.php @@ -12,7 +12,11 @@ class DockerImage extends Component { - public string $dockerImage = ''; + public string $imageName = ''; + + public string $imageTag = ''; + + public string $imageSha256 = ''; public array $parameters; @@ -26,12 +30,41 @@ public function mount() public function submit() { + // Strip 'sha256:' prefix if user pasted it + if ($this->imageSha256) { + $this->imageSha256 = preg_replace('/^sha256:/i', '', trim($this->imageSha256)); + } + + // Remove @sha256 from image name if user added it + if ($this->imageName) { + $this->imageName = preg_replace('/@sha256$/i', '', trim($this->imageName)); + } + $this->validate([ - 'dockerImage' => 'required', + 'imageName' => ['required', 'string'], + 'imageTag' => ['nullable', 'string', 'regex:/^[a-z0-9][a-z0-9._-]*$/i'], + 'imageSha256' => ['nullable', 'string', 'regex:/^[a-f0-9]{64}$/i'], ]); + // Validate that either tag or sha256 is provided, but not both + if ($this->imageTag && $this->imageSha256) { + $this->addError('imageTag', 'Provide either a tag or SHA256 digest, not both.'); + $this->addError('imageSha256', 'Provide either a tag or SHA256 digest, not both.'); + + return; + } + + // Build the full Docker image string + if ($this->imageSha256) { + $dockerImage = $this->imageName.'@sha256:'.$this->imageSha256; + } elseif ($this->imageTag) { + $dockerImage = $this->imageName.':'.$this->imageTag; + } else { + $dockerImage = $this->imageName.':latest'; + } + $parser = new DockerImageParser; - $parser->parse($this->dockerImage); + $parser->parse($dockerImage); $destination_uuid = $this->query['destination']; $destination = StandaloneDocker::where('uuid', $destination_uuid)->first(); @@ -45,6 +78,16 @@ public function submit() $project = Project::where('uuid', $this->parameters['project_uuid'])->first(); $environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first(); + + // Determine the image tag based on whether it's a hash or regular tag + $imageTag = $parser->isImageHash() ? 'sha256-'.$parser->getTag() : $parser->getTag(); + + // Append @sha256 to image name if using digest and not already present + $imageName = $parser->getFullImageNameWithoutTag(); + if ($parser->isImageHash() && ! str_ends_with($imageName, '@sha256')) { + $imageName .= '@sha256'; + } + $application = Application::create([ 'name' => 'docker-image-'.new Cuid2, 'repository_project_id' => 0, @@ -52,7 +95,7 @@ public function submit() 'git_branch' => 'main', 'build_pack' => 'dockerimage', 'ports_exposes' => 80, - 'docker_registry_image_name' => $parser->getFullImageNameWithoutTag(), + 'docker_registry_image_name' => $imageName, 'docker_registry_image_tag' => $parser->getTag(), 'environment_id' => $environment->id, 'destination_id' => $destination->id, diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php index 2933a8cca..7f0caaba3 100644 --- a/app/Livewire/Project/Service/FileStorage.php +++ b/app/Livewire/Project/Service/FileStorage.php @@ -34,6 +34,8 @@ class FileStorage extends Component public bool $permanently_delete = true; + public bool $isReadOnly = false; + protected $rules = [ 'fileStorage.is_directory' => 'required', 'fileStorage.fs_path' => 'required', @@ -52,6 +54,8 @@ public function mount() $this->workdir = null; $this->fs_path = $this->fileStorage->fs_path; } + + $this->isReadOnly = $this->fileStorage->isReadOnlyVolume(); } public function convertToDirectory() diff --git a/app/Livewire/Project/Shared/Storages/Show.php b/app/Livewire/Project/Shared/Storages/Show.php index 3928ee1d4..4f57cbfa6 100644 --- a/app/Livewire/Project/Shared/Storages/Show.php +++ b/app/Livewire/Project/Shared/Storages/Show.php @@ -37,6 +37,11 @@ class Show extends Component 'host_path' => 'host', ]; + public function mount() + { + $this->isReadOnly = $this->storage->isReadOnlyVolume(); + } + public function submit() { $this->authorize('update', $this->resource); diff --git a/app/Models/Application.php b/app/Models/Application.php index 4f1796790..914d9948d 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -739,9 +739,9 @@ public function environment_variables() return $this->morphMany(EnvironmentVariable::class, 'resourceable') ->where('is_preview', false) ->orderByRaw(" - CASE - WHEN LOWER(key) LIKE 'service_%' THEN 1 - WHEN is_required = true AND (value IS NULL OR value = '') THEN 2 + CASE + WHEN is_required = true THEN 1 + WHEN LOWER(key) LIKE 'service_%' THEN 2 ELSE 3 END, LOWER(key) ASC @@ -767,9 +767,9 @@ public function environment_variables_preview() return $this->morphMany(EnvironmentVariable::class, 'resourceable') ->where('is_preview', true) ->orderByRaw(" - CASE - WHEN LOWER(key) LIKE 'service_%' THEN 1 - WHEN is_required = true AND (value IS NULL OR value = '') THEN 2 + CASE + WHEN is_required = true THEN 1 + WHEN LOWER(key) LIKE 'service_%' THEN 2 ELSE 3 END, LOWER(key) ASC diff --git a/app/Models/ApplicationDeploymentQueue.php b/app/Models/ApplicationDeploymentQueue.php index 8df6877ab..4e8eee10f 100644 --- a/app/Models/ApplicationDeploymentQueue.php +++ b/app/Models/ApplicationDeploymentQueue.php @@ -41,11 +41,9 @@ class ApplicationDeploymentQueue extends Model { protected $guarded = []; - public function application(): Attribute + public function application() { - return Attribute::make( - get: fn () => Application::find($this->application_id), - ); + return $this->belongsTo(Application::class); } public function server(): Attribute diff --git a/app/Models/LocalFileVolume.php b/app/Models/LocalFileVolume.php index b3e71d75d..376ea9c5e 100644 --- a/app/Models/LocalFileVolume.php +++ b/app/Models/LocalFileVolume.php @@ -5,6 +5,7 @@ use App\Events\FileStorageChanged; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Symfony\Component\Yaml\Yaml; class LocalFileVolume extends BaseModel { @@ -192,4 +193,61 @@ public function scopeWherePlainMountPath($query, $path) { return $query->get()->where('plain_mount_path', $path); } + + // Check if this volume is read-only by parsing the docker-compose content + public function isReadOnlyVolume(): bool + { + try { + // Only check for services + $service = $this->service; + if (! $service || ! method_exists($service, 'service')) { + return false; + } + + $actualService = $service->service; + if (! $actualService || ! $actualService->docker_compose_raw) { + return false; + } + + // Parse the docker-compose content + $compose = Yaml::parse($actualService->docker_compose_raw); + if (! isset($compose['services'])) { + return false; + } + + // Find the service that this volume belongs to + $serviceName = $service->name; + if (! isset($compose['services'][$serviceName]['volumes'])) { + return false; + } + + $volumes = $compose['services'][$serviceName]['volumes']; + + // Check each volume to find a match + foreach ($volumes as $volume) { + // Volume can be string like "host:container:ro" or "host:container" + if (is_string($volume)) { + $parts = explode(':', $volume); + + // Check if this volume matches our fs_path and mount_path + if (count($parts) >= 2) { + $hostPath = $parts[0]; + $containerPath = $parts[1]; + $options = $parts[2] ?? null; + + // Match based on fs_path and mount_path + if ($hostPath === $this->fs_path && $containerPath === $this->mount_path) { + return $options === 'ro'; + } + } + } + } + + return false; + } catch (\Throwable $e) { + ray($e->getMessage(), 'Error checking read-only volume'); + + return false; + } + } } diff --git a/app/Models/LocalPersistentVolume.php b/app/Models/LocalPersistentVolume.php index 00dc15fea..e7862478b 100644 --- a/app/Models/LocalPersistentVolume.php +++ b/app/Models/LocalPersistentVolume.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Model; +use Symfony\Component\Yaml\Yaml; class LocalPersistentVolume extends Model { @@ -48,4 +49,69 @@ protected function hostPath(): Attribute } ); } + + // Check if this volume is read-only by parsing the docker-compose content + public function isReadOnlyVolume(): bool + { + try { + // Get the resource (can be application, service, or database) + $resource = $this->resource; + if (! $resource) { + return false; + } + + // Only check for services + if (! method_exists($resource, 'service')) { + return false; + } + + $actualService = $resource->service; + if (! $actualService || ! $actualService->docker_compose_raw) { + return false; + } + + // Parse the docker-compose content + $compose = Yaml::parse($actualService->docker_compose_raw); + if (! isset($compose['services'])) { + return false; + } + + // Find the service that this volume belongs to + $serviceName = $resource->name; + if (! isset($compose['services'][$serviceName]['volumes'])) { + return false; + } + + $volumes = $compose['services'][$serviceName]['volumes']; + + // Check each volume to find a match + foreach ($volumes as $volume) { + // Volume can be string like "host:container:ro" or "host:container" + if (is_string($volume)) { + $parts = explode(':', $volume); + + // Check if this volume matches our mount_path + if (count($parts) >= 2) { + $containerPath = $parts[1]; + $options = $parts[2] ?? null; + + // Match based on mount_path + // Remove leading slash from mount_path if present for comparison + $mountPath = str($this->mount_path)->ltrim('/')->toString(); + $containerPathClean = str($containerPath)->ltrim('/')->toString(); + + if ($mountPath === $containerPathClean || $this->mount_path === $containerPath) { + return $options === 'ro'; + } + } + } + } + + return false; + } catch (\Throwable $e) { + ray($e->getMessage(), 'Error checking read-only persistent volume'); + + return false; + } + } } diff --git a/app/Models/Service.php b/app/Models/Service.php index d42d471c6..c4b8623e0 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -547,6 +547,21 @@ public function extraFields() } $fields->put('Grafana', $data->toArray()); break; + case $image->contains('elasticsearch'): + $data = collect([]); + $elastic_password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_ELASTICSEARCH')->first(); + if ($elastic_password) { + $data = $data->merge([ + 'Password (default user: elastic)' => [ + 'key' => data_get($elastic_password, 'key'), + 'value' => data_get($elastic_password, 'value'), + 'rules' => 'required', + 'isPassword' => true, + ], + ]); + } + $fields->put('Elasticsearch', $data->toArray()); + break; case $image->contains('directus'): $data = collect([]); $admin_email = $this->environment_variables()->where('key', 'ADMIN_EMAIL')->first(); @@ -1231,9 +1246,9 @@ public function environment_variables() { return $this->morphMany(EnvironmentVariable::class, 'resourceable') ->orderByRaw(" - CASE - WHEN LOWER(key) LIKE 'service_%' THEN 1 - WHEN is_required = true AND (value IS NULL OR value = '') THEN 2 + CASE + WHEN is_required = true THEN 1 + WHEN LOWER(key) LIKE 'service_%' THEN 2 ELSE 3 END, LOWER(key) ASC @@ -1263,6 +1278,21 @@ public function saveComposeConfigs() $commands[] = "cd $workdir"; $commands[] = 'rm -f .env || true'; + $envs = collect([]); + + // Generate SERVICE_NAME_* environment variables from docker-compose services + if ($this->docker_compose) { + try { + $dockerCompose = \Symfony\Component\Yaml\Yaml::parse($this->docker_compose); + $services = data_get($dockerCompose, 'services', []); + foreach ($services as $serviceName => $_) { + $envs->push('SERVICE_NAME_'.str($serviceName)->replace('-', '_')->replace('.', '_')->upper().'='.$serviceName); + } + } catch (\Exception $e) { + ray($e->getMessage()); + } + } + $envs_from_coolify = $this->environment_variables()->get(); $sorted = $envs_from_coolify->sortBy(function ($env) { if (str($env->key)->startsWith('SERVICE_')) { @@ -1274,7 +1304,6 @@ public function saveComposeConfigs() return 3; }); - $envs = collect([]); foreach ($sorted as $env) { $envs->push("{$env->key}={$env->real_value}"); } diff --git a/app/Rules/DockerImageFormat.php b/app/Rules/DockerImageFormat.php new file mode 100644 index 000000000..a6a78a76c --- /dev/null +++ b/app/Rules/DockerImageFormat.php @@ -0,0 +1,41 @@ + strrpos($imageString, '/'))) { - $mainPart = substr($imageString, 0, $lastColon); - $this->tag = substr($imageString, $lastColon + 1); + // Check for @sha256: format first (e.g., nginx@sha256:abc123...) + if (preg_match('/^(.+)@sha256:([a-f0-9]{64})$/i', $imageString, $matches)) { + $mainPart = $matches[1]; + $this->tag = $matches[2]; + $this->isImageHash = true; } else { - $mainPart = $imageString; - $this->tag = 'latest'; + // Split by : to handle the tag, but be careful with registry ports + $lastColon = strrpos($imageString, ':'); + $hasSlash = str_contains($imageString, '/'); + + // If the last colon appears after the last slash, it's a tag + // Otherwise it might be a port in the registry URL + if ($lastColon !== false && (! $hasSlash || $lastColon > strrpos($imageString, '/'))) { + $mainPart = substr($imageString, 0, $lastColon); + $this->tag = substr($imageString, $lastColon + 1); + + // Check if the tag is a SHA256 hash + $this->isImageHash = $this->isSha256Hash($this->tag); + } else { + $mainPart = $imageString; + $this->tag = 'latest'; + $this->isImageHash = false; + } } // Split the main part by / to handle registry and image name @@ -41,6 +54,37 @@ public function parse(string $imageString): self return $this; } + /** + * Check if the given string is a SHA256 hash + */ + private function isSha256Hash(string $hash): bool + { + // SHA256 hashes are 64 characters long and contain only hexadecimal characters + return preg_match('/^[a-f0-9]{64}$/i', $hash) === 1; + } + + /** + * Check if the current tag is an image hash + */ + public function isImageHash(): bool + { + return $this->isImageHash; + } + + /** + * Get the full image name with hash if present + */ + public function getFullImageNameWithHash(): string + { + $imageName = $this->getFullImageNameWithoutTag(); + + if ($this->isImageHash) { + return $imageName.'@sha256:'.$this->tag; + } + + return $imageName.':'.$this->tag; + } + public function getFullImageNameWithoutTag(): string { if ($this->registryUrl) { @@ -73,6 +117,10 @@ public function toString(): string } $parts[] = $this->imageName; + if ($this->isImageHash) { + return implode('/', $parts).'@sha256:'.$this->tag; + } + return implode('/', $parts).':'.$this->tag; } } diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 25cc5d0a6..a588ed882 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -1172,6 +1172,9 @@ function serviceParser(Service $resource): Collection $parsedServices = collect([]); + // Generate SERVICE_NAME variables for docker compose services + $serviceNameEnvironments = generateDockerComposeServiceName($services); + $allMagicEnvironments = collect([]); // Presave services foreach ($services as $serviceName => $service) { @@ -1988,7 +1991,7 @@ function serviceParser(Service $resource): Collection $payload['volumes'] = $volumesParsed; } if ($environment->count() > 0 || $coolifyEnvironments->count() > 0) { - $payload['environment'] = $environment->merge($coolifyEnvironments); + $payload['environment'] = $environment->merge($coolifyEnvironments)->merge($serviceNameEnvironments); } if ($logging) { $payload['logging'] = $logging; diff --git a/config/constants.php b/config/constants.php index ddda70d19..01eaa7fa1 100644 --- a/config/constants.php +++ b/config/constants.php @@ -2,7 +2,7 @@ return [ 'coolify' => [ - 'version' => '4.0.0-beta.434', + 'version' => '4.0.0-beta.435', 'helper_version' => '1.0.11', 'realtime_version' => '1.0.10', 'self_hosted' => env('SELF_HOSTED', true), diff --git a/other/nightly/versions.json b/other/nightly/versions.json index b5cf3360a..2e5cc5e84 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.433" + "version": "4.0.0-beta.435" }, "nightly": { - "version": "4.0.0-beta.434" + "version": "4.0.0-beta.436" }, "helper": { "version": "1.0.11" diff --git a/public/ente-photos-icon-green.png b/public/ente-photos-icon-green.png new file mode 100644 index 000000000..b74aa472d Binary files /dev/null and b/public/ente-photos-icon-green.png differ diff --git a/public/svgs/ente-photos.svg b/public/svgs/ente-photos.svg new file mode 100644 index 000000000..e6a469e91 --- /dev/null +++ b/public/svgs/ente-photos.svg @@ -0,0 +1,15 @@ + diff --git a/public/svgs/ente.png b/public/svgs/ente.png new file mode 100644 index 000000000..f510a7bf7 Binary files /dev/null and b/public/svgs/ente.png differ diff --git a/resources/views/livewire/deployments-indicator.blade.php b/resources/views/livewire/deployments-indicator.blade.php index ed24249e0..7f3cc23ed 100644 --- a/resources/views/livewire/deployments-indicator.blade.php +++ b/resources/views/livewire/deployments-indicator.blade.php @@ -68,6 +68,9 @@ class="flex items-start gap-3 p-3 rounded-lg dark:bg-coolgray-200 bg-gray-50 tra {{ $deployment->application_name }}
+ {{ $deployment->application?->environment?->project?->name }} / {{ $deployment->application?->environment?->name }} +
+{{ $deployment->server_name }}
@if ($deployment->pull_request_id) diff --git a/resources/views/livewire/project/application/deployment-navbar.blade.php b/resources/views/livewire/project/application/deployment-navbar.blade.php index effb6b6fe..60c660bf7 100644 --- a/resources/views/livewire/project/application/deployment-navbar.blade.php +++ b/resources/views/livewire/project/application/deployment-navbar.blade.php @@ -5,6 +5,9 @@ @else