diff --git a/.env.development.example b/.env.development.example index 3023a21a6..d4daed4f7 100644 --- a/.env.development.example +++ b/.env.development.example @@ -6,7 +6,7 @@ APP_KEY= APP_URL=http://localhost APP_PORT=8000 APP_DEBUG=true -SSH_MUX_ENABLED=false +SSH_MUX_ENABLED=true # PostgreSQL Database Configuration DB_DATABASE=coolify @@ -19,11 +19,7 @@ DB_PORT=5432 # Set to true to enable Ray RAY_ENABLED=false # Set custom ray port -RAY_PORT= - -# Clockwork Configuration -CLOCKWORK_ENABLED=false -CLOCKWORK_QUEUE_COLLECT=true +# RAY_PORT= # Enable Laravel Telescope for debugging TELESCOPE_ENABLED=false diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 3ded74ce3..5afe00a30 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1 +1,13 @@ -> Always use `next` branch as destination branch for PRs, not `main` +## Submit Checklist (REMOVE THIS SECTION BEFORE SUBMITTING) +- [ ] I have selected the `next` branch as the destination for my PR, not `main`. +- [ ] I have listed all changes in the `Changes` section. +- [ ] I have filled out the `Issues` section with the issue/discussion link(s) (if applicable). +- [ ] I have tested my changes. +- [ ] I have considered backwards compatibility. +- [ ] I have removed this checklist and any unused sections. + +## Changes +- + +## Issues +- fix # diff --git a/.github/workflows/remove-labels-and-assignees-on-close.yml b/.github/workflows/remove-labels-and-assignees-on-close.yml index 04d62623c..ea097e328 100644 --- a/.github/workflows/remove-labels-and-assignees-on-close.yml +++ b/.github/workflows/remove-labels-and-assignees-on-close.yml @@ -18,7 +18,7 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const { owner, repo } = context.repo; - + async function processIssue(issueNumber) { try { const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ @@ -65,11 +65,14 @@ jobs: } if (context.eventName === 'pull_request' || context.eventName === 'pull_request_target') { - const { data: closedIssues } = await github.rest.search.issuesAndPullRequests({ - q: `repo:${owner}/${repo} is:issue is:closed linked:${context.payload.pull_request.number}`, - per_page: 100 - }); - for (const issue of closedIssues.items) { - await processIssue(issue.number); + const pr = context.payload.pull_request; + if (pr.body) { + const issueReferences = pr.body.match(/#(\d+)/g); + if (issueReferences) { + for (const reference of issueReferences) { + const issueNumber = parseInt(reference.substring(1)); + await processIssue(issueNumber); + } + } } } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 590360ddb..4a3e0e538 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,15 +6,19 @@ You can ask for guidance anytime on our [Discord server](https://coollabs.io/dis ## Table of Contents -1. [Setup Development Environment](#1-setup-development-environment) -2. [Verify Installation](#2-verify-installation-optional) -3. [Fork and Setup Local Repository](#3-fork-and-setup-local-repository) -4. [Set up Environment Variables](#4-set-up-environment-variables) -5. [Start Coolify](#5-start-coolify) -6. [Start Development](#6-start-development) -7. [Development Notes](#7-development-notes) -8. [Create a Pull Request](#8-create-a-pull-request) -9. [Additional Contribution Guidelines](#additional-contribution-guidelines) +- [Contributing to Coolify](#contributing-to-coolify) + - [Table of Contents](#table-of-contents) + - [1. Setup Development Environment](#1-setup-development-environment) + - [2. Verify Installation (Optional)](#2-verify-installation-optional) + - [3. Fork and Setup Local Repository](#3-fork-and-setup-local-repository) + - [4. Set up Environment Variables](#4-set-up-environment-variables) + - [5. Start Coolify](#5-start-coolify) + - [6. Start Development](#6-start-development) + - [7. Development Notes](#7-development-notes) + - [8. Create a Pull Request](#8-create-a-pull-request) + - [Additional Contribution Guidelines](#additional-contribution-guidelines) + - [Contributing a New Service](#contributing-a-new-service) + - [Contributing to Documentation](#contributing-to-documentation) ## 1. Setup Development Environment diff --git a/RELEASE.md b/RELEASE.md index 2cb96b72b..d9f05f17d 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -2,35 +2,120 @@ This guide outlines the release process for Coolify, intended for developers and those interested in understanding how releases are managed and deployed. +## Table of Contents +- [Release Process](#release-process) +- [Version Types](#version-types) + - [Stable](#stable) + - [Nightly](#nightly) + - [Beta](#beta) +- [Version Availability](#version-availability) + - [Self-Hosted](#self-hosted) + - [Cloud](#cloud) +- [Manually Update to Specific Versions](#manually-update-to-specific-versions) + ## Release Process -1. **Development on `next` or separate branches** - - Changes, fixes and new features are developed on the `next` or even separate branches. +1. **Development on `next` or Feature Branches** + - Improvements, fixes, and new features are developed on the `next` branch or separate feature branches. 2. **Merging to `main`** - - Once changes are ready, they are merged from `next` into the `main` branch. + - Once ready, changes are merged from the `next` branch into the `main` branch. -3. **Building the release** - - After merging to `main`, a new release is built. - - Note: A push to `main` does not automatically mean a new version is released. +3. **Building the Release** + - After merging to `main`, GitHub Actions automatically builds release images for all architectures and pushes them to the GitHub Container Registry with the version tag and the `latest` tag. -4. **Creating a GitHub release** - - A new release is created on GitHub with the new version details. +4. **Creating a GitHub Release** + - A new GitHub release is manually created with details of the changes made in the version. 5. **Updating the CDN** - - The final step is updating the version information on the CDN: - [https://cdn.coollabs.io/coolify/versions.json](https://cdn.coollabs.io/coolify/versions.json) + - To make a new version publicly available, the version information on the CDN needs to be updated: [https://cdn.coollabs.io/coolify/versions.json](https://cdn.coollabs.io/coolify/versions.json) > [!NOTE] -> The CDN update may not occur immediately after the GitHub release. It can happen hours or even days later due to additional testing, stability checks, or potential hotfixes. +> The CDN update may not occur immediately after the GitHub release. It can take hours or even days due to additional testing, stability checks, or potential hotfixes. **The update becomes available only after the CDN is updated.** +## Version Types + +
+ Stable (coming soon) + +- **Stable** + - The production version suitable for stable, production environments (generally recommended). + - **Update Frequency:** Every 2 to 4 weeks, with more frequent possible hotfixes. + - **Release Size:** Larger but less frequent releases. Multiple nightly versions are consolidated into a single stable release. + - **Versioning Scheme:** Follows semantic versioning (e.g., `v4.0.0`). + - **Installation Command:** + ```bash + curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash + ``` + +
+ +
+ Nightly + +- **Nightly** + - The latest development version, suitable for testing the latest changes and experimenting with new features. + - **Update Frequency:** Daily or bi-weekly updates. + - **Release Size:** Smaller, more frequent releases. + - **Versioning Scheme:** TO BE DETERMINED + - **Installation Command:** + ```bash + curl -fsSL https://cdn.coollabs.io/coolify-nightly/install.sh | bash -s next + ``` + +
+ +
+ Beta + +- **Beta** + - Test releases for the upcoming stable version. + - **Purpose:** Allows users to test and provide feedback on new features and changes before they become stable. + - **Update Frequency:** Available if we think beta testing is necessary. + - **Release Size:** Same size as stable release as it will become the next stabe release after some time. + - **Versioning Scheme:** Follows semantic versioning (e.g., `4.1.0-beta.1`). + - **Installation Command:** + ```bash + curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash + ``` + +
+ +> [!WARNING] +> Do not use nightly/beta builds in production as there is no guarantee of stability. ## Version Availability -It's important to understand that a new version released on GitHub may not immediately become available for users to update (through manual or auto-update). +When a new version is released and a new GitHub release is created, it doesn't immediately become available for your instance. Here's how version availability works for different instance types. + +### Self-Hosted + +- **Update Frequency:** More frequent updates, especially on the nightly release channel. +- **Update Availability:** New versions are available once the CDN has been updated. +- **Update Methods:** + 1. **Manual Update in Instance Settings:** + - Go to `Settings > Update Check Frequency` and click the `Check Manually` button. + - If an update is available, an upgrade button will appear on the sidebar. + 2. **Automatic Update:** + - If enabled, the instance will update automatically at the time set in the settings. + 3. **Re-run Installation Script:** + - Run the installation script again to upgrade to the latest version available on the CDN: + ```bash + curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash + ``` > [!IMPORTANT] -> If you see a new release on GitHub but haven't received the update, it's likely because the CDN hasn't been updated yet. This is intentional and ensures stability and allows for hotfixes before the new version is officially released. +> If a new release is available on GitHub but your instance hasn't updated yet or no upgrade button is shown in the UI, the CDN might not have been updated yet. This intentional delay ensures stability and allows for hotfixes before official release. + +### Cloud + +- **Update Frequency:** Less frequent as it's a managed service. +- **Update Availability:** New versions are available once Andras has updated the cloud version manually. +- **Update Method:** + - Updates are managed by Andras, who ensures each cloud version is thoroughly tested and stable before releasing it. + +> [!IMPORTANT] +> The cloud version of Coolify may be several versions behind the latest GitHub releases even if the CDN is updated. This is intentional to ensure stability and reliability for cloud users and Andras will manully update the cloud version when the update is ready. ## Manually Update to Specific Versions @@ -42,4 +127,4 @@ To update your Coolify instance to a specific (unreleased) version, use the foll ```bash curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash -s ``` --> Replace `` with the version you want to update to (for example `4.0.0-beta.332`). +Replace `` with the version you want to update to (for example `4.0.0-beta.332`). diff --git a/app/Actions/Application/StopApplication.php b/app/Actions/Application/StopApplication.php index 7155f9a0a..61005845b 100644 --- a/app/Actions/Application/StopApplication.php +++ b/app/Actions/Application/StopApplication.php @@ -2,6 +2,7 @@ namespace App\Actions\Application; +use App\Actions\Server\CleanupDocker; use App\Models\Application; use Lorisleiva\Actions\Concerns\AsAction; @@ -9,44 +10,35 @@ class StopApplication { use AsAction; - public function handle(Application $application, bool $previewDeployments = false) + public function handle(Application $application, bool $previewDeployments = false, bool $dockerCleanup = true) { - if ($application->destination->server->isSwarm()) { - instant_remote_process(["docker stack rm {$application->uuid}"], $application->destination->server); - - return; - } - - $servers = collect([]); - $servers->push($application->destination->server); - $application->additional_servers->map(function ($server) use ($servers) { - $servers->push($server); - }); - foreach ($servers as $server) { + try { + $server = $application->destination->server; if (! $server->isFunctional()) { return 'Server is not functional'; } - if ($previewDeployments) { - $containers = getCurrentApplicationContainerStatus($server, $application->id, includePullrequests: true); - } else { - $containers = getCurrentApplicationContainerStatus($server, $application->id, 0); - } - if ($containers->count() > 0) { - foreach ($containers as $container) { - $containerName = data_get($container, 'Names'); - if ($containerName) { - instant_remote_process(command: ["docker stop --time=30 $containerName"], server: $server, throwError: false); - instant_remote_process(command: ["docker rm $containerName"], server: $server, throwError: false); - instant_remote_process(command: ["docker rm -f {$containerName}"], server: $server, throwError: false); - } - } + ray('Stopping application: '.$application->name); + + if ($server->isSwarm()) { + instant_remote_process(["docker stack rm {$application->uuid}"], $server); + + return; } + + $containersToStop = $application->getContainersToStop($previewDeployments); + $application->stopContainers($containersToStop, $server); + if ($application->build_pack === 'dockercompose') { - // remove network - $uuid = $application->uuid; - instant_remote_process(["docker network disconnect {$uuid} coolify-proxy"], $server, false); - instant_remote_process(["docker network rm {$uuid}"], $server, false); + $application->delete_connected_networks($application->uuid); } + + if ($dockerCleanup) { + CleanupDocker::dispatch($server, true); + } + } catch (\Exception $e) { + ray($e->getMessage()); + + return $e->getMessage(); } } } diff --git a/app/Actions/CoolifyTask/RunRemoteProcess.php b/app/Actions/CoolifyTask/RunRemoteProcess.php index 63e3afe2f..c691f52c0 100644 --- a/app/Actions/CoolifyTask/RunRemoteProcess.php +++ b/app/Actions/CoolifyTask/RunRemoteProcess.php @@ -4,6 +4,7 @@ namespace App\Actions\CoolifyTask; use App\Enums\ActivityTypes; use App\Enums\ProcessStatus; +use App\Helpers\SshMultiplexingHelper; use App\Jobs\ApplicationDeploymentJob; use App\Models\Server; use Illuminate\Process\ProcessResult; @@ -137,7 +138,7 @@ class RunRemoteProcess $command = $this->activity->getExtraProperty('command'); $server = Server::whereUuid($server_uuid)->firstOrFail(); - return generateSshCommand($server, $command); + return SshMultiplexingHelper::generateSshCommand($server, $command); } protected function handleOutput(string $type, string $output) diff --git a/app/Actions/Database/StartDragonfly.php b/app/Actions/Database/StartDragonfly.php index 621834df0..352c6a59f 100644 --- a/app/Actions/Database/StartDragonfly.php +++ b/app/Actions/Database/StartDragonfly.php @@ -23,7 +23,7 @@ class StartDragonfly $startCommand = "dragonfly --requirepass {$this->database->dragonfly_password}"; $container_name = $this->database->uuid; - $this->configuration_dir = database_configuration_dir() . '/' . $container_name; + $this->configuration_dir = database_configuration_dir().'/'.$container_name; $this->commands = [ "echo 'Starting {$database->name}.'", @@ -75,7 +75,7 @@ class StartDragonfly ], ], ]; - if (!is_null($this->database->limits_cpuset)) { + if (! is_null($this->database->limits_cpuset)) { data_set($docker_compose, "services.{$container_name}.cpuset", $this->database->limits_cpuset); } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { @@ -118,10 +118,10 @@ class StartDragonfly $local_persistent_volumes = []; foreach ($this->database->persistentStorages as $persistentStorage) { if ($persistentStorage->host_path !== '' && $persistentStorage->host_path !== null) { - $local_persistent_volumes[] = $persistentStorage->host_path . ':' . $persistentStorage->mount_path; + $local_persistent_volumes[] = $persistentStorage->host_path.':'.$persistentStorage->mount_path; } else { $volume_name = $persistentStorage->name; - $local_persistent_volumes[] = $volume_name . ':' . $persistentStorage->mount_path; + $local_persistent_volumes[] = $volume_name.':'.$persistentStorage->mount_path; } } @@ -152,7 +152,7 @@ class StartDragonfly $environment_variables->push("$env->key=$env->real_value"); } - if ($environment_variables->filter(fn($env) => str($env)->contains('REDIS_PASSWORD'))->isEmpty()) { + if ($environment_variables->filter(fn ($env) => str($env)->contains('REDIS_PASSWORD'))->isEmpty()) { $environment_variables->push("REDIS_PASSWORD={$this->database->dragonfly_password}"); } diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php index 9290efc7c..a11452a68 100644 --- a/app/Actions/Database/StartKeydb.php +++ b/app/Actions/Database/StartKeydb.php @@ -24,7 +24,7 @@ class StartKeydb $startCommand = "keydb-server --requirepass {$this->database->keydb_password} --appendonly yes"; $container_name = $this->database->uuid; - $this->configuration_dir = database_configuration_dir() . '/' . $container_name; + $this->configuration_dir = database_configuration_dir().'/'.$container_name; $this->commands = [ "echo 'Starting {$database->name}.'", @@ -74,7 +74,7 @@ class StartKeydb ], ], ]; - if (!is_null($this->database->limits_cpuset)) { + if (! is_null($this->database->limits_cpuset)) { data_set($docker_compose, "services.{$container_name}.cpuset", $this->database->limits_cpuset); } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { @@ -94,10 +94,10 @@ class StartKeydb if (count($volume_names) > 0) { $docker_compose['volumes'] = $volume_names; } - if (!is_null($this->database->keydb_conf) || !empty($this->database->keydb_conf)) { + if (! is_null($this->database->keydb_conf) || ! empty($this->database->keydb_conf)) { $docker_compose['services'][$container_name]['volumes'][] = [ 'type' => 'bind', - 'source' => $this->configuration_dir . '/keydb.conf', + 'source' => $this->configuration_dir.'/keydb.conf', 'target' => '/etc/keydb/keydb.conf', 'read_only' => true, ]; @@ -125,10 +125,10 @@ class StartKeydb $local_persistent_volumes = []; foreach ($this->database->persistentStorages as $persistentStorage) { if ($persistentStorage->host_path !== '' && $persistentStorage->host_path !== null) { - $local_persistent_volumes[] = $persistentStorage->host_path . ':' . $persistentStorage->mount_path; + $local_persistent_volumes[] = $persistentStorage->host_path.':'.$persistentStorage->mount_path; } else { $volume_name = $persistentStorage->name; - $local_persistent_volumes[] = $volume_name . ':' . $persistentStorage->mount_path; + $local_persistent_volumes[] = $volume_name.':'.$persistentStorage->mount_path; } } @@ -159,7 +159,7 @@ class StartKeydb $environment_variables->push("$env->key=$env->real_value"); } - if ($environment_variables->filter(fn($env) => str($env)->contains('REDIS_PASSWORD'))->isEmpty()) { + if ($environment_variables->filter(fn ($env) => str($env)->contains('REDIS_PASSWORD'))->isEmpty()) { $environment_variables->push("REDIS_PASSWORD={$this->database->keydb_password}"); } diff --git a/app/Actions/Database/StartMariadb.php b/app/Actions/Database/StartMariadb.php index f37a5e361..a5630f734 100644 --- a/app/Actions/Database/StartMariadb.php +++ b/app/Actions/Database/StartMariadb.php @@ -21,7 +21,7 @@ class StartMariadb $this->database = $database; $container_name = $this->database->uuid; - $this->configuration_dir = database_configuration_dir() . '/' . $container_name; + $this->configuration_dir = database_configuration_dir().'/'.$container_name; $this->commands = [ "echo 'Starting {$database->name}.'", @@ -69,7 +69,7 @@ class StartMariadb ], ], ]; - if (!is_null($this->database->limits_cpuset)) { + if (! is_null($this->database->limits_cpuset)) { data_set($docker_compose, "services.{$container_name}.cpuset", $this->database->limits_cpuset); } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { @@ -89,10 +89,10 @@ class StartMariadb if (count($volume_names) > 0) { $docker_compose['volumes'] = $volume_names; } - if (!is_null($this->database->mariadb_conf) || !empty($this->database->mariadb_conf)) { + if (! is_null($this->database->mariadb_conf) || ! empty($this->database->mariadb_conf)) { $docker_compose['services'][$container_name]['volumes'][] = [ 'type' => 'bind', - 'source' => $this->configuration_dir . '/custom-config.cnf', + 'source' => $this->configuration_dir.'/custom-config.cnf', 'target' => '/etc/mysql/conf.d/custom-config.cnf', 'read_only' => true, ]; @@ -120,10 +120,10 @@ class StartMariadb $local_persistent_volumes = []; foreach ($this->database->persistentStorages as $persistentStorage) { if ($persistentStorage->host_path !== '' && $persistentStorage->host_path !== null) { - $local_persistent_volumes[] = $persistentStorage->host_path . ':' . $persistentStorage->mount_path; + $local_persistent_volumes[] = $persistentStorage->host_path.':'.$persistentStorage->mount_path; } else { $volume_name = $persistentStorage->name; - $local_persistent_volumes[] = $volume_name . ':' . $persistentStorage->mount_path; + $local_persistent_volumes[] = $volume_name.':'.$persistentStorage->mount_path; } } @@ -154,18 +154,18 @@ class StartMariadb $environment_variables->push("$env->key=$env->real_value"); } - if ($environment_variables->filter(fn($env) => str($env)->contains('MARIADB_ROOT_PASSWORD'))->isEmpty()) { + if ($environment_variables->filter(fn ($env) => str($env)->contains('MARIADB_ROOT_PASSWORD'))->isEmpty()) { $environment_variables->push("MARIADB_ROOT_PASSWORD={$this->database->mariadb_root_password}"); } - if ($environment_variables->filter(fn($env) => str($env)->contains('MARIADB_DATABASE'))->isEmpty()) { + if ($environment_variables->filter(fn ($env) => str($env)->contains('MARIADB_DATABASE'))->isEmpty()) { $environment_variables->push("MARIADB_DATABASE={$this->database->mariadb_database}"); } - if ($environment_variables->filter(fn($env) => str($env)->contains('MARIADB_USER'))->isEmpty()) { + if ($environment_variables->filter(fn ($env) => str($env)->contains('MARIADB_USER'))->isEmpty()) { $environment_variables->push("MARIADB_USER={$this->database->mariadb_user}"); } - if ($environment_variables->filter(fn($env) => str($env)->contains('MARIADB_PASSWORD'))->isEmpty()) { + if ($environment_variables->filter(fn ($env) => str($env)->contains('MARIADB_PASSWORD'))->isEmpty()) { $environment_variables->push("MARIADB_PASSWORD={$this->database->mariadb_password}"); } diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php index 42fc8f348..5bff194d5 100644 --- a/app/Actions/Database/StartMongodb.php +++ b/app/Actions/Database/StartMongodb.php @@ -23,7 +23,7 @@ class StartMongodb $startCommand = 'mongod'; $container_name = $this->database->uuid; - $this->configuration_dir = database_configuration_dir() . '/' . $container_name; + $this->configuration_dir = database_configuration_dir().'/'.$container_name; $this->commands = [ "echo 'Starting {$database->name}.'", @@ -77,7 +77,7 @@ class StartMongodb ], ], ]; - if (!is_null($this->database->limits_cpuset)) { + if (! is_null($this->database->limits_cpuset)) { data_set($docker_compose, "services.{$container_name}.cpuset", $this->database->limits_cpuset); } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { @@ -97,19 +97,19 @@ class StartMongodb if (count($volume_names) > 0) { $docker_compose['volumes'] = $volume_names; } - if (!is_null($this->database->mongo_conf) || !empty($this->database->mongo_conf)) { + if (! is_null($this->database->mongo_conf) || ! empty($this->database->mongo_conf)) { $docker_compose['services'][$container_name]['volumes'][] = [ 'type' => 'bind', - 'source' => $this->configuration_dir . '/mongod.conf', + 'source' => $this->configuration_dir.'/mongod.conf', 'target' => '/etc/mongo/mongod.conf', 'read_only' => true, ]; - $docker_compose['services'][$container_name]['command'] = $startCommand . ' --config /etc/mongo/mongod.conf'; + $docker_compose['services'][$container_name]['command'] = $startCommand.' --config /etc/mongo/mongod.conf'; } $this->add_default_database(); $docker_compose['services'][$container_name]['volumes'][] = [ 'type' => 'bind', - 'source' => $this->configuration_dir . '/docker-entrypoint-initdb.d', + 'source' => $this->configuration_dir.'/docker-entrypoint-initdb.d', 'target' => '/docker-entrypoint-initdb.d', 'read_only' => true, ]; @@ -136,10 +136,10 @@ class StartMongodb $local_persistent_volumes = []; foreach ($this->database->persistentStorages as $persistentStorage) { if ($persistentStorage->host_path !== '' && $persistentStorage->host_path !== null) { - $local_persistent_volumes[] = $persistentStorage->host_path . ':' . $persistentStorage->mount_path; + $local_persistent_volumes[] = $persistentStorage->host_path.':'.$persistentStorage->mount_path; } else { $volume_name = $persistentStorage->name; - $local_persistent_volumes[] = $volume_name . ':' . $persistentStorage->mount_path; + $local_persistent_volumes[] = $volume_name.':'.$persistentStorage->mount_path; } } @@ -170,15 +170,15 @@ class StartMongodb $environment_variables->push("$env->key=$env->real_value"); } - if ($environment_variables->filter(fn($env) => str($env)->contains('MONGO_INITDB_ROOT_USERNAME'))->isEmpty()) { + if ($environment_variables->filter(fn ($env) => str($env)->contains('MONGO_INITDB_ROOT_USERNAME'))->isEmpty()) { $environment_variables->push("MONGO_INITDB_ROOT_USERNAME={$this->database->mongo_initdb_root_username}"); } - if ($environment_variables->filter(fn($env) => str($env)->contains('MONGO_INITDB_ROOT_PASSWORD'))->isEmpty()) { + if ($environment_variables->filter(fn ($env) => str($env)->contains('MONGO_INITDB_ROOT_PASSWORD'))->isEmpty()) { $environment_variables->push("MONGO_INITDB_ROOT_PASSWORD={$this->database->mongo_initdb_root_password}"); } - if ($environment_variables->filter(fn($env) => str($env)->contains('MONGO_INITDB_DATABASE'))->isEmpty()) { + if ($environment_variables->filter(fn ($env) => str($env)->contains('MONGO_INITDB_DATABASE'))->isEmpty()) { $environment_variables->push("MONGO_INITDB_DATABASE={$this->database->mongo_initdb_database}"); } diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php index 2043342fe..cc4203580 100644 --- a/app/Actions/Database/StartMysql.php +++ b/app/Actions/Database/StartMysql.php @@ -21,7 +21,7 @@ class StartMysql $this->database = $database; $container_name = $this->database->uuid; - $this->configuration_dir = database_configuration_dir() . '/' . $container_name; + $this->configuration_dir = database_configuration_dir().'/'.$container_name; $this->commands = [ "echo 'Starting {$database->name}.'", @@ -69,7 +69,7 @@ class StartMysql ], ], ]; - if (!is_null($this->database->limits_cpuset)) { + if (! is_null($this->database->limits_cpuset)) { data_set($docker_compose, "services.{$container_name}.cpuset", $this->database->limits_cpuset); } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { @@ -89,10 +89,10 @@ class StartMysql if (count($volume_names) > 0) { $docker_compose['volumes'] = $volume_names; } - if (!is_null($this->database->mysql_conf) || !empty($this->database->mysql_conf)) { + if (! is_null($this->database->mysql_conf) || ! empty($this->database->mysql_conf)) { $docker_compose['services'][$container_name]['volumes'][] = [ 'type' => 'bind', - 'source' => $this->configuration_dir . '/custom-config.cnf', + 'source' => $this->configuration_dir.'/custom-config.cnf', 'target' => '/etc/mysql/conf.d/custom-config.cnf', 'read_only' => true, ]; @@ -120,10 +120,10 @@ class StartMysql $local_persistent_volumes = []; foreach ($this->database->persistentStorages as $persistentStorage) { if ($persistentStorage->host_path !== '' && $persistentStorage->host_path !== null) { - $local_persistent_volumes[] = $persistentStorage->host_path . ':' . $persistentStorage->mount_path; + $local_persistent_volumes[] = $persistentStorage->host_path.':'.$persistentStorage->mount_path; } else { $volume_name = $persistentStorage->name; - $local_persistent_volumes[] = $volume_name . ':' . $persistentStorage->mount_path; + $local_persistent_volumes[] = $volume_name.':'.$persistentStorage->mount_path; } } @@ -154,18 +154,18 @@ class StartMysql $environment_variables->push("$env->key=$env->real_value"); } - if ($environment_variables->filter(fn($env) => str($env)->contains('MYSQL_ROOT_PASSWORD'))->isEmpty()) { + if ($environment_variables->filter(fn ($env) => str($env)->contains('MYSQL_ROOT_PASSWORD'))->isEmpty()) { $environment_variables->push("MYSQL_ROOT_PASSWORD={$this->database->mysql_root_password}"); } - if ($environment_variables->filter(fn($env) => str($env)->contains('MYSQL_DATABASE'))->isEmpty()) { + if ($environment_variables->filter(fn ($env) => str($env)->contains('MYSQL_DATABASE'))->isEmpty()) { $environment_variables->push("MYSQL_DATABASE={$this->database->mysql_database}"); } - if ($environment_variables->filter(fn($env) => str($env)->contains('MYSQL_USER'))->isEmpty()) { + if ($environment_variables->filter(fn ($env) => str($env)->contains('MYSQL_USER'))->isEmpty()) { $environment_variables->push("MYSQL_USER={$this->database->mysql_user}"); } - if ($environment_variables->filter(fn($env) => str($env)->contains('MYSQL_PASSWORD'))->isEmpty()) { + if ($environment_variables->filter(fn ($env) => str($env)->contains('MYSQL_PASSWORD'))->isEmpty()) { $environment_variables->push("MYSQL_PASSWORD={$this->database->mysql_password}"); } diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index bc37fd5cf..2a8e5476c 100644 --- a/app/Actions/Database/StartPostgresql.php +++ b/app/Actions/Database/StartPostgresql.php @@ -37,7 +37,6 @@ class StartPostgresql $this->generate_init_scripts(); $this->add_custom_conf(); - $docker_compose = [ 'services' => [ $container_name => [ diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php index b837414d6..eeddab924 100644 --- a/app/Actions/Database/StartRedis.php +++ b/app/Actions/Database/StartRedis.php @@ -24,7 +24,7 @@ class StartRedis $startCommand = "redis-server --requirepass {$this->database->redis_password} --appendonly yes"; $container_name = $this->database->uuid; - $this->configuration_dir = database_configuration_dir() . '/' . $container_name; + $this->configuration_dir = database_configuration_dir().'/'.$container_name; $this->commands = [ "echo 'Starting {$database->name}.'", @@ -78,7 +78,7 @@ class StartRedis ], ], ]; - if (!is_null($this->database->limits_cpuset)) { + if (! is_null($this->database->limits_cpuset)) { data_set($docker_compose, "services.{$container_name}.cpuset", $this->database->limits_cpuset); } if ($this->database->destination->server->isLogDrainEnabled() && $this->database->isLogDrainEnabled()) { @@ -98,10 +98,10 @@ class StartRedis if (count($volume_names) > 0) { $docker_compose['volumes'] = $volume_names; } - if (!is_null($this->database->redis_conf) || !empty($this->database->redis_conf)) { + if (! is_null($this->database->redis_conf) || ! empty($this->database->redis_conf)) { $docker_compose['services'][$container_name]['volumes'][] = [ 'type' => 'bind', - 'source' => $this->configuration_dir . '/redis.conf', + 'source' => $this->configuration_dir.'/redis.conf', 'target' => '/usr/local/etc/redis/redis.conf', 'read_only' => true, ]; @@ -130,10 +130,10 @@ class StartRedis $local_persistent_volumes = []; foreach ($this->database->persistentStorages as $persistentStorage) { if ($persistentStorage->host_path !== '' && $persistentStorage->host_path !== null) { - $local_persistent_volumes[] = $persistentStorage->host_path . ':' . $persistentStorage->mount_path; + $local_persistent_volumes[] = $persistentStorage->host_path.':'.$persistentStorage->mount_path; } else { $volume_name = $persistentStorage->name; - $local_persistent_volumes[] = $volume_name . ':' . $persistentStorage->mount_path; + $local_persistent_volumes[] = $volume_name.':'.$persistentStorage->mount_path; } } @@ -164,7 +164,7 @@ class StartRedis $environment_variables->push("$env->key=$env->real_value"); } - if ($environment_variables->filter(fn($env) => str($env)->contains('REDIS_PASSWORD'))->isEmpty()) { + if ($environment_variables->filter(fn ($env) => str($env)->contains('REDIS_PASSWORD'))->isEmpty()) { $environment_variables->push("REDIS_PASSWORD={$this->database->redis_password}"); } diff --git a/app/Actions/Database/StopDatabase.php b/app/Actions/Database/StopDatabase.php index d562ec56f..e4cea7cee 100644 --- a/app/Actions/Database/StopDatabase.php +++ b/app/Actions/Database/StopDatabase.php @@ -2,6 +2,7 @@ namespace App\Actions\Database; +use App\Actions\Server\CleanupDocker; use App\Models\StandaloneClickhouse; use App\Models\StandaloneDragonfly; use App\Models\StandaloneKeydb; @@ -10,25 +11,65 @@ use App\Models\StandaloneMongodb; use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; +use Illuminate\Support\Facades\Process; use Lorisleiva\Actions\Concerns\AsAction; class StopDatabase { use AsAction; - public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database) + public function handle(StandaloneRedis|StandalonePostgresql|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $database, bool $isDeleteOperation = false, bool $dockerCleanup = true) { $server = $database->destination->server; if (! $server->isFunctional()) { return 'Server is not functional'; } - instant_remote_process(command: ["docker stop --time=30 $database->uuid"], server: $server, throwError: false); - instant_remote_process(command: ["docker rm $database->uuid"], server: $server, throwError: false); - instant_remote_process(command: ["docker rm -f $database->uuid"], server: $server, throwError: false); + $this->stopContainer($database, $database->uuid, 300); + if (! $isDeleteOperation) { + if ($dockerCleanup) { + CleanupDocker::dispatch($server, true); + } + } if ($database->is_public) { StopDatabaseProxy::run($database); } + + return 'Database stopped successfully'; + } + + private function stopContainer($database, string $containerName, int $timeout = 300): void + { + $server = $database->destination->server; + + $process = Process::timeout($timeout)->start("docker stop --time=$timeout $containerName"); + + $startTime = time(); + while ($process->running()) { + if (time() - $startTime >= $timeout) { + $this->forceStopContainer($containerName, $server); + break; + } + usleep(100000); + } + + $this->removeContainer($containerName, $server); + } + + private function forceStopContainer(string $containerName, $server): void + { + instant_remote_process(command: ["docker kill $containerName"], server: $server, throwError: false); + } + + private function removeContainer(string $containerName, $server): void + { + instant_remote_process(command: ["docker rm -f $containerName"], server: $server, throwError: false); + } + + private function deleteConnectedNetworks($uuid, $server) + { + instant_remote_process(["docker network disconnect {$uuid} coolify-proxy"], $server, false); + instant_remote_process(["docker network rm {$uuid}"], $server, false); } } diff --git a/app/Actions/Docker/GetContainersStatus.php b/app/Actions/Docker/GetContainersStatus.php index fdaa88ebf..ed563eaae 100644 --- a/app/Actions/Docker/GetContainersStatus.php +++ b/app/Actions/Docker/GetContainersStatus.php @@ -543,7 +543,7 @@ class GetContainersStatus } } } - $exitedServices = $exitedServices->unique('id'); + $exitedServices = $exitedServices->unique('uuid'); foreach ($exitedServices as $exitedService) { if (str($exitedService->status)->startsWith('exited')) { continue; @@ -651,8 +651,9 @@ class GetContainersStatus // $this->server->team?->notify(new ContainerStopped($containerName, $this->server, $url)); } - // Check if proxy is running - $this->server->proxyType(); + if (! $this->server->proxySet() || $this->server->proxy->force_stop) { + return; + } $foundProxyContainer = $this->containers->filter(function ($value, $key) { if ($this->server->isSwarm()) { return data_get($value, 'Spec.Name') === 'coolify-proxy_traefik'; diff --git a/app/Actions/Proxy/CheckProxy.php b/app/Actions/Proxy/CheckProxy.php index 735b972af..cf0f6015c 100644 --- a/app/Actions/Proxy/CheckProxy.php +++ b/app/Actions/Proxy/CheckProxy.php @@ -26,7 +26,7 @@ class CheckProxy if (is_null($proxyType) || $proxyType === 'NONE' || $server->proxy->force_stop) { return false; } - ['uptime' => $uptime, 'error' => $error] = $server->validateConnection(); + ['uptime' => $uptime, 'error' => $error] = $server->validateConnection(false); if (! $uptime) { throw new \Exception($error); } diff --git a/app/Actions/Proxy/StartProxy.php b/app/Actions/Proxy/StartProxy.php index 991c94b11..4ef9618d0 100644 --- a/app/Actions/Proxy/StartProxy.php +++ b/app/Actions/Proxy/StartProxy.php @@ -35,7 +35,7 @@ class StartProxy "echo 'Creating required Docker Compose file.'", "echo 'Starting coolify-proxy.'", 'docker stack deploy -c docker-compose.yml coolify-proxy', - "echo 'Proxy started successfully.'", + "echo 'Successfully started coolify-proxy.'", ]); } else { $caddfile = 'import /dynamic/*.caddy'; @@ -46,11 +46,14 @@ class StartProxy "echo 'Creating required Docker Compose file.'", "echo 'Pulling docker image.'", 'docker compose pull', - "echo 'Stopping existing coolify-proxy.'", - 'docker compose down -v --remove-orphans > /dev/null 2>&1', + 'if docker ps -a --format "{{.Names}}" | grep -q "^coolify-proxy$"; then', + " echo 'Stopping and removing existing coolify-proxy.'", + ' docker rm -f coolify-proxy || true', + " echo 'Successfully stopped and removed existing coolify-proxy.'", + 'fi', "echo 'Starting coolify-proxy.'", 'docker compose up -d --remove-orphans', - "echo 'Proxy started successfully.'", + "echo 'Successfully started coolify-proxy.'", ]); $commands = $commands->merge(connectProxyToNetworks($server)); } diff --git a/app/Actions/Server/ConfigureCloudflared.php b/app/Actions/Server/ConfigureCloudflared.php index 3946afe95..0d36e8863 100644 --- a/app/Actions/Server/ConfigureCloudflared.php +++ b/app/Actions/Server/ConfigureCloudflared.php @@ -2,6 +2,7 @@ namespace App\Actions\Server; +use App\Events\CloudflareTunnelConfigured; use App\Models\Server; use Lorisleiva\Actions\Concerns\AsAction; use Symfony\Component\Yaml\Yaml; @@ -40,12 +41,17 @@ class ConfigureCloudflared instant_remote_process($commands, $server); } catch (\Throwable $e) { ray($e); + $server->settings->is_cloudflare_tunnel = false; + $server->settings->save(); throw $e; } finally { + CloudflareTunnelConfigured::dispatch($server->team_id); + $commands = collect([ 'rm -fr /tmp/cloudflared', ]); instant_remote_process($commands, $server); + } } } diff --git a/app/Actions/Service/DeleteService.php b/app/Actions/Service/DeleteService.php index 194cf4db9..f28e5490e 100644 --- a/app/Actions/Service/DeleteService.php +++ b/app/Actions/Service/DeleteService.php @@ -2,6 +2,7 @@ namespace App\Actions\Service; +use App\Actions\Server\CleanupDocker; use App\Models\Service; use Lorisleiva\Actions\Concerns\AsAction; @@ -9,11 +10,11 @@ class DeleteService { use AsAction; - public function handle(Service $service) + public function handle(Service $service, bool $deleteConfigurations, bool $deleteVolumes, bool $dockerCleanup, bool $deleteConnectedNetworks) { try { $server = data_get($service, 'server'); - if ($server->isFunctional()) { + if ($deleteVolumes && $server->isFunctional()) { $storagesToDelete = collect([]); $service->environment_variables()->delete(); @@ -33,13 +34,29 @@ class DeleteService foreach ($storagesToDelete as $storage) { $commands[] = "docker volume rm -f $storage->name"; } - $commands[] = "docker rm -f $service->uuid"; - instant_remote_process($commands, $server, false); + // Execute volume deletion first, this must be done first otherwise volumes will not be deleted. + if (! empty($commands)) { + foreach ($commands as $command) { + $result = instant_remote_process([$command], $server, false); + if ($result !== 0) { + ray("Failed to execute: $command"); + } + } + } } + + if ($deleteConnectedNetworks) { + $service->delete_connected_networks($service->uuid); + } + + instant_remote_process(["docker rm -f $service->uuid"], $server, throwError: false); } catch (\Exception $e) { throw new \Exception($e->getMessage()); } finally { + if ($deleteConfigurations) { + $service->delete_configurations(); + } foreach ($service->applications()->get() as $application) { $application->forceDelete(); } @@ -50,6 +67,11 @@ class DeleteService $task->delete(); } $service->tags()->detach(); + $service->forceDelete(); + + if ($dockerCleanup) { + CleanupDocker::dispatch($server, true); + } } } } diff --git a/app/Actions/Service/StopService.php b/app/Actions/Service/StopService.php index 82b0b3ece..5c7bbc2aa 100644 --- a/app/Actions/Service/StopService.php +++ b/app/Actions/Service/StopService.php @@ -2,6 +2,7 @@ namespace App\Actions\Service; +use App\Actions\Server\CleanupDocker; use App\Models\Service; use Lorisleiva\Actions\Concerns\AsAction; @@ -9,40 +10,27 @@ class StopService { use AsAction; - public function handle(Service $service) + public function handle(Service $service, bool $isDeleteOperation = false, bool $dockerCleanup = true) { try { $server = $service->destination->server; if (! $server->isFunctional()) { return 'Server is not functional'; } - ray('Stopping service: '.$service->name); - $applications = $service->applications()->get(); - foreach ($applications as $application) { - if ($applications->count() < 6) { - instant_remote_process(command: ["docker stop --time=10 {$application->name}-{$service->uuid}"], server: $server, throwError: false); - } - instant_remote_process(command: ["docker rm {$application->name}-{$service->uuid}"], server: $server, throwError: false); - instant_remote_process(command: ["docker rm -f {$application->name}-{$service->uuid}"], server: $server, throwError: false); - $application->update(['status' => 'exited']); - } - $dbs = $service->databases()->get(); - foreach ($dbs as $db) { - if ($dbs->count() < 6) { - instant_remote_process(command: ["docker stop --time=10 {$db->name}-{$service->uuid}"], server: $server, throwError: false); + $containersToStop = $service->getContainersToStop(); + $service->stopContainers($containersToStop, $server); + + if (! $isDeleteOperation) { + $service->delete_connected_networks($service->uuid); + if ($dockerCleanup) { + CleanupDocker::dispatch($server, true); } - instant_remote_process(command: ["docker rm {$db->name}-{$service->uuid}"], server: $server, throwError: false); - instant_remote_process(command: ["docker rm -f {$db->name}-{$service->uuid}"], server: $server, throwError: false); - $db->update(['status' => 'exited']); } - instant_remote_process(["docker network disconnect {$service->uuid} coolify-proxy"], $service->server); - instant_remote_process(["docker network rm {$service->uuid}"], $service->server); } catch (\Exception $e) { ray($e->getMessage()); return $e->getMessage(); } - } } diff --git a/app/Console/Commands/CleanupQueue.php b/app/Console/Commands/CleanupQueue.php deleted file mode 100644 index fd2b637ac..000000000 --- a/app/Console/Commands/CleanupQueue.php +++ /dev/null @@ -1,24 +0,0 @@ -keys('*:laravel*'); - foreach ($keys as $key) { - $keyWithoutPrefix = str_replace($prefix, '', $key); - Redis::connection()->del($keyWithoutPrefix); - } - } -} diff --git a/app/Console/Commands/CleanupRedis.php b/app/Console/Commands/CleanupRedis.php new file mode 100644 index 000000000..ed0740d34 --- /dev/null +++ b/app/Console/Commands/CleanupRedis.php @@ -0,0 +1,31 @@ +keys('*:laravel*'); + collect($keys)->each(function ($key) use ($prefix) { + $keyWithoutPrefix = str_replace($prefix, '', $key); + Redis::connection()->del($keyWithoutPrefix); + }); + + $queueOverlaps = Redis::connection()->keys('*laravel-queue-overlap*'); + collect($queueOverlaps)->each(function ($key) { + Redis::connection()->del($key); + }); + + } +} diff --git a/app/Console/Commands/CleanupStuckedResources.php b/app/Console/Commands/CleanupStuckedResources.php index 68beb448a..dfd09d4b7 100644 --- a/app/Console/Commands/CleanupStuckedResources.php +++ b/app/Console/Commands/CleanupStuckedResources.php @@ -2,10 +2,12 @@ namespace App\Console\Commands; +use App\Jobs\CleanupHelperContainersJob; use App\Models\Application; use App\Models\ApplicationPreview; use App\Models\ScheduledDatabaseBackup; use App\Models\ScheduledTask; +use App\Models\Server; use App\Models\Service; use App\Models\ServiceApplication; use App\Models\ServiceDatabase; @@ -35,6 +37,16 @@ class CleanupStuckedResources extends Command private function cleanup_stucked_resources() { + try { + $servers = Server::all()->filter(function ($server) { + return $server->isFunctional(); + }); + foreach ($servers as $server) { + CleanupHelperContainersJob::dispatch($server); + } + } catch (\Throwable $e) { + echo "Error in cleaning stucked resources: {$e->getMessage()}\n"; + } try { $applications = Application::withTrashed()->whereNotNull('deleted_at')->get(); foreach ($applications as $application) { diff --git a/app/Console/Commands/Init.php b/app/Console/Commands/Init.php index 7bfd1a14f..2f5d36140 100644 --- a/app/Console/Commands/Init.php +++ b/app/Console/Commands/Init.php @@ -5,7 +5,6 @@ namespace App\Console\Commands; use App\Actions\Server\StopSentinel; use App\Enums\ActivityTypes; use App\Enums\ApplicationDeploymentStatus; -use App\Jobs\CleanupHelperContainersJob; use App\Models\ApplicationDeploymentQueue; use App\Models\Environment; use App\Models\InstanceSettings; @@ -18,7 +17,7 @@ use Illuminate\Support\Facades\Http; class Init extends Command { - protected $signature = 'app:init {--full-cleanup} {--cleanup-deployments} {--cleanup-proxy-networks}'; + protected $signature = 'app:init {--force-cloud}'; protected $description = 'Cleanup instance related stuffs'; @@ -26,9 +25,63 @@ class Init extends Command public function handle() { + if (isCloud() && ! $this->option('force-cloud')) { + echo "Skipping init as we are on cloud and --force-cloud option is not set\n"; + + return; + } + $this->servers = Server::all(); - $this->alive(); - get_public_ips(); + if (isCloud()) { + + } else { + $this->send_alive_signal(); + get_public_ips(); + } + + // Backward compatibility + $this->disable_metrics(); + $this->replace_slash_in_environment_name(); + $this->restore_coolify_db_backup(); + // + $this->update_traefik_labels(); + if (! isCloud() || $this->option('force-cloud')) { + $this->cleanup_unused_network_from_coolify_proxy(); + } + if (isCloud()) { + $this->cleanup_unnecessary_dynamic_proxy_configuration(); + } else { + $this->cleanup_in_progress_application_deployments(); + } + $this->call('cleanup:redis'); + $this->call('cleanup:stucked-resources'); + + if (isCloud()) { + $response = Http::retry(3, 1000)->get(config('constants.services.official')); + if ($response->successful()) { + $services = $response->json(); + File::put(base_path('templates/service-templates.json'), json_encode($services)); + } + } else { + try { + $localhost = $this->servers->where('id', 0)->first(); + $localhost->setupDynamicProxyConfiguration(); + } catch (\Throwable $e) { + echo "Could not setup dynamic configuration: {$e->getMessage()}\n"; + } + $settings = InstanceSettings::get(); + if (! is_null(env('AUTOUPDATE', null))) { + if (env('AUTOUPDATE') == true) { + $settings->update(['is_auto_update_enabled' => true]); + } else { + $settings->update(['is_auto_update_enabled' => false]); + } + } + } + } + + private function disable_metrics() + { if (version_compare('4.0.0-beta.312', config('version'), '<=')) { foreach ($this->servers as $server) { if ($server->settings->is_metrics_enabled === true) { @@ -39,62 +92,6 @@ class Init extends Command } } } - - $full_cleanup = $this->option('full-cleanup'); - $cleanup_deployments = $this->option('cleanup-deployments'); - $cleanup_proxy_networks = $this->option('cleanup-proxy-networks'); - $this->replace_slash_in_environment_name(); - if ($cleanup_deployments) { - echo "Running cleanup deployments.\n"; - $this->cleanup_in_progress_application_deployments(); - - return; - } - if ($cleanup_proxy_networks) { - echo "Running cleanup proxy networks.\n"; - $this->cleanup_unused_network_from_coolify_proxy(); - - return; - } - if ($full_cleanup) { - // Required for falsely deleted coolify db - $this->restore_coolify_db_backup(); - $this->update_traefik_labels(); - $this->cleanup_unused_network_from_coolify_proxy(); - $this->cleanup_unnecessary_dynamic_proxy_configuration(); - $this->cleanup_in_progress_application_deployments(); - $this->cleanup_stucked_helper_containers(); - $this->call('cleanup:queue'); - $this->call('cleanup:stucked-resources'); - if (! isCloud()) { - try { - $localhost = $this->servers->where('id', 0)->first(); - $localhost->setupDynamicProxyConfiguration(); - } catch (\Throwable $e) { - echo "Could not setup dynamic configuration: {$e->getMessage()}\n"; - } - } - - $settings = InstanceSettings::get(); - if (! is_null(env('AUTOUPDATE', null))) { - if (env('AUTOUPDATE') == true) { - $settings->update(['is_auto_update_enabled' => true]); - } else { - $settings->update(['is_auto_update_enabled' => false]); - } - } - if (isCloud()) { - $response = Http::retry(3, 1000)->get(config('constants.services.official')); - if ($response->successful()) { - $services = $response->json(); - File::put(base_path('templates/service-templates.json'), json_encode($services)); - } - } - - return; - } - $this->cleanup_stucked_helper_containers(); - $this->call('cleanup:stucked-resources'); } private function update_traefik_labels() @@ -108,33 +105,28 @@ class Init extends Command private function cleanup_unnecessary_dynamic_proxy_configuration() { - if (isCloud()) { - foreach ($this->servers as $server) { - try { - if (! $server->isFunctional()) { - continue; - } - if ($server->id === 0) { - continue; - } - $file = $server->proxyPath().'/dynamic/coolify.yaml'; - - return instant_remote_process([ - "rm -f $file", - ], $server, false); - } catch (\Throwable $e) { - echo "Error in cleaning up unnecessary dynamic proxy configuration: {$e->getMessage()}\n"; + foreach ($this->servers as $server) { + try { + if (! $server->isFunctional()) { + continue; } + if ($server->id === 0) { + continue; + } + $file = $server->proxyPath().'/dynamic/coolify.yaml'; + return instant_remote_process([ + "rm -f $file", + ], $server, false); + } catch (\Throwable $e) { + echo "Error in cleaning up unnecessary dynamic proxy configuration: {$e->getMessage()}\n"; } + } } private function cleanup_unused_network_from_coolify_proxy() { - if (isCloud()) { - return; - } foreach ($this->servers as $server) { if (! $server->isFunctional()) { continue; @@ -175,39 +167,32 @@ class Init extends Command private function restore_coolify_db_backup() { - try { - $database = StandalonePostgresql::withTrashed()->find(0); - if ($database && $database->trashed()) { - echo "Restoring coolify db backup\n"; - $database->restore(); - $scheduledBackup = ScheduledDatabaseBackup::find(0); - if (! $scheduledBackup) { - ScheduledDatabaseBackup::create([ - 'id' => 0, - 'enabled' => true, - 'save_s3' => false, - 'frequency' => '0 0 * * *', - 'database_id' => $database->id, - 'database_type' => 'App\Models\StandalonePostgresql', - 'team_id' => 0, - ]); + if (version_compare('4.0.0-beta.179', config('version'), '<=')) { + try { + $database = StandalonePostgresql::withTrashed()->find(0); + if ($database && $database->trashed()) { + echo "Restoring coolify db backup\n"; + $database->restore(); + $scheduledBackup = ScheduledDatabaseBackup::find(0); + if (! $scheduledBackup) { + ScheduledDatabaseBackup::create([ + 'id' => 0, + 'enabled' => true, + 'save_s3' => false, + 'frequency' => '0 0 * * *', + 'database_id' => $database->id, + 'database_type' => 'App\Models\StandalonePostgresql', + 'team_id' => 0, + ]); + } } - } - } catch (\Throwable $e) { - echo "Error in restoring coolify db backup: {$e->getMessage()}\n"; - } - } - - private function cleanup_stucked_helper_containers() - { - foreach ($this->servers as $server) { - if ($server->isFunctional()) { - CleanupHelperContainersJob::dispatch($server); + } catch (\Throwable $e) { + echo "Error in restoring coolify db backup: {$e->getMessage()}\n"; } } } - private function alive() + private function send_alive_signal() { $id = config('app.id'); $version = config('version'); @@ -225,23 +210,7 @@ class Init extends Command echo "Error in alive: {$e->getMessage()}\n"; } } - // private function cleanup_ssh() - // { - // TODO: it will cleanup id.root@host.docker.internal - // try { - // $files = Storage::allFiles('ssh/keys'); - // foreach ($files as $file) { - // Storage::delete($file); - // } - // $files = Storage::allFiles('ssh/mux'); - // foreach ($files as $file) { - // Storage::delete($file); - // } - // } catch (\Throwable $e) { - // echo "Error in cleaning ssh: {$e->getMessage()}\n"; - // } - // } private function cleanup_in_progress_application_deployments() { // Cleanup any failed deployments @@ -263,11 +232,13 @@ class Init extends Command private function replace_slash_in_environment_name() { - $environments = Environment::all(); - foreach ($environments as $environment) { - if (str_contains($environment->name, '/')) { - $environment->name = str_replace('/', '-', $environment->name); - $environment->save(); + if (version_compare('4.0.0-beta.298', config('version'), '<=')) { + $environments = Environment::all(); + foreach ($environments as $environment) { + if (str_contains($environment->name, '/')) { + $environment->name = str_replace('/', '-', $environment->name); + $environment->save(); + } } } } diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index b960a4a8b..03d479400 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -43,6 +43,8 @@ class Kernel extends ConsoleKernel $schedule->command('uploads:clear')->everyTwoMinutes(); $schedule->command('telescope:prune')->daily(); + + $schedule->job(new PullHelperImageJob)->everyFiveMinutes()->onOneServer(); } else { // Instance Jobs $schedule->command('horizon:snapshot')->everyFiveMinutes(); @@ -77,11 +79,11 @@ class Kernel extends ConsoleKernel } })->cron($settings->update_check_frequency)->timezone($settings->instance_timezone)->onOneServer(); } - $schedule->job(new PullHelperImageJob($server)) - ->cron($settings->update_check_frequency) - ->timezone($settings->instance_timezone) - ->onOneServer(); } + $schedule->job(new PullHelperImageJob) + ->cron($settings->update_check_frequency) + ->timezone($settings->instance_timezone) + ->onOneServer(); } private function schedule_updates($schedule) diff --git a/app/Enums/ContainerStatusTypes.php b/app/Enums/ContainerStatusTypes.php new file mode 100644 index 000000000..ffcb6d5b5 --- /dev/null +++ b/app/Enums/ContainerStatusTypes.php @@ -0,0 +1,14 @@ +user()->currentTeam()->id ?? null; + } + if (is_null($teamId)) { + throw new \Exception('Team id is null'); + } + $this->teamId = $teamId; + } + + public function broadcastOn(): array + { + return [ + new PrivateChannel("team.{$this->teamId}"), + ]; + } +} diff --git a/app/Helpers/SshMultiplexingHelper.php b/app/Helpers/SshMultiplexingHelper.php new file mode 100644 index 000000000..b0a832605 --- /dev/null +++ b/app/Helpers/SshMultiplexingHelper.php @@ -0,0 +1,184 @@ +private_key_id); + $sshKeyLocation = $privateKey->getKeyLocation(); + $muxFilename = '/var/www/html/storage/app/ssh/mux/mux_'.$server->uuid; + + return [ + 'sshKeyLocation' => $sshKeyLocation, + 'muxFilename' => $muxFilename, + ]; + } + + public static function ensureMultiplexedConnection(Server $server) + { + if (! self::isMultiplexingEnabled()) { + return; + } + + $sshConfig = self::serverSshConfiguration($server); + $muxSocket = $sshConfig['muxFilename']; + $sshKeyLocation = $sshConfig['sshKeyLocation']; + + self::validateSshKey($sshKeyLocation); + + $checkCommand = "ssh -O check -o ControlPath=$muxSocket "; + if (data_get($server, 'settings.is_cloudflare_tunnel')) { + $checkCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" '; + } + $checkCommand .= "{$server->user}@{$server->ip}"; + $process = Process::run($checkCommand); + + if ($process->exitCode() !== 0) { + self::establishNewMultiplexedConnection($server); + } + } + + public static function establishNewMultiplexedConnection(Server $server) + { + $sshConfig = self::serverSshConfiguration($server); + $sshKeyLocation = $sshConfig['sshKeyLocation']; + $muxSocket = $sshConfig['muxFilename']; + + $connectionTimeout = config('constants.ssh.connection_timeout'); + $serverInterval = config('constants.ssh.server_interval'); + $muxPersistTime = config('constants.ssh.mux_persist_time'); + + $establishCommand = "ssh -fNM -o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} "; + + if (data_get($server, 'settings.is_cloudflare_tunnel')) { + $establishCommand .= ' -o ProxyCommand="cloudflared access ssh --hostname %h" '; + } + + $establishCommand .= self::getCommonSshOptions($server, $sshKeyLocation, $connectionTimeout, $serverInterval); + $establishCommand .= "{$server->user}@{$server->ip}"; + + $establishProcess = Process::run($establishCommand); + + if ($establishProcess->exitCode() !== 0) { + throw new \RuntimeException('Failed to establish multiplexed connection: '.$establishProcess->errorOutput()); + } + } + + public static function removeMuxFile(Server $server) + { + $sshConfig = self::serverSshConfiguration($server); + $muxSocket = $sshConfig['muxFilename']; + + $closeCommand = "ssh -O exit -o ControlPath=$muxSocket "; + if (data_get($server, 'settings.is_cloudflare_tunnel')) { + $closeCommand .= '-o ProxyCommand="cloudflared access ssh --hostname %h" '; + } + $closeCommand .= "{$server->user}@{$server->ip}"; + Process::run($closeCommand); + } + + public static function generateScpCommand(Server $server, string $source, string $dest) + { + $sshConfig = self::serverSshConfiguration($server); + $sshKeyLocation = $sshConfig['sshKeyLocation']; + $muxSocket = $sshConfig['muxFilename']; + + $timeout = config('constants.ssh.command_timeout'); + $muxPersistTime = config('constants.ssh.mux_persist_time'); + + $scp_command = "timeout $timeout scp "; + + if (self::isMultiplexingEnabled()) { + $scp_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} "; + self::ensureMultiplexedConnection($server); + } + + if (data_get($server, 'settings.is_cloudflare_tunnel')) { + $scp_command .= '-o ProxyCommand="cloudflared access ssh --hostname %h" '; + } + + $scp_command .= self::getCommonSshOptions($server, $sshKeyLocation, config('constants.ssh.connection_timeout'), config('constants.ssh.server_interval'), isScp: true); + $scp_command .= "{$source} {$server->user}@{$server->ip}:{$dest}"; + + return $scp_command; + } + + public static function generateSshCommand(Server $server, string $command) + { + if ($server->settings->force_disabled) { + throw new \RuntimeException('Server is disabled.'); + } + + $sshConfig = self::serverSshConfiguration($server); + $sshKeyLocation = $sshConfig['sshKeyLocation']; + $muxSocket = $sshConfig['muxFilename']; + + $timeout = config('constants.ssh.command_timeout'); + $muxPersistTime = config('constants.ssh.mux_persist_time'); + + $ssh_command = "timeout $timeout ssh "; + + if (self::isMultiplexingEnabled()) { + $ssh_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} "; + self::ensureMultiplexedConnection($server); + } + + if (data_get($server, 'settings.is_cloudflare_tunnel')) { + $ssh_command .= "-o ProxyCommand='cloudflared access ssh --hostname %h' "; + } + + $ssh_command .= self::getCommonSshOptions($server, $sshKeyLocation, config('constants.ssh.connection_timeout'), config('constants.ssh.server_interval')); + + $command = "PATH=\$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/host/usr/local/sbin:/host/usr/local/bin:/host/usr/sbin:/host/usr/bin:/host/sbin:/host/bin && $command"; + $delimiter = Hash::make($command); + $command = str_replace($delimiter, '', $command); + + $ssh_command .= "{$server->user}@{$server->ip} 'bash -se' << \\$delimiter".PHP_EOL + .$command.PHP_EOL + .$delimiter; + + return $ssh_command; + } + + private static function isMultiplexingEnabled(): bool + { + return config('constants.ssh.mux_enabled') && ! config('coolify.is_windows_docker_desktop'); + } + + private static function validateSshKey(string $sshKeyLocation): void + { + $checkKeyCommand = "ls $sshKeyLocation 2>/dev/null"; + $keyCheckProcess = Process::run($checkKeyCommand); + + if ($keyCheckProcess->exitCode() !== 0) { + throw new \RuntimeException("SSH key file not accessible: $sshKeyLocation"); + } + } + + private static function getCommonSshOptions(Server $server, string $sshKeyLocation, int $connectionTimeout, int $serverInterval, bool $isScp = false): string + { + $options = "-i {$sshKeyLocation} " + .'-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ' + .'-o PasswordAuthentication=no ' + ."-o ConnectTimeout=$connectionTimeout " + ."-o ServerAliveInterval=$serverInterval " + .'-o RequestTTY=no ' + .'-o LogLevel=ERROR '; + + // Bruh + if ($isScp) { + $options .= "-P {$server->port} "; + } else { + $options .= "-p {$server->port} "; + } + + return $options; + } +} diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 81b173011..48e126f27 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -2529,6 +2529,131 @@ class ApplicationsController extends Controller } + #[OA\Post( + summary: 'Execute Command', + description: "Execute a command on the application's current container.", + path: '/applications/{uuid}/execute', + operationId: 'execute-command-application', + security: [ + ['bearerAuth' => []], + ], + tags: ['Applications'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'UUID of the application.', + required: true, + schema: new OA\Schema( + type: 'string', + format: 'uuid', + ) + ), + ], + requestBody: new OA\RequestBody( + required: true, + description: 'Command to execute.', + content: new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'command' => ['type' => 'string', 'description' => 'Command to execute.'], + ], + ), + ), + ), + responses: [ + new OA\Response( + response: 200, + description: "Execute a command on the application's current container.", + content: [ + new OA\MediaType( + mediaType: 'application/json', + schema: new OA\Schema( + type: 'object', + properties: [ + 'message' => ['type' => 'string', 'example' => 'Command executed.'], + 'response' => ['type' => 'string'], + ] + ) + ), + ] + ), + new OA\Response( + response: 401, + ref: '#/components/responses/401', + ), + new OA\Response( + response: 400, + ref: '#/components/responses/400', + ), + new OA\Response( + response: 404, + ref: '#/components/responses/404', + ), + ] + )] + public function execute_command_by_uuid(Request $request) + { + // TODO: Need to review this from security perspective, to not allow arbitrary command execution + $allowedFields = ['command']; + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $uuid = $request->route('uuid'); + if (! $uuid) { + return response()->json(['message' => 'UUID is required.'], 400); + } + $application = Application::ownedByCurrentTeamAPI($teamId)->where('uuid', $request->uuid)->first(); + if (! $application) { + return response()->json(['message' => 'Application not found.'], 404); + } + $return = validateIncomingRequest($request); + if ($return instanceof \Illuminate\Http\JsonResponse) { + return $return; + } + $validator = customApiValidator($request->all(), [ + 'command' => 'string|required', + ]); + + $extraFields = array_diff(array_keys($request->all()), $allowedFields); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + if (! empty($extraFields)) { + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + $container = getCurrentApplicationContainerStatus($application->destination->server, $application->id)->firstOrFail(); + $status = getContainerStatus($application->destination->server, $container['Names']); + + if ($status !== 'running') { + return response()->json([ + 'message' => 'Application is not running.', + ], 400); + } + + $commands = collect([ + executeInDocker($container['Names'], $request->command), + ]); + + $res = instant_remote_process(command: $commands, server: $application->destination->server); + + return response()->json([ + 'message' => 'Command executed.', + 'response' => $res, + ]); + } + private function validateDataApplications(Request $request, Server $server) { $teamId = getTeamIdFromToken(); diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 32db4ac32..24565b389 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -27,6 +27,7 @@ use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Process; use Illuminate\Support\Sleep; use Illuminate\Support\Str; use RuntimeException; @@ -210,7 +211,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue } ray('New container name: ', $this->container_name)->green(); - savePrivateKeyToFs($this->server); $this->saved_outputs = collect(); // Set preview fqdn @@ -1456,10 +1456,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue executeInDocker($this->deployment_uuid, 'chmod 600 /root/.ssh/id_rsa'), ], [ - executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i /root/.ssh/id_rsa\" git ls-remote {$this->fullRepoUrl} {$local_branch}"), + executeInDocker($this->deployment_uuid, "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$this->customPort} -o Port={$this->customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null\" git ls-remote {$this->fullRepoUrl} {$local_branch}"), 'hidden' => true, 'save' => 'git_commit_sha', - ], + ] ); } else { $this->execute_remote_command( @@ -2211,20 +2211,40 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $this->application_deployment_queue->addLogEntry('Building docker image completed.'); } - /** - * @param int $timeout in seconds - */ - private function graceful_shutdown_container(string $containerName, int $timeout = 30) + private function graceful_shutdown_container(string $containerName, int $timeout = 300) { try { - $this->execute_remote_command( - ["docker stop --time=$timeout $containerName", 'hidden' => true, 'ignore_errors' => true], - ["docker rm $containerName", 'hidden' => true, 'ignore_errors' => true] - ); + $process = Process::timeout($timeout)->start("docker stop --time=$timeout $containerName"); + + $startTime = time(); + while ($process->running()) { + if (time() - $startTime >= $timeout) { + $this->execute_remote_command( + ["docker kill $containerName", 'hidden' => true, 'ignore_errors' => true] + ); + break; + } + usleep(100000); + } + + $isRunning = $this->execute_remote_command( + ["docker inspect -f '{{.State.Running}}' $containerName", 'hidden' => true, 'ignore_errors' => true] + ) === 'true'; + + if ($isRunning) { + $this->execute_remote_command( + ["docker kill $containerName", 'hidden' => true, 'ignore_errors' => true] + ); + } } catch (\Exception $error) { - // report error if needed + $this->application_deployment_queue->addLogEntry("Error stopping container $containerName: ".$error->getMessage(), 'stderr'); } + $this->remove_container($containerName); + } + + private function remove_container(string $containerName) + { $this->execute_remote_command( ["docker rm -f $containerName", 'hidden' => true, 'ignore_errors' => true] ); diff --git a/app/Jobs/CheckForUpdatesJob.php b/app/Jobs/CheckForUpdatesJob.php index ddc264839..747a9a98a 100644 --- a/app/Jobs/CheckForUpdatesJob.php +++ b/app/Jobs/CheckForUpdatesJob.php @@ -9,8 +9,8 @@ use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; -use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Http; class CheckForUpdatesJob implements ShouldBeEncrypted, ShouldQueue { diff --git a/app/Jobs/CleanupHelperContainersJob.php b/app/Jobs/CleanupHelperContainersJob.php index 7b064a464..b8ca8b7ed 100644 --- a/app/Jobs/CleanupHelperContainersJob.php +++ b/app/Jobs/CleanupHelperContainersJob.php @@ -21,11 +21,10 @@ class CleanupHelperContainersJob implements ShouldBeEncrypted, ShouldBeUnique, S { try { ray('Cleaning up helper containers on '.$this->server->name); - $containers = instant_remote_process(['docker container ps --filter "ancestor=ghcr.io/coollabsio/coolify-helper:next" --filter "ancestor=ghcr.io/coollabsio/coolify-helper:latest" --format \'{{json .}}\''], $this->server, false); - $containers = format_docker_command_output_to_json($containers); - if ($containers->count() > 0) { - foreach ($containers as $container) { - $containerId = data_get($container, 'ID'); + $containers = instant_remote_process(['docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image | contains("ghcr.io/coollabsio/coolify-helper")))\''], $this->server, false); + $containerIds = collect(json_decode($containers))->pluck('ID'); + if ($containerIds->count() > 0) { + foreach ($containerIds as $containerId) { ray('Removing container '.$containerId); instant_remote_process(['docker container rm -f '.$containerId], $this->server, false); } diff --git a/app/Jobs/CleanupStaleMultiplexedConnections.php b/app/Jobs/CleanupStaleMultiplexedConnections.php index bcca77c18..6d49bee4b 100644 --- a/app/Jobs/CleanupStaleMultiplexedConnections.php +++ b/app/Jobs/CleanupStaleMultiplexedConnections.php @@ -3,12 +3,14 @@ namespace App\Jobs; use App\Models\Server; +use Carbon\Carbon; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Process; +use Illuminate\Support\Facades\Storage; class CleanupStaleMultiplexedConnections implements ShouldQueue { @@ -16,22 +18,65 @@ class CleanupStaleMultiplexedConnections implements ShouldQueue public function handle() { - Server::chunk(100, function ($servers) { - foreach ($servers as $server) { - $this->cleanupStaleConnection($server); - } - }); + $this->cleanupStaleConnections(); + $this->cleanupNonExistentServerConnections(); } - private function cleanupStaleConnection(Server $server) + private function cleanupStaleConnections() { - $muxSocket = "/tmp/mux_{$server->id}"; - $checkCommand = "ssh -O check -o ControlPath=$muxSocket {$server->user}@{$server->ip} 2>/dev/null"; - $checkProcess = Process::run($checkCommand); + $muxFiles = Storage::disk('ssh-mux')->files(); - if ($checkProcess->exitCode() !== 0) { - $closeCommand = "ssh -O exit -o ControlPath=$muxSocket {$server->user}@{$server->ip} 2>/dev/null"; - Process::run($closeCommand); + foreach ($muxFiles as $muxFile) { + $serverUuid = $this->extractServerUuidFromMuxFile($muxFile); + $server = Server::where('uuid', $serverUuid)->first(); + + if (! $server) { + $this->removeMultiplexFile($muxFile); + + continue; + } + + $muxSocket = "/var/www/html/storage/app/ssh/mux/{$muxFile}"; + $checkCommand = "ssh -O check -o ControlPath={$muxSocket} {$server->user}@{$server->ip} 2>/dev/null"; + $checkProcess = Process::run($checkCommand); + + if ($checkProcess->exitCode() !== 0) { + $this->removeMultiplexFile($muxFile); + } else { + $muxContent = Storage::disk('ssh-mux')->get($muxFile); + $establishedAt = Carbon::parse(substr($muxContent, 37)); + $expirationTime = $establishedAt->addSeconds(config('constants.ssh.mux_persist_time')); + + if (Carbon::now()->isAfter($expirationTime)) { + $this->removeMultiplexFile($muxFile); + } + } } } + + private function cleanupNonExistentServerConnections() + { + $muxFiles = Storage::disk('ssh-mux')->files(); + $existingServerUuids = Server::pluck('uuid')->toArray(); + + foreach ($muxFiles as $muxFile) { + $serverUuid = $this->extractServerUuidFromMuxFile($muxFile); + if (! in_array($serverUuid, $existingServerUuids)) { + $this->removeMultiplexFile($muxFile); + } + } + } + + private function extractServerUuidFromMuxFile($muxFile) + { + return substr($muxFile, 4); + } + + private function removeMultiplexFile($muxFile) + { + $muxSocket = "/var/www/html/storage/app/ssh/mux/{$muxFile}"; + $closeCommand = "ssh -O exit -o ControlPath={$muxSocket} localhost 2>/dev/null"; + Process::run($closeCommand); + Storage::disk('ssh-mux')->delete($muxFile); + } } diff --git a/app/Jobs/ContainerStatusJob.php b/app/Jobs/ContainerStatusJob.php index e919855d5..22ae06ebd 100644 --- a/app/Jobs/ContainerStatusJob.php +++ b/app/Jobs/ContainerStatusJob.php @@ -9,7 +9,6 @@ use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; -use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; class ContainerStatusJob implements ShouldBeEncrypted, ShouldQueue @@ -25,16 +24,6 @@ class ContainerStatusJob implements ShouldBeEncrypted, ShouldQueue public function __construct(public Server $server) {} - public function middleware(): array - { - return [(new WithoutOverlapping($this->server->uuid))]; - } - - public function uniqueId(): int - { - return $this->server->uuid; - } - public function handle() { GetContainersStatus::run($this->server); diff --git a/app/Jobs/DatabaseBackupJob.php b/app/Jobs/DatabaseBackupJob.php index 3bd13564b..947dc4317 100644 --- a/app/Jobs/DatabaseBackupJob.php +++ b/app/Jobs/DatabaseBackupJob.php @@ -23,7 +23,6 @@ use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; -use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Str; use Visus\Cuid2\Cuid2; @@ -80,16 +79,6 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue } } - public function middleware(): array - { - return [new WithoutOverlapping($this->backup->id)]; - } - - public function uniqueId(): int - { - return $this->backup->id; - } - public function handle(): void { try { diff --git a/app/Jobs/DatabaseBackupStatusJob.php b/app/Jobs/DatabaseBackupStatusJob.php deleted file mode 100644 index d3b0e99cf..000000000 --- a/app/Jobs/DatabaseBackupStatusJob.php +++ /dev/null @@ -1,62 +0,0 @@ -scheduledDatabaseBackups()->get(); - // if ($scheduled_backups->isEmpty()) { - // continue; - // } - // foreach ($scheduled_backups as $scheduled_backup) { - // $last_days_backups = $scheduled_backup->get_last_days_backup_status(); - // if ($last_days_backups->isEmpty()) { - // continue; - // } - // $failed = $last_days_backups->where('status', 'failed'); - // } - // } - - // $scheduled_backups = ScheduledDatabaseBackup::all(); - // $databases = collect(); - // $teams = collect(); - // foreach ($scheduled_backups as $scheduled_backup) { - // $last_days_backups = $scheduled_backup->get_last_days_backup_status(); - // if ($last_days_backups->isEmpty()) { - // continue; - // } - // $failed = $last_days_backups->where('status', 'failed'); - // $database = $scheduled_backup->database; - // $team = $database->team(); - // $teams->put($team->id, $team); - // $databases->put("{$team->id}:{$database->name}", [ - // 'failed_count' => $failed->count(), - // ]); - // } - // foreach ($databases as $name => $database) { - // [$team_id, $name] = explode(':', $name); - // $team = $teams->get($team_id); - // $team?->notify(new DailyBackup($databases)); - // } - } -} diff --git a/app/Jobs/DeleteResourceJob.php b/app/Jobs/DeleteResourceJob.php index dbf44dd5d..ac34d064e 100644 --- a/app/Jobs/DeleteResourceJob.php +++ b/app/Jobs/DeleteResourceJob.php @@ -4,6 +4,7 @@ namespace App\Jobs; use App\Actions\Application\StopApplication; use App\Actions\Database\StopDatabase; +use App\Actions\Server\CleanupDocker; use App\Actions\Service\DeleteService; use App\Actions\Service\StopService; use App\Models\Application; @@ -30,8 +31,11 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue public function __construct( public Application|Service|StandalonePostgresql|StandaloneRedis|StandaloneMongodb|StandaloneMysql|StandaloneMariadb|StandaloneKeydb|StandaloneDragonfly|StandaloneClickhouse $resource, - public bool $deleteConfigurations = false, - public bool $deleteVolumes = false) {} + public bool $deleteConfigurations, + public bool $deleteVolumes, + public bool $dockerCleanup, + public bool $deleteConnectedNetworks + ) {} public function handle() { @@ -51,11 +55,11 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue case 'standalone-dragonfly': case 'standalone-clickhouse': $persistentStorages = $this->resource?->persistentStorages()?->get(); - StopDatabase::run($this->resource); + StopDatabase::run($this->resource, true); break; case 'service': - StopService::run($this->resource); - DeleteService::run($this->resource); + StopService::run($this->resource, true); + DeleteService::run($this->resource, $this->deleteConfigurations, $this->deleteVolumes, $this->dockerCleanup, $this->deleteConnectedNetworks); break; } @@ -65,12 +69,31 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue if ($this->deleteConfigurations) { $this->resource?->delete_configurations(); } + + $isDatabase = $this->resource instanceof StandalonePostgresql + || $this->resource instanceof StandaloneRedis + || $this->resource instanceof StandaloneMongodb + || $this->resource instanceof StandaloneMysql + || $this->resource instanceof StandaloneMariadb + || $this->resource instanceof StandaloneKeydb + || $this->resource instanceof StandaloneDragonfly + || $this->resource instanceof StandaloneClickhouse; + $server = data_get($this->resource, 'server') ?? data_get($this->resource, 'destination.server'); + if (($this->dockerCleanup || $isDatabase) && $server) { + CleanupDocker::dispatch($server, true); + } + + if ($this->deleteConnectedNetworks && ! $isDatabase) { + $this->resource?->delete_connected_networks($this->resource->uuid); + } } catch (\Throwable $e) { - ray($e->getMessage()); send_internal_notification('ContainerStoppingJob failed with: '.$e->getMessage()); throw $e; } finally { $this->resource->forceDelete(); + if ($this->dockerCleanup) { + CleanupDocker::dispatch($server, true); + } Artisan::queue('cleanup:stucked-resources'); } } diff --git a/app/Jobs/DockerCleanupJob.php b/app/Jobs/DockerCleanupJob.php index 4874e9670..e326dc671 100644 --- a/app/Jobs/DockerCleanupJob.php +++ b/app/Jobs/DockerCleanupJob.php @@ -10,7 +10,6 @@ use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; -use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Log; @@ -26,16 +25,6 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue public function __construct(public Server $server, public bool $manualCleanup = false) {} - public function middleware(): array - { - return [new WithoutOverlapping($this->server->id)]; - } - - public function uniqueId(): int - { - return $this->server->id; - } - public function handle(): void { try { diff --git a/app/Jobs/GithubAppPermissionJob.php b/app/Jobs/GithubAppPermissionJob.php index 3188d35d6..9c0a2b55b 100644 --- a/app/Jobs/GithubAppPermissionJob.php +++ b/app/Jobs/GithubAppPermissionJob.php @@ -8,7 +8,6 @@ use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; -use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Http; @@ -25,16 +24,6 @@ class GithubAppPermissionJob implements ShouldBeEncrypted, ShouldQueue public function __construct(public GithubApp $github_app) {} - public function middleware(): array - { - return [(new WithoutOverlapping($this->github_app->uuid))]; - } - - public function uniqueId(): int - { - return $this->github_app->uuid; - } - public function handle() { try { diff --git a/app/Jobs/PullHelperImageJob.php b/app/Jobs/PullHelperImageJob.php index 63b7fa920..ef1659680 100644 --- a/app/Jobs/PullHelperImageJob.php +++ b/app/Jobs/PullHelperImageJob.php @@ -9,7 +9,6 @@ use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; -use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Http; @@ -19,17 +18,7 @@ class PullHelperImageJob implements ShouldBeEncrypted, ShouldQueue public $timeout = 1000; - public function middleware(): array - { - return [(new WithoutOverlapping($this->server->uuid))]; - } - - public function uniqueId(): string - { - return $this->server->uuid; - } - - public function __construct(public Server $server) {} + public function __construct() {} public function handle(): void { diff --git a/app/Jobs/PullSentinelImageJob.php b/app/Jobs/PullSentinelImageJob.php index f8c769382..32f84e6d5 100644 --- a/app/Jobs/PullSentinelImageJob.php +++ b/app/Jobs/PullSentinelImageJob.php @@ -9,7 +9,6 @@ use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; -use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; class PullSentinelImageJob implements ShouldBeEncrypted, ShouldQueue @@ -18,16 +17,6 @@ class PullSentinelImageJob implements ShouldBeEncrypted, ShouldQueue public $timeout = 1000; - public function middleware(): array - { - return [(new WithoutOverlapping($this->server->uuid))]; - } - - public function uniqueId(): string - { - return $this->server->uuid; - } - public function __construct(public Server $server) {} public function handle(): void diff --git a/app/Jobs/ScheduledTaskJob.php b/app/Jobs/ScheduledTaskJob.php index 93d5fca70..6850ae98a 100644 --- a/app/Jobs/ScheduledTaskJob.php +++ b/app/Jobs/ScheduledTaskJob.php @@ -13,7 +13,6 @@ use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; -use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; class ScheduledTaskJob implements ShouldQueue @@ -56,24 +55,17 @@ class ScheduledTaskJob implements ShouldQueue { if ($this->resource instanceof Application) { $timezone = $this->resource->destination->server->settings->server_timezone; + return $timezone; } elseif ($this->resource instanceof Service) { $timezone = $this->resource->server->settings->server_timezone; + return $timezone; } + return 'UTC'; } - public function middleware(): array - { - return [new WithoutOverlapping($this->task->id)]; - } - - public function uniqueId(): int - { - return $this->task->id; - } - public function handle(): void { @@ -94,12 +86,12 @@ class ScheduledTaskJob implements ShouldQueue } elseif ($this->resource->type() == 'service') { $this->resource->applications()->get()->each(function ($application) { if (str(data_get($application, 'status'))->contains('running')) { - $this->containers[] = data_get($application, 'name') . '-' . data_get($this->resource, 'uuid'); + $this->containers[] = data_get($application, 'name').'-'.data_get($this->resource, 'uuid'); } }); $this->resource->databases()->get()->each(function ($database) { if (str(data_get($database, 'status'))->contains('running')) { - $this->containers[] = data_get($database, 'name') . '-' . data_get($this->resource, 'uuid'); + $this->containers[] = data_get($database, 'name').'-'.data_get($this->resource, 'uuid'); } }); } @@ -112,8 +104,8 @@ class ScheduledTaskJob implements ShouldQueue } foreach ($this->containers as $containerName) { - if (count($this->containers) == 1 || str_starts_with($containerName, $this->task->container . '-' . $this->resource->uuid)) { - $cmd = "sh -c '" . str_replace("'", "'\''", $this->task->command) . "'"; + if (count($this->containers) == 1 || str_starts_with($containerName, $this->task->container.'-'.$this->resource->uuid)) { + $cmd = "sh -c '".str_replace("'", "'\''", $this->task->command)."'"; $exec = "docker exec {$containerName} {$cmd}"; $this->task_output = instant_remote_process([$exec], $this->server, true); $this->task_log->update([ diff --git a/app/Jobs/ServerCheckJob.php b/app/Jobs/ServerCheckJob.php index 540085385..39d4aa0c0 100644 --- a/app/Jobs/ServerCheckJob.php +++ b/app/Jobs/ServerCheckJob.php @@ -16,7 +16,6 @@ use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; -use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Arr; @@ -24,7 +23,7 @@ class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; - public $tries = 3; + public $tries = 1; public $timeout = 60; @@ -45,16 +44,6 @@ class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue public function __construct(public Server $server) {} - public function middleware(): array - { - return [(new WithoutOverlapping($this->server->id))]; - } - - public function uniqueId(): int - { - return $this->server->id; - } - public function handle() { try { @@ -80,7 +69,9 @@ class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue return 'No containers found.'; } GetContainersStatus::run($this->server, $this->containers, $containerReplicates); - $this->checkLogDrainContainer(); + if ($this->server->isLogDrainEnabled()) { + $this->checkLogDrainContainer(); + } } } catch (\Throwable $e) { @@ -93,7 +84,7 @@ class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue private function serverStatus() { - ['uptime' => $uptime] = $this->server->validateConnection(); + ['uptime' => $uptime] = $this->server->validateConnection(false); if ($uptime) { if ($this->server->unreachable_notification_sent === true) { $this->server->update(['unreachable_notification_sent' => false]); @@ -126,9 +117,6 @@ class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue private function checkLogDrainContainer() { - if (! $this->server->isLogDrainEnabled()) { - return; - } $foundLogDrainContainer = $this->containers->filter(function ($value, $key) { return data_get($value, 'Name') === '/coolify-log-drain'; })->first(); diff --git a/app/Jobs/ServerLimitCheckJob.php b/app/Jobs/ServerLimitCheckJob.php index 24292025b..b2c816f5d 100644 --- a/app/Jobs/ServerLimitCheckJob.php +++ b/app/Jobs/ServerLimitCheckJob.php @@ -10,7 +10,7 @@ use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; -use Illuminate\Queue\Middleware\WithoutOverlapping; +use Illuminate\Queue\Middleware\; use Illuminate\Queue\SerializesModels; class ServerLimitCheckJob implements ShouldBeEncrypted, ShouldQueue @@ -26,16 +26,6 @@ class ServerLimitCheckJob implements ShouldBeEncrypted, ShouldQueue public function __construct(public Team $team) {} - public function middleware(): array - { - return [(new WithoutOverlapping($this->team->uuid))]; - } - - public function uniqueId(): int - { - return $this->team->uuid; - } - public function handle() { try { diff --git a/app/Jobs/ServerStatusJob.php b/app/Jobs/ServerStatusJob.php index ac9182eca..fcc33c859 100644 --- a/app/Jobs/ServerStatusJob.php +++ b/app/Jobs/ServerStatusJob.php @@ -8,7 +8,6 @@ use Illuminate\Contracts\Queue\ShouldBeEncrypted; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; -use Illuminate\Queue\Middleware\WithoutOverlapping; use Illuminate\Queue\SerializesModels; class ServerStatusJob implements ShouldBeEncrypted, ShouldQueue @@ -26,16 +25,6 @@ class ServerStatusJob implements ShouldBeEncrypted, ShouldQueue public function __construct(public Server $server) {} - public function middleware(): array - { - return [(new WithoutOverlapping($this->server->uuid))]; - } - - public function uniqueId(): int - { - return $this->server->uuid; - } - public function handle() { if (! $this->server->isServerReady($this->tries)) { diff --git a/app/Livewire/Boarding/Index.php b/app/Livewire/Boarding/Index.php index af05ad767..52d4674ee 100644 --- a/app/Livewire/Boarding/Index.php +++ b/app/Livewire/Boarding/Index.php @@ -141,7 +141,7 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== if (! $this->createdServer) { return $this->dispatch('error', 'Localhost server is not found. Something went wrong during installation. Please try to reinstall or contact support.'); } - $this->serverPublicKey = $this->createdServer->privateKey->publicKey(); + $this->serverPublicKey = $this->createdServer->privateKey->getPublicKey(); return $this->validateServer('localhost'); } elseif ($this->selectedServerType === 'remote') { @@ -175,7 +175,7 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== return; } $this->selectedExistingPrivateKey = $this->createdServer->privateKey->id; - $this->serverPublicKey = $this->createdServer->privateKey->publicKey(); + $this->serverPublicKey = $this->createdServer->privateKey->getPublicKey(); $this->updateServerDetails(); $this->currentState = 'validate-server'; } @@ -231,17 +231,24 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== public function savePrivateKey() { $this->validate([ - 'privateKeyName' => 'required', - 'privateKey' => 'required', + 'privateKeyName' => 'required|string|max:255', + 'privateKeyDescription' => 'nullable|string|max:255', + 'privateKey' => 'required|string', ]); - $this->createdPrivateKey = PrivateKey::create([ - 'name' => $this->privateKeyName, - 'description' => $this->privateKeyDescription, - 'private_key' => $this->privateKey, - 'team_id' => currentTeam()->id, - ]); - $this->createdPrivateKey->save(); - $this->currentState = 'create-server'; + + try { + $privateKey = PrivateKey::createAndStore([ + 'name' => $this->privateKeyName, + 'description' => $this->privateKeyDescription, + 'private_key' => $this->privateKey, + 'team_id' => currentTeam()->id, + ]); + + $this->createdPrivateKey = $privateKey; + $this->currentState = 'create-server'; + } catch (\Exception $e) { + $this->addError('privateKey', 'Failed to save private key: '.$e->getMessage()); + } } public function saveServer() diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index 68555d26c..1f0b68dd3 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -30,7 +30,6 @@ class Dashboard extends Component public function cleanup_queue() { - $this->dispatch('success', 'Cleanup started.'); Artisan::queue('cleanup:application-deployment-queue', [ '--team-id' => currentTeam()->id, ]); diff --git a/app/Livewire/Destination/Form.php b/app/Livewire/Destination/Form.php index 7125f2120..87ae83931 100644 --- a/app/Livewire/Destination/Form.php +++ b/app/Livewire/Destination/Form.php @@ -38,7 +38,7 @@ class Form extends Component } $this->destination->delete(); - return redirect()->route('dashboard'); + return redirect()->route('destination.all'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/NavbarDeleteTeam.php b/app/Livewire/NavbarDeleteTeam.php index ec196c154..988add7c8 100644 --- a/app/Livewire/NavbarDeleteTeam.php +++ b/app/Livewire/NavbarDeleteTeam.php @@ -2,13 +2,28 @@ namespace App\Livewire; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Hash; use Livewire\Component; class NavbarDeleteTeam extends Component { - public function delete() + public $team; + + public function mount() { + $this->team = currentTeam()->name; + } + + public function delete($password) + { + if (! Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + + return; + } + $currentTeam = currentTeam(); $currentTeam->delete(); diff --git a/app/Livewire/Project/Application/Deployment/Show.php b/app/Livewire/Project/Application/Deployment/Show.php index f2968f6d9..3de895f8c 100644 --- a/app/Livewire/Project/Application/Deployment/Show.php +++ b/app/Livewire/Project/Application/Deployment/Show.php @@ -4,7 +4,6 @@ namespace App\Livewire\Project\Application\Deployment; use App\Models\Application; use App\Models\ApplicationDeploymentQueue; -use Illuminate\Support\Collection; use Livewire\Component; class Show extends Component diff --git a/app/Livewire/Project/Application/Heading.php b/app/Livewire/Project/Application/Heading.php index c02949e17..1082b48cd 100644 --- a/app/Livewire/Project/Application/Heading.php +++ b/app/Livewire/Project/Application/Heading.php @@ -21,6 +21,8 @@ class Heading extends Component protected string $deploymentUuid; + public bool $docker_cleanup = true; + public function getListeners() { $teamId = auth()->user()->currentTeam()->id; @@ -102,7 +104,7 @@ class Heading extends Component public function stop() { - StopApplication::run($this->application); + StopApplication::run($this->application, false, $this->docker_cleanup); $this->application->status = 'exited'; $this->application->save(); if ($this->application->additional_servers->count() > 0) { @@ -135,4 +137,13 @@ class Heading extends Component 'environment_name' => $this->parameters['environment_name'], ]); } + + public function render() + { + return view('livewire.project.application.heading', [ + 'checkboxes' => [ + ['id' => 'docker_cleanup', 'label' => __('resource.docker_cleanup')], + ], + ]); + } } diff --git a/app/Livewire/Project/Application/Previews.php b/app/Livewire/Project/Application/Previews.php index 317a2ae51..b1ba035dc 100644 --- a/app/Livewire/Project/Application/Previews.php +++ b/app/Livewire/Project/Application/Previews.php @@ -5,7 +5,9 @@ namespace App\Livewire\Project\Application; use App\Actions\Docker\GetContainersStatus; use App\Models\Application; use App\Models\ApplicationPreview; +use Illuminate\Process\InvokedProcess; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Process; use Livewire\Component; use Spatie\Url\Url; use Visus\Cuid2\Cuid2; @@ -184,17 +186,20 @@ class Previews extends Component public function stop(int $pull_request_id) { try { + $server = $this->application->destination->server; + $timeout = 300; + if ($this->application->destination->server->isSwarm()) { - instant_remote_process(["docker stack rm {$this->application->uuid}-{$pull_request_id}"], $this->application->destination->server); + instant_remote_process(["docker stack rm {$this->application->uuid}-{$pull_request_id}"], $server); } else { - $containers = getCurrentApplicationContainerStatus($this->application->destination->server, $this->application->id, $pull_request_id); - foreach ($containers as $container) { - $name = str_replace('/', '', $container['Names']); - instant_remote_process(["docker rm -f $name"], $this->application->destination->server, throwError: false); - } + $containers = getCurrentApplicationContainerStatus($server, $this->application->id, $pull_request_id)->toArray(); + $this->stopContainers($containers, $server, $timeout); } - GetContainersStatus::dispatchSync($this->application->destination->server)->onQueue('high'); - $this->dispatch('reloadWindow'); + + GetContainersStatus::run($server); + $this->application->refresh(); + $this->dispatch('containerStatusUpdated'); + $this->dispatch('success', 'Preview Deployment stopped.'); } catch (\Throwable $e) { return handleError($e, $this); } @@ -203,16 +208,21 @@ class Previews extends Component public function delete(int $pull_request_id) { try { + $server = $this->application->destination->server; + $timeout = 300; + if ($this->application->destination->server->isSwarm()) { - instant_remote_process(["docker stack rm {$this->application->uuid}-{$pull_request_id}"], $this->application->destination->server); + instant_remote_process(["docker stack rm {$this->application->uuid}-{$pull_request_id}"], $server); } else { - $containers = getCurrentApplicationContainerStatus($this->application->destination->server, $this->application->id, $pull_request_id); - foreach ($containers as $container) { - $name = str_replace('/', '', $container['Names']); - instant_remote_process(["docker rm -f $name"], $this->application->destination->server, throwError: false); - } + $containers = getCurrentApplicationContainerStatus($server, $this->application->id, $pull_request_id)->toArray(); + $this->stopContainers($containers, $server, $timeout); } - ApplicationPreview::where('application_id', $this->application->id)->where('pull_request_id', $pull_request_id)->first()->delete(); + + ApplicationPreview::where('application_id', $this->application->id) + ->where('pull_request_id', $pull_request_id) + ->first() + ->delete(); + $this->application->refresh(); $this->dispatch('update_links'); $this->dispatch('success', 'Preview deleted.'); @@ -220,4 +230,49 @@ class Previews extends Component return handleError($e, $this); } } + + private function stopContainers(array $containers, $server, int $timeout) + { + $processes = []; + foreach ($containers as $container) { + $containerName = str_replace('/', '', $container['Names']); + $processes[$containerName] = $this->stopContainer($containerName, $timeout); + } + + $startTime = time(); + while (count($processes) > 0) { + $finishedProcesses = array_filter($processes, function ($process) { + return ! $process->running(); + }); + foreach (array_keys($finishedProcesses) as $containerName) { + unset($processes[$containerName]); + $this->removeContainer($containerName, $server); + } + + if (time() - $startTime >= $timeout) { + $this->forceStopRemainingContainers(array_keys($processes), $server); + break; + } + + usleep(100000); + } + } + + private function stopContainer(string $containerName, int $timeout): InvokedProcess + { + return Process::timeout($timeout)->start("docker stop --time=$timeout $containerName"); + } + + private function removeContainer(string $containerName, $server) + { + instant_remote_process(["docker rm -f $containerName"], $server, throwError: false); + } + + private function forceStopRemainingContainers(array $containerNames, $server) + { + foreach ($containerNames as $containerName) { + instant_remote_process(["docker kill $containerName"], $server, throwError: false); + $this->removeContainer($containerName, $server); + } + } } diff --git a/app/Livewire/Project/Database/BackupEdit.php b/app/Livewire/Project/Database/BackupEdit.php index 59f2f9a39..ec87beead 100644 --- a/app/Livewire/Project/Database/BackupEdit.php +++ b/app/Livewire/Project/Database/BackupEdit.php @@ -3,6 +3,8 @@ namespace App\Livewire\Project\Database; use App\Models\ScheduledDatabaseBackup; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Hash; use Livewire\Component; use Spatie\Url\Url; @@ -12,6 +14,12 @@ class BackupEdit extends Component public $s3s; + public bool $delete_associated_backups_locally = false; + + public bool $delete_associated_backups_s3 = false; + + public bool $delete_associated_backups_sftp = false; + public ?string $status = null; public array $parameters; @@ -46,10 +54,24 @@ class BackupEdit extends Component } } - public function delete() + public function delete($password) { + if (! Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + + return; + } + try { + if ($this->delete_associated_backups_locally) { + $this->deleteAssociatedBackupsLocally(); + } + if ($this->delete_associated_backups_s3) { + $this->deleteAssociatedBackupsS3(); + } + $this->backup->delete(); + if ($this->backup->database->getMorphClass() === 'App\Models\ServiceDatabase') { $previousUrl = url()->previous(); $url = Url::fromString($previousUrl); @@ -104,4 +126,66 @@ class BackupEdit extends Component $this->dispatch('error', $e->getMessage()); } } + + public function deleteAssociatedBackupsLocally() + { + $executions = $this->backup->executions; + $backupFolder = null; + + foreach ($executions as $execution) { + if ($this->backup->database->getMorphClass() === 'App\Models\ServiceDatabase') { + $server = $this->backup->database->service->destination->server; + } else { + $server = $this->backup->database->destination->server; + } + + if (! $backupFolder) { + $backupFolder = dirname($execution->filename); + } + + delete_backup_locally($execution->filename, $server); + $execution->delete(); + } + + if ($backupFolder) { + $this->deleteEmptyBackupFolder($backupFolder, $server); + } + } + + public function deleteAssociatedBackupsS3() + { + //Add function to delete backups from S3 + } + + public function deleteAssociatedBackupsSftp() + { + //Add function to delete backups from SFTP + } + + private function deleteEmptyBackupFolder($folderPath, $server) + { + $checkEmpty = instant_remote_process(["[ -z \"$(ls -A '$folderPath')\" ] && echo 'empty' || echo 'not empty'"], $server); + + if (trim($checkEmpty) === 'empty') { + instant_remote_process(["rmdir '$folderPath'"], $server); + + $parentFolder = dirname($folderPath); + $checkParentEmpty = instant_remote_process(["[ -z \"$(ls -A '$parentFolder')\" ] && echo 'empty' || echo 'not empty'"], $server); + + if (trim($checkParentEmpty) === 'empty') { + instant_remote_process(["rmdir '$parentFolder'"], $server); + } + } + } + + public function render() + { + return view('livewire.project.database.backup-edit', [ + 'checkboxes' => [ + ['id' => 'delete_associated_backups_locally', 'label' => 'All backups associated with this backup job from this database will be permanently deleted from local storage.'], + // ['id' => 'delete_associated_backups_s3', 'label' => 'All backups associated with this backup job from this database will be permanently deleted from the selected S3 Storage.'] + // ['id' => 'delete_associated_backups_sftp', 'label' => 'All backups associated with this backup job from this database will be permanently deleted from the selected SFTP Storage.'] + ], + ]); + } } diff --git a/app/Livewire/Project/Database/BackupExecutions.php b/app/Livewire/Project/Database/BackupExecutions.php index 5d56ea53d..c8c33a022 100644 --- a/app/Livewire/Project/Database/BackupExecutions.php +++ b/app/Livewire/Project/Database/BackupExecutions.php @@ -3,18 +3,28 @@ namespace App\Livewire\Project\Database; use App\Models\ScheduledDatabaseBackup; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Hash; +use Livewire\Attributes\On; use Livewire\Component; class BackupExecutions extends Component { public ?ScheduledDatabaseBackup $backup = null; + public $database; + public $executions = []; + public $setDeletableBackup; + public $delete_backup_s3 = true; + + public $delete_backup_sftp = true; + public function getListeners() { - $userId = auth()->user()->id; + $userId = Auth::id(); return [ "echo-private:team.{$userId},BackupCreated" => 'refreshBackupExecutions', @@ -31,19 +41,36 @@ class BackupExecutions extends Component } } - public function deleteBackup($exeuctionId) + #[On('deleteBackup')] + public function deleteBackup($executionId, $password) { - $execution = $this->backup->executions()->where('id', $exeuctionId)->first(); + if (! Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + + return; + } + + $execution = $this->backup->executions()->where('id', $executionId)->first(); if (is_null($execution)) { $this->dispatch('error', 'Backup execution not found.'); return; } + if ($execution->scheduledDatabaseBackup->database->getMorphClass() === 'App\Models\ServiceDatabase') { delete_backup_locally($execution->filename, $execution->scheduledDatabaseBackup->database->service->destination->server); } else { delete_backup_locally($execution->filename, $execution->scheduledDatabaseBackup->database->destination->server); } + + if ($this->delete_backup_s3) { + // Add logic to delete from S3 + } + + if ($this->delete_backup_sftp) { + // Add logic to delete from SFTP + } + $execution->delete(); $this->dispatch('success', 'Backup deleted.'); $this->refreshBackupExecutions(); @@ -82,16 +109,18 @@ class BackupExecutions extends Component return $server; } } + return null; } public function getServerTimezone() { $server = $this->server(); - if (!$server) { + if (! $server) { return 'UTC'; } $serverTimezone = $server->settings->server_timezone; + return $serverTimezone; } @@ -104,6 +133,17 @@ class BackupExecutions extends Component } catch (\Exception $e) { $dateObj->setTimezone(new \DateTimeZone('UTC')); } + return $dateObj->format('Y-m-d H:i:s T'); } + + public function render() + { + return view('livewire.project.database.backup-executions', [ + 'checkboxes' => [ + ['id' => 'delete_backup_s3', 'label' => 'Delete the selected backup permanently form S3 Storage'], + ['id' => 'delete_backup_sftp', 'label' => 'Delete the selected backup permanently form SFTP Storage'], + ], + ]); + } } diff --git a/app/Livewire/Project/Database/Clickhouse/General.php b/app/Livewire/Project/Database/Clickhouse/General.php index a6e2a1320..7a6446815 100644 --- a/app/Livewire/Project/Database/Clickhouse/General.php +++ b/app/Livewire/Project/Database/Clickhouse/General.php @@ -56,7 +56,7 @@ class General extends Component public function instantSaveAdvanced() { try { - if (!$this->server->isLogDrainEnabled()) { + if (! $this->server->isLogDrainEnabled()) { $this->database->is_log_drain_enabled = false; $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); @@ -73,14 +73,14 @@ class General extends Component public function instantSave() { try { - if ($this->database->is_public && !$this->database->public_port) { + if ($this->database->is_public && ! $this->database->public_port) { $this->dispatch('error', 'Public port is required.'); $this->database->is_public = false; return; } if ($this->database->is_public) { - if (!str($this->database->status)->startsWith('running')) { + if (! str($this->database->status)->startsWith('running')) { $this->dispatch('error', 'Database must be started to be publicly accessible.'); $this->database->is_public = false; @@ -95,7 +95,7 @@ class General extends Component $this->db_url_public = $this->database->external_db_url; $this->database->save(); } catch (\Throwable $e) { - $this->database->is_public = !$this->database->is_public; + $this->database->is_public = ! $this->database->is_public; return handleError($e, $this); } diff --git a/app/Livewire/Project/Database/Dragonfly/General.php b/app/Livewire/Project/Database/Dragonfly/General.php index 00e0ff09f..394ba6c9a 100644 --- a/app/Livewire/Project/Database/Dragonfly/General.php +++ b/app/Livewire/Project/Database/Dragonfly/General.php @@ -54,7 +54,7 @@ class General extends Component public function instantSaveAdvanced() { try { - if (!$this->server->isLogDrainEnabled()) { + if (! $this->server->isLogDrainEnabled()) { $this->database->is_log_drain_enabled = false; $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); @@ -88,14 +88,14 @@ class General extends Component public function instantSave() { try { - if ($this->database->is_public && !$this->database->public_port) { + if ($this->database->is_public && ! $this->database->public_port) { $this->dispatch('error', 'Public port is required.'); $this->database->is_public = false; return; } if ($this->database->is_public) { - if (!str($this->database->status)->startsWith('running')) { + if (! str($this->database->status)->startsWith('running')) { $this->dispatch('error', 'Database must be started to be publicly accessible.'); $this->database->is_public = false; @@ -110,7 +110,7 @@ class General extends Component $this->db_url_public = $this->database->external_db_url; $this->database->save(); } catch (\Throwable $e) { - $this->database->is_public = !$this->database->is_public; + $this->database->is_public = ! $this->database->is_public; return handleError($e, $this); } diff --git a/app/Livewire/Project/Database/Heading.php b/app/Livewire/Project/Database/Heading.php index 6435f6781..765213f60 100644 --- a/app/Livewire/Project/Database/Heading.php +++ b/app/Livewire/Project/Database/Heading.php @@ -14,6 +14,8 @@ class Heading extends Component public array $parameters; + public $docker_cleanup = true; + public function getListeners() { $userId = auth()->user()->id; @@ -54,7 +56,7 @@ class Heading extends Component public function stop() { - StopDatabase::run($this->database); + StopDatabase::run($this->database, false, $this->docker_cleanup); $this->database->status = 'exited'; $this->database->save(); $this->check_status(); @@ -71,4 +73,13 @@ class Heading extends Component $activity = StartDatabase::run($this->database); $this->dispatch('activityMonitor', $activity->id); } + + public function render() + { + return view('livewire.project.database.heading', [ + 'checkboxes' => [ + ['id' => 'docker_cleanup', 'label' => 'Cleanup docker build cache and unused images (next deployment could take longer).'], + ], + ]); + } } diff --git a/app/Livewire/Project/Database/Keydb/General.php b/app/Livewire/Project/Database/Keydb/General.php index 320feeac7..f976e1edd 100644 --- a/app/Livewire/Project/Database/Keydb/General.php +++ b/app/Livewire/Project/Database/Keydb/General.php @@ -57,7 +57,7 @@ class General extends Component public function instantSaveAdvanced() { try { - if (!$this->server->isLogDrainEnabled()) { + if (! $this->server->isLogDrainEnabled()) { $this->database->is_log_drain_enabled = false; $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); @@ -94,14 +94,14 @@ class General extends Component public function instantSave() { try { - if ($this->database->is_public && !$this->database->public_port) { + if ($this->database->is_public && ! $this->database->public_port) { $this->dispatch('error', 'Public port is required.'); $this->database->is_public = false; return; } if ($this->database->is_public) { - if (!str($this->database->status)->startsWith('running')) { + if (! str($this->database->status)->startsWith('running')) { $this->dispatch('error', 'Database must be started to be publicly accessible.'); $this->database->is_public = false; @@ -116,7 +116,7 @@ class General extends Component $this->db_url_public = $this->database->external_db_url; $this->database->save(); } catch (\Throwable $e) { - $this->database->is_public = !$this->database->is_public; + $this->database->is_public = ! $this->database->is_public; return handleError($e, $this); } diff --git a/app/Livewire/Project/Database/Mariadb/General.php b/app/Livewire/Project/Database/Mariadb/General.php index 70545910c..12d4882f3 100644 --- a/app/Livewire/Project/Database/Mariadb/General.php +++ b/app/Livewire/Project/Database/Mariadb/General.php @@ -63,7 +63,7 @@ class General extends Component public function instantSaveAdvanced() { try { - if (!$this->server->isLogDrainEnabled()) { + if (! $this->server->isLogDrainEnabled()) { $this->database->is_log_drain_enabled = false; $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); @@ -100,14 +100,14 @@ class General extends Component public function instantSave() { try { - if ($this->database->is_public && !$this->database->public_port) { + if ($this->database->is_public && ! $this->database->public_port) { $this->dispatch('error', 'Public port is required.'); $this->database->is_public = false; return; } if ($this->database->is_public) { - if (!str($this->database->status)->startsWith('running')) { + if (! str($this->database->status)->startsWith('running')) { $this->dispatch('error', 'Database must be started to be publicly accessible.'); $this->database->is_public = false; @@ -122,7 +122,7 @@ class General extends Component $this->db_url_public = $this->database->external_db_url; $this->database->save(); } catch (\Throwable $e) { - $this->database->is_public = !$this->database->is_public; + $this->database->is_public = ! $this->database->is_public; return handleError($e, $this); } diff --git a/app/Livewire/Project/Database/Mongodb/General.php b/app/Livewire/Project/Database/Mongodb/General.php index d23b66c00..ac40e7dfa 100644 --- a/app/Livewire/Project/Database/Mongodb/General.php +++ b/app/Livewire/Project/Database/Mongodb/General.php @@ -61,7 +61,7 @@ class General extends Component public function instantSaveAdvanced() { try { - if (!$this->server->isLogDrainEnabled()) { + if (! $this->server->isLogDrainEnabled()) { $this->database->is_log_drain_enabled = false; $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); @@ -101,14 +101,14 @@ class General extends Component public function instantSave() { try { - if ($this->database->is_public && !$this->database->public_port) { + if ($this->database->is_public && ! $this->database->public_port) { $this->dispatch('error', 'Public port is required.'); $this->database->is_public = false; return; } if ($this->database->is_public) { - if (!str($this->database->status)->startsWith('running')) { + if (! str($this->database->status)->startsWith('running')) { $this->dispatch('error', 'Database must be started to be publicly accessible.'); $this->database->is_public = false; @@ -123,7 +123,7 @@ class General extends Component $this->db_url_public = $this->database->external_db_url; $this->database->save(); } catch (\Throwable $e) { - $this->database->is_public = !$this->database->is_public; + $this->database->is_public = ! $this->database->is_public; return handleError($e, $this); } diff --git a/app/Livewire/Project/Database/Mysql/General.php b/app/Livewire/Project/Database/Mysql/General.php index 29a9cbae2..7d5270ddf 100644 --- a/app/Livewire/Project/Database/Mysql/General.php +++ b/app/Livewire/Project/Database/Mysql/General.php @@ -62,7 +62,7 @@ class General extends Component public function instantSaveAdvanced() { try { - if (!$this->server->isLogDrainEnabled()) { + if (! $this->server->isLogDrainEnabled()) { $this->database->is_log_drain_enabled = false; $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); @@ -99,14 +99,14 @@ class General extends Component public function instantSave() { try { - if ($this->database->is_public && !$this->database->public_port) { + if ($this->database->is_public && ! $this->database->public_port) { $this->dispatch('error', 'Public port is required.'); $this->database->is_public = false; return; } if ($this->database->is_public) { - if (!str($this->database->status)->startsWith('running')) { + if (! str($this->database->status)->startsWith('running')) { $this->dispatch('error', 'Database must be started to be publicly accessible.'); $this->database->is_public = false; @@ -121,7 +121,7 @@ class General extends Component $this->db_url_public = $this->database->external_db_url; $this->database->save(); } catch (\Throwable $e) { - $this->database->is_public = !$this->database->is_public; + $this->database->is_public = ! $this->database->is_public; return handleError($e, $this); } diff --git a/app/Livewire/Project/Database/Redis/General.php b/app/Livewire/Project/Database/Redis/General.php index fd2f9834f..72fd95de8 100644 --- a/app/Livewire/Project/Database/Redis/General.php +++ b/app/Livewire/Project/Database/Redis/General.php @@ -57,7 +57,7 @@ class General extends Component public function instantSaveAdvanced() { try { - if (!$this->server->isLogDrainEnabled()) { + if (! $this->server->isLogDrainEnabled()) { $this->database->is_log_drain_enabled = false; $this->dispatch('error', 'Log drain is not enabled on the server. Please enable it first.'); @@ -88,14 +88,14 @@ class General extends Component public function instantSave() { try { - if ($this->database->is_public && !$this->database->public_port) { + if ($this->database->is_public && ! $this->database->public_port) { $this->dispatch('error', 'Public port is required.'); $this->database->is_public = false; return; } if ($this->database->is_public) { - if (!str($this->database->status)->startsWith('running')) { + if (! str($this->database->status)->startsWith('running')) { $this->dispatch('error', 'Database must be started to be publicly accessible.'); $this->database->is_public = false; @@ -110,7 +110,7 @@ class General extends Component $this->db_url_public = $this->database->external_db_url; $this->database->save(); } catch (\Throwable $e) { - $this->database->is_public = !$this->database->is_public; + $this->database->is_public = ! $this->database->is_public; return handleError($e, $this); } diff --git a/app/Livewire/Project/DeleteEnvironment.php b/app/Livewire/Project/DeleteEnvironment.php index 22478916f..e01741770 100644 --- a/app/Livewire/Project/DeleteEnvironment.php +++ b/app/Livewire/Project/DeleteEnvironment.php @@ -13,9 +13,12 @@ class DeleteEnvironment extends Component public bool $disabled = false; + public string $environmentName = ''; + public function mount() { $this->parameters = get_route_parameters(); + $this->environmentName = Environment::findOrFail($this->environment_id)->name; } public function delete() diff --git a/app/Livewire/Project/DeleteProject.php b/app/Livewire/Project/DeleteProject.php index 499b86e3e..360fad10a 100644 --- a/app/Livewire/Project/DeleteProject.php +++ b/app/Livewire/Project/DeleteProject.php @@ -13,9 +13,12 @@ class DeleteProject extends Component public bool $disabled = false; + public string $projectName = ''; + public function mount() { $this->parameters = get_route_parameters(); + $this->projectName = Project::findOrFail($this->project_id)->name; } public function delete() diff --git a/app/Livewire/Project/Service/Configuration.php b/app/Livewire/Project/Service/Configuration.php index fa44fdfbf..a2e48fee7 100644 --- a/app/Livewire/Project/Service/Configuration.php +++ b/app/Livewire/Project/Service/Configuration.php @@ -52,7 +52,7 @@ class Configuration extends Component $application = $this->service->applications->find($id); if ($application) { $application->restart(); - $this->dispatch('success', 'Application restarted successfully.'); + $this->dispatch('success', 'Service application restarted successfully.'); } } catch (\Exception $e) { return handleError($e, $this); @@ -65,7 +65,7 @@ class Configuration extends Component $database = $this->service->databases->find($id); if ($database) { $database->restart(); - $this->dispatch('success', 'Database restarted successfully.'); + $this->dispatch('success', 'Service database restarted successfully.'); } } catch (\Exception $e) { return handleError($e, $this); diff --git a/app/Livewire/Project/Service/FileStorage.php b/app/Livewire/Project/Service/FileStorage.php index 6cd54883e..215019112 100644 --- a/app/Livewire/Project/Service/FileStorage.php +++ b/app/Livewire/Project/Service/FileStorage.php @@ -14,6 +14,8 @@ use App\Models\StandaloneMongodb; use App\Models\StandaloneMysql; use App\Models\StandalonePostgresql; use App\Models\StandaloneRedis; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Hash; use Livewire\Component; class FileStorage extends Component @@ -83,8 +85,14 @@ class FileStorage extends Component } } - public function delete() + public function delete($password) { + if (! Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + + return; + } + try { $message = 'File deleted.'; if ($this->fileStorage->is_directory) { @@ -129,6 +137,13 @@ class FileStorage extends Component public function render() { - return view('livewire.project.service.file-storage'); + return view('livewire.project.service.file-storage', [ + 'directoryDeletionCheckboxes' => [ + ['id' => 'permanently_delete', 'label' => 'The selected directory and all its contents will be permantely deleted form the server.'], + ], + 'fileDeletionCheckboxes' => [ + ['id' => 'permanently_delete', 'label' => 'The selected file will be permanently deleted form the server.'], + ], + ]); } } diff --git a/app/Livewire/Project/Service/Navbar.php b/app/Livewire/Project/Service/Navbar.php index e6bb6d9bf..42c9357fd 100644 --- a/app/Livewire/Project/Service/Navbar.php +++ b/app/Livewire/Project/Service/Navbar.php @@ -20,6 +20,8 @@ class Navbar extends Component public $isDeploymentProgress = false; + public $docker_cleanup = true; + public $title = 'Configuration'; public function mount() @@ -42,7 +44,7 @@ class Navbar extends Component public function serviceStarted() { - $this->dispatch('success', 'Service status changed.'); + // $this->dispatch('success', 'Service status changed.'); if (is_null($this->service->config_hash) || $this->service->isConfigurationChanged()) { $this->service->isConfigurationChanged(true); $this->dispatch('configurationChanged'); @@ -62,11 +64,6 @@ class Navbar extends Component $this->dispatch('success', 'Service status updated.'); } - public function render() - { - return view('livewire.project.service.navbar'); - } - public function checkDeployments() { try { @@ -97,14 +94,9 @@ class Navbar extends Component $this->dispatch('activityMonitor', $activity->id); } - public function stop(bool $forceCleanup = false) + public function stop() { - StopService::run($this->service); - if ($forceCleanup) { - $this->dispatch('success', 'Containers cleaned up.'); - } else { - $this->dispatch('success', 'Service stopped.'); - } + StopService::run($this->service, false, $this->docker_cleanup); ServiceStatusChanged::dispatch(); } @@ -123,4 +115,13 @@ class Navbar extends Component $activity = StartService::run($this->service); $this->dispatch('activityMonitor', $activity->id); } + + public function render() + { + return view('livewire.project.service.navbar', [ + 'checkboxes' => [ + ['id' => 'docker_cleanup', 'label' => __('resource.docker_cleanup')], + ], + ]); + } } diff --git a/app/Livewire/Project/Service/ServiceApplicationView.php b/app/Livewire/Project/Service/ServiceApplicationView.php index e7d00c3dd..56b506043 100644 --- a/app/Livewire/Project/Service/ServiceApplicationView.php +++ b/app/Livewire/Project/Service/ServiceApplicationView.php @@ -3,6 +3,8 @@ namespace App\Livewire\Project\Service; use App\Models\ServiceApplication; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Hash; use Livewire\Component; class ServiceApplicationView extends Component @@ -11,6 +13,10 @@ class ServiceApplicationView extends Component public $parameters; + public $docker_cleanup = true; + + public $delete_volumes = true; + protected $rules = [ 'application.human_name' => 'nullable', 'application.description' => 'nullable', @@ -23,11 +29,6 @@ class ServiceApplicationView extends Component 'application.is_stripprefix_enabled' => 'nullable|boolean', ]; - public function render() - { - return view('livewire.project.service.service-application-view'); - } - public function updatedApplicationFqdn() { $this->application->fqdn = str($this->application->fqdn)->replaceEnd(',', '')->trim(); @@ -56,8 +57,14 @@ class ServiceApplicationView extends Component $this->dispatch('success', 'You need to restart the service for the changes to take effect.'); } - public function delete() + public function delete($password) { + if (! Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + + return; + } + try { $this->application->delete(); $this->dispatch('success', 'Application deleted.'); @@ -91,4 +98,17 @@ class ServiceApplicationView extends Component $this->dispatch('generateDockerCompose'); } } + + public function render() + { + return view('livewire.project.service.service-application-view', [ + 'checkboxes' => [ + ['id' => 'delete_volumes', 'label' => __('resource.delete_volumes')], + ['id' => 'docker_cleanup', 'label' => __('resource.docker_cleanup')], + // ['id' => 'delete_associated_backups_locally', 'label' => 'All backups associated with this Ressource will be permanently deleted from local storage.'], + // ['id' => 'delete_associated_backups_s3', 'label' => 'All backups associated with this Ressource will be permanently deleted from the selected S3 Storage.'], + // ['id' => 'delete_associated_backups_sftp', 'label' => 'All backups associated with this Ressource will be permanently deleted from the selected SFTP Storage.'] + ], + ]); + } } diff --git a/app/Livewire/Project/Service/StackForm.php b/app/Livewire/Project/Service/StackForm.php index 04bb136db..7f2416e3e 100644 --- a/app/Livewire/Project/Service/StackForm.php +++ b/app/Livewire/Project/Service/StackForm.php @@ -33,7 +33,7 @@ class StackForm extends Component $key = data_get($field, 'key'); $value = data_get($field, 'value'); $rules = data_get($field, 'rules', 'nullable'); - $isPassword = data_get($field, 'isPassword'); + $isPassword = data_get($field, 'isPassword', false); $this->fields->put($key, [ 'serviceName' => $serviceName, 'key' => $key, @@ -47,7 +47,15 @@ class StackForm extends Component $this->validationAttributes["fields.$key.value"] = $fieldKey; } } - $this->fields = $this->fields->sortBy('name'); + $this->fields = $this->fields->groupBy('serviceName')->map(function ($group) { + return $group->sortBy(function ($field) { + return data_get($field, 'isPassword') ? 1 : 0; + })->mapWithKeys(function ($field) { + return [$field['key'] => $field]; + }); + })->flatMap(function ($group) { + return $group; + }); } public function saveCompose($raw) diff --git a/app/Livewire/Project/Shared/Danger.php b/app/Livewire/Project/Shared/Danger.php index 5f0178be4..543e64539 100644 --- a/app/Livewire/Project/Shared/Danger.php +++ b/app/Livewire/Project/Shared/Danger.php @@ -3,6 +3,11 @@ namespace App\Livewire\Project\Shared; use App\Jobs\DeleteResourceJob; +use App\Models\Service; +use App\Models\ServiceApplication; +use App\Models\ServiceDatabase; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Hash; use Livewire\Component; use Visus\Cuid2\Cuid2; @@ -10,6 +15,8 @@ class Danger extends Component { public $resource; + public $resourceName; + public $projectUuid; public $environmentName; @@ -18,22 +25,93 @@ class Danger extends Component public bool $delete_volumes = true; + public bool $docker_cleanup = true; + + public bool $delete_connected_networks = true; + public ?string $modalId = null; + public string $resourceDomain = ''; + public function mount() { - $this->modalId = new Cuid2; $parameters = get_route_parameters(); + $this->modalId = new Cuid2; $this->projectUuid = data_get($parameters, 'project_uuid'); $this->environmentName = data_get($parameters, 'environment_name'); + + if ($this->resource === null) { + if (isset($parameters['service_uuid'])) { + $this->resource = Service::where('uuid', $parameters['service_uuid'])->first(); + } elseif (isset($parameters['stack_service_uuid'])) { + $this->resource = ServiceApplication::where('uuid', $parameters['stack_service_uuid'])->first() + ?? ServiceDatabase::where('uuid', $parameters['stack_service_uuid'])->first(); + } + } + + if ($this->resource === null) { + $this->resourceName = 'Unknown Resource'; + + return; + } + + if (! method_exists($this->resource, 'type')) { + $this->resourceName = 'Unknown Resource'; + + return; + } + + switch ($this->resource->type()) { + case 'application': + $this->resourceName = $this->resource->name ?? 'Application'; + break; + case 'standalone-postgresql': + case 'standalone-redis': + case 'standalone-mongodb': + case 'standalone-mysql': + case 'standalone-mariadb': + case 'standalone-keydb': + case 'standalone-dragonfly': + case 'standalone-clickhouse': + $this->resourceName = $this->resource->name ?? 'Database'; + break; + case 'service': + $this->resourceName = $this->resource->name ?? 'Service'; + break; + case 'service-application': + $this->resourceName = $this->resource->name ?? 'Service Application'; + break; + case 'service-database': + $this->resourceName = $this->resource->name ?? 'Service Database'; + break; + default: + $this->resourceName = 'Unknown Resource'; + } } - public function delete() + public function delete($password) { + if (! Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + + return; + } + + if (! $this->resource) { + $this->addError('resource', 'Resource not found.'); + + return; + } + try { - // $this->authorize('delete', $this->resource); $this->resource->delete(); - DeleteResourceJob::dispatch($this->resource, $this->delete_configurations, $this->delete_volumes); + DeleteResourceJob::dispatch( + $this->resource, + $this->delete_configurations, + $this->delete_volumes, + $this->docker_cleanup, + $this->delete_connected_networks + ); return redirect()->route('project.resource.index', [ 'project_uuid' => $this->projectUuid, @@ -43,4 +121,19 @@ class Danger extends Component return handleError($e, $this); } } + + public function render() + { + return view('livewire.project.shared.danger', [ + 'checkboxes' => [ + ['id' => 'delete_volumes', 'label' => __('resource.delete_volumes')], + ['id' => 'delete_connected_networks', 'label' => __('resource.delete_connected_networks')], + ['id' => 'delete_configurations', 'label' => __('resource.delete_configurations')], + ['id' => 'docker_cleanup', 'label' => __('resource.docker_cleanup')], + // ['id' => 'delete_associated_backups_locally', 'label' => 'All backups associated with this Ressource will be permanently deleted from local storage.'], + // ['id' => 'delete_associated_backups_s3', 'label' => 'All backups associated with this Ressource will be permanently deleted from the selected S3 Storage.'], + // ['id' => 'delete_associated_backups_sftp', 'label' => 'All backups associated with this Ressource will be permanently deleted from the selected SFTP Storage.'] + ], + ]); + } } diff --git a/app/Livewire/Project/Shared/Destination.php b/app/Livewire/Project/Shared/Destination.php index a2c018beb..7fb5c45db 100644 --- a/app/Livewire/Project/Shared/Destination.php +++ b/app/Livewire/Project/Shared/Destination.php @@ -8,6 +8,8 @@ use App\Events\ApplicationStatusChanged; use App\Jobs\ContainerStatusJob; use App\Models\Server; use App\Models\StandaloneDocker; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Hash; use Livewire\Component; use Visus\Cuid2\Cuid2; @@ -115,8 +117,14 @@ class Destination extends Component ApplicationStatusChanged::dispatch(data_get($this->resource, 'environment.project.team.id')); } - public function removeServer(int $network_id, int $server_id) + public function removeServer(int $network_id, int $server_id, $password) { + if (! Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + + return; + } + if ($this->resource->destination->server->id == $server_id && $this->resource->destination->id == $network_id) { $this->dispatch('error', 'You cannot remove this destination server.', 'You are trying to remove the main server.'); diff --git a/app/Livewire/Project/Shared/GetLogs.php b/app/Livewire/Project/Shared/GetLogs.php index deccc875c..0e140b8c1 100644 --- a/app/Livewire/Project/Shared/GetLogs.php +++ b/app/Livewire/Project/Shared/GetLogs.php @@ -2,6 +2,7 @@ namespace App\Livewire\Project\Shared; +use App\Helpers\SshMultiplexingHelper; use App\Models\Application; use App\Models\Server; use App\Models\Service; @@ -108,14 +109,14 @@ class GetLogs extends Component $command = parseCommandsByLineForSudo(collect($command), $this->server); $command = $command[0]; } - $sshCommand = generateSshCommand($this->server, $command); + $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); } else { $command = "docker logs -n {$this->numberOfLines} -t {$this->container}"; if ($this->server->isNonRoot()) { $command = parseCommandsByLineForSudo(collect($command), $this->server); $command = $command[0]; } - $sshCommand = generateSshCommand($this->server, $command); + $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); } } else { if ($this->server->isSwarm()) { @@ -124,14 +125,14 @@ class GetLogs extends Component $command = parseCommandsByLineForSudo(collect($command), $this->server); $command = $command[0]; } - $sshCommand = generateSshCommand($this->server, $command); + $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); } else { $command = "docker logs -n {$this->numberOfLines} {$this->container}"; if ($this->server->isNonRoot()) { $command = parseCommandsByLineForSudo(collect($command), $this->server); $command = $command[0]; } - $sshCommand = generateSshCommand($this->server, $command); + $sshCommand = SshMultiplexingHelper::generateSshCommand($this->server, $command); } } if ($refresh) { diff --git a/app/Livewire/Project/Shared/ScheduledTask/Executions.php b/app/Livewire/Project/Shared/ScheduledTask/Executions.php index 5bd6b4b9b..017cc9fd7 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Executions.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Executions.php @@ -7,7 +7,9 @@ use Livewire\Component; class Executions extends Component { public $executions = []; + public $selectedKey; + public $task; public function getListeners() @@ -29,7 +31,7 @@ class Executions extends Component public function server() { - if (!$this->task) { + if (! $this->task) { return null; } @@ -42,16 +44,18 @@ class Executions extends Component return $this->task->service->destination->server; } } + return null; } public function getServerTimezone() { $server = $this->server(); - if (!$server) { + if (! $server) { return 'UTC'; } $serverTimezone = $server->settings->server_timezone; + return $serverTimezone; } @@ -64,6 +68,7 @@ class Executions extends Component } catch (\Exception $e) { $dateObj->setTimezone(new \DateTimeZone('UTC')); } + return $dateObj->format('Y-m-d H:i:s T'); } } diff --git a/app/Livewire/Project/Shared/ScheduledTask/Show.php b/app/Livewire/Project/Shared/ScheduledTask/Show.php index 8be4ff643..37f50dd32 100644 --- a/app/Livewire/Project/Shared/ScheduledTask/Show.php +++ b/app/Livewire/Project/Shared/ScheduledTask/Show.php @@ -20,6 +20,8 @@ class Show extends Component public string $type; + public string $scheduledTaskName; + protected $rules = [ 'task.enabled' => 'required|boolean', 'task.name' => 'required|string', @@ -49,6 +51,7 @@ class Show extends Component $this->modalId = new Cuid2; $this->task = ModelsScheduledTask::where('uuid', request()->route('task_uuid'))->first(); + $this->scheduledTaskName = $this->task->name; } public function instantSave() @@ -75,9 +78,9 @@ class Show extends Component $this->task->delete(); if ($this->type == 'application') { - return redirect()->route('project.application.configuration', $this->parameters); + return redirect()->route('project.application.configuration', $this->parameters, $this->scheduledTaskName); } else { - return redirect()->route('project.service.configuration', $this->parameters); + return redirect()->route('project.service.configuration', $this->parameters, $this->scheduledTaskName); } } catch (\Exception $e) { return handleError($e); diff --git a/app/Livewire/Project/Shared/Storages/Show.php b/app/Livewire/Project/Shared/Storages/Show.php index 08f51ce08..e4b5c9b89 100644 --- a/app/Livewire/Project/Shared/Storages/Show.php +++ b/app/Livewire/Project/Shared/Storages/Show.php @@ -3,6 +3,8 @@ namespace App\Livewire\Project\Shared\Storages; use App\Models\LocalPersistentVolume; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Hash; use Livewire\Component; class Show extends Component @@ -36,8 +38,14 @@ class Show extends Component $this->dispatch('success', 'Storage updated successfully'); } - public function delete() + public function delete($password) { + if (! Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + + return; + } + $this->storage->delete(); $this->dispatch('refreshStorages'); } diff --git a/app/Livewire/Project/Shared/Terminal.php b/app/Livewire/Project/Shared/Terminal.php index 802e65a30..27be46227 100644 --- a/app/Livewire/Project/Shared/Terminal.php +++ b/app/Livewire/Project/Shared/Terminal.php @@ -2,12 +2,27 @@ namespace App\Livewire\Project\Shared; +use App\Helpers\SshMultiplexingHelper; use App\Models\Server; use Livewire\Attributes\On; use Livewire\Component; class Terminal extends Component { + public function getListeners() + { + $teamId = auth()->user()->currentTeam()->id; + + return [ + "echo-private:team.{$teamId},ApplicationStatusChanged" => 'closeTerminal', + ]; + } + + public function closeTerminal() + { + $this->dispatch('reloadWindow'); + } + #[On('send-terminal-command')] public function sendTerminalCommand($isContainer, $identifier, $serverUuid) { @@ -19,9 +34,9 @@ class Terminal extends Component if ($status !== 'running') { return; } - $command = generateSshCommand($server, "docker exec -it {$identifier} sh -c 'if [ -f ~/.profile ]; then . ~/.profile; fi; if [ -n \"\$SHELL\" ]; then exec \$SHELL; else sh; fi'"); + $command = SshMultiplexingHelper::generateSshCommand($server, "docker exec -it {$identifier} sh -c 'if [ -f ~/.profile ]; then . ~/.profile; fi; if [ -n \"\$SHELL\" ]; then exec \$SHELL; else sh; fi'"); } else { - $command = generateSshCommand($server, "sh -c 'if [ -f ~/.profile ]; then . ~/.profile; fi; if [ -n \"\$SHELL\" ]; then exec \$SHELL; else sh; fi'"); + $command = SshMultiplexingHelper::generateSshCommand($server, "sh -c 'if [ -f ~/.profile ]; then . ~/.profile; fi; if [ -n \"\$SHELL\" ]; then exec \$SHELL; else sh; fi'"); } // ssh command is sent back to frontend then to websocket diff --git a/app/Livewire/Security/PrivateKey/Create.php b/app/Livewire/Security/PrivateKey/Create.php index 32a67bbea..319cec192 100644 --- a/app/Livewire/Security/PrivateKey/Create.php +++ b/app/Livewire/Security/PrivateKey/Create.php @@ -3,17 +3,13 @@ namespace App\Livewire\Security\PrivateKey; use App\Models\PrivateKey; -use DanHarrin\LivewireRateLimiting\WithRateLimiting; use Livewire\Component; -use phpseclib3\Crypt\PublicKeyLoader; class Create extends Component { - use WithRateLimiting; + public string $name = ''; - public string $name; - - public string $value; + public string $value = ''; public ?string $from = null; @@ -26,72 +22,69 @@ class Create extends Component 'value' => 'required|string', ]; - protected $validationAttributes = [ - 'name' => 'name', - 'value' => 'private Key', - ]; - public function generateNewRSAKey() { - try { - $this->rateLimit(10); - $this->name = generate_random_name(); - $this->description = 'Created by Coolify'; - ['private' => $this->value, 'public' => $this->publicKey] = generateSSHKey(); - } catch (\Throwable $e) { - return handleError($e, $this); - } + $this->generateNewKey('rsa'); } public function generateNewEDKey() { - try { - $this->rateLimit(10); - $this->name = generate_random_name(); - $this->description = 'Created by Coolify'; - ['private' => $this->value, 'public' => $this->publicKey] = generateSSHKey('ed25519'); - } catch (\Throwable $e) { - return handleError($e, $this); - } + $this->generateNewKey('ed25519'); } - public function updated($updateProperty) + private function generateNewKey($type) { - if ($updateProperty === 'value') { - try { - $this->publicKey = PublicKeyLoader::load($this->$updateProperty)->getPublicKey()->toString('OpenSSH', ['comment' => '']); - } catch (\Throwable $e) { - if ($this->$updateProperty === '') { - $this->publicKey = ''; - } else { - $this->publicKey = 'Invalid private key'; - } - } + $keyData = PrivateKey::generateNewKeyPair($type); + $this->setKeyData($keyData); + } + + public function updated($property) + { + if ($property === 'value') { + $this->validatePrivateKey(); } - $this->validateOnly($updateProperty); } public function createPrivateKey() { $this->validate(); + try { - $this->value = trim($this->value); - if (! str_ends_with($this->value, "\n")) { - $this->value .= "\n"; - } - $private_key = PrivateKey::create([ + $privateKey = PrivateKey::createAndStore([ 'name' => $this->name, 'description' => $this->description, - 'private_key' => $this->value, + 'private_key' => trim($this->value)."\n", 'team_id' => currentTeam()->id, ]); - if ($this->from === 'server') { - return redirect()->route('dashboard'); - } - return redirect()->route('security.private-key.show', ['private_key_uuid' => $private_key->uuid]); + return $this->redirectAfterCreation($privateKey); } catch (\Throwable $e) { return handleError($e, $this); } } + + private function setKeyData(array $keyData) + { + $this->name = $keyData['name']; + $this->description = $keyData['description']; + $this->value = $keyData['private_key']; + $this->publicKey = $keyData['public_key']; + } + + private function validatePrivateKey() + { + $validationResult = PrivateKey::validateAndExtractPublicKey($this->value); + $this->publicKey = $validationResult['publicKey']; + + if (! $validationResult['isValid']) { + $this->addError('value', 'Invalid private key'); + } + } + + private function redirectAfterCreation(PrivateKey $privateKey) + { + return $this->from === 'server' + ? redirect()->route('dashboard') + : redirect()->route('security.private-key.show', ['private_key_uuid' => $privateKey->uuid]); + } } diff --git a/app/Livewire/Security/PrivateKey/Index.php b/app/Livewire/Security/PrivateKey/Index.php new file mode 100644 index 000000000..76441a67e --- /dev/null +++ b/app/Livewire/Security/PrivateKey/Index.php @@ -0,0 +1,24 @@ +get(); + + return view('livewire.security.private-key.index', [ + 'privateKeys' => $privateKeys, + ])->layout('components.layout'); + } + + public function cleanupUnusedKeys() + { + PrivateKey::cleanupUnusedKeys(); + $this->dispatch('success', 'Unused keys have been cleaned up.'); + } +} diff --git a/app/Livewire/Security/PrivateKey/Show.php b/app/Livewire/Security/PrivateKey/Show.php index d86bd5d1e..249c84f14 100644 --- a/app/Livewire/Security/PrivateKey/Show.php +++ b/app/Livewire/Security/PrivateKey/Show.php @@ -29,25 +29,27 @@ class Show extends Component try { $this->private_key = PrivateKey::ownedByCurrentTeam(['name', 'description', 'private_key', 'is_git_related'])->whereUuid(request()->private_key_uuid)->firstOrFail(); } catch (\Throwable $e) { - return handleError($e, $this); + abort(404); } } public function loadPublicKey() { - $this->public_key = $this->private_key->publicKey(); + $this->public_key = $this->private_key->getPublicKey(); + if ($this->public_key === 'Error loading private key') { + $this->dispatch('error', 'Failed to load public key. The private key may be invalid.'); + } } public function delete() { try { - if ($this->private_key->isEmpty()) { - $this->private_key->delete(); - currentTeam()->privateKeys = PrivateKey::where('team_id', currentTeam()->id)->get(); + $this->private_key->safeDelete(); + currentTeam()->privateKeys = PrivateKey::where('team_id', currentTeam()->id)->get(); - return redirect()->route('security.private-key.index'); - } - $this->dispatch('error', 'This private key is in use and cannot be deleted. Please delete all servers, applications, and GitHub/GitLab apps that use this private key before deleting it.'); + return redirect()->route('security.private-key.index'); + } catch (\Exception $e) { + $this->dispatch('error', $e->getMessage()); } catch (\Throwable $e) { return handleError($e, $this); } @@ -56,8 +58,9 @@ class Show extends Component public function changePrivateKey() { try { - $this->private_key->private_key = formatPrivateKey($this->private_key->private_key); - $this->private_key->save(); + $this->private_key->updatePrivateKey([ + 'private_key' => formatPrivateKey($this->private_key->private_key), + ]); refresh_server_connection($this->private_key); $this->dispatch('success', 'Private key updated.'); } catch (\Throwable $e) { diff --git a/app/Livewire/Server/ConfigureCloudflareTunnels.php b/app/Livewire/Server/ConfigureCloudflareTunnels.php index f7306a5b5..a69a5e15d 100644 --- a/app/Livewire/Server/ConfigureCloudflareTunnels.php +++ b/app/Livewire/Server/ConfigureCloudflareTunnels.php @@ -31,13 +31,12 @@ class ConfigureCloudflareTunnels extends Component { try { $server = Server::ownedByCurrentTeam()->where('id', $this->server_id)->firstOrFail(); - ConfigureCloudflared::run($server, $this->cloudflare_token); + ConfigureCloudflared::dispatch($server, $this->cloudflare_token); $server->settings->is_cloudflare_tunnel = true; $server->ip = $this->ssh_domain; $server->save(); $server->settings->save(); - $this->dispatch('success', 'Cloudflare Tunnels configured successfully.'); - $this->dispatch('refreshServerShow'); + $this->dispatch('warning', 'Cloudflare Tunnels configuration started.'); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Livewire/Server/Delete.php b/app/Livewire/Server/Delete.php index 3beec0c91..ed2345b2a 100644 --- a/app/Livewire/Server/Delete.php +++ b/app/Livewire/Server/Delete.php @@ -3,6 +3,8 @@ namespace App\Livewire\Server; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Hash; use Livewire\Component; class Delete extends Component @@ -11,8 +13,13 @@ class Delete extends Component public $server; - public function delete() + public function delete($password) { + if (! Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + + return; + } try { $this->authorize('delete', $this->server); if ($this->server->hasDefinedResources()) { diff --git a/app/Livewire/Server/Form.php b/app/Livewire/Server/Form.php index a5786038c..38a5725b0 100644 --- a/app/Livewire/Server/Form.php +++ b/app/Livewire/Server/Form.php @@ -28,11 +28,16 @@ class Form extends Component public $delete_unused_volumes = false; public $delete_unused_networks = false; - protected $listeners = [ - 'serverInstalled', - 'refreshServerShow' => 'serverInstalled', - 'revalidate' => '$refresh', - ]; + public function getListeners() + { + $teamId = auth()->user()->currentTeam()->id; + + return [ + "echo-private:team.{$teamId},CloudflareTunnelConfigured" => 'cloudflareTunnelConfigured', + 'refreshServerShow' => 'serverInstalled', + 'revalidate' => '$refresh', + ]; + } protected $rules = [ 'server.name' => 'required', @@ -106,6 +111,12 @@ class Form extends Component } } + public function cloudflareTunnelConfigured() + { + $this->serverInstalled(); + $this->dispatch('success', 'Cloudflare Tunnels configured successfully.'); + } + public function serverInstalled() { $this->server->refresh(); @@ -260,4 +271,11 @@ class Form extends Component return handleError($e, $this); } } + public function manualCloudflareConfig() + { + $this->server->settings->is_cloudflare_tunnel = true; + $this->server->settings->save(); + $this->server->refresh(); + $this->dispatch('success', 'Cloudflare Tunnels enabled.'); + } } diff --git a/app/Livewire/Server/Proxy.php b/app/Livewire/Server/Proxy.php index 123b29d70..55d0c4966 100644 --- a/app/Livewire/Server/Proxy.php +++ b/app/Livewire/Server/Proxy.php @@ -39,6 +39,7 @@ class Proxy extends Component { $this->server->proxy = null; $this->server->save(); + $this->dispatch('proxyChanged'); } public function selectProxy($proxy_type) @@ -47,7 +48,7 @@ class Proxy extends Component $this->server->proxy->set('type', $proxy_type); $this->server->save(); $this->selectedProxy = $this->server->proxy->type; - if ($this->selectedProxy !== 'NONE') { + if ($this->server->proxySet()) { StartProxy::run($this->server, false); } $this->dispatch('proxyStatusUpdated'); diff --git a/app/Livewire/Server/Proxy/Deploy.php b/app/Livewire/Server/Proxy/Deploy.php index 2279951ee..eaa312663 100644 --- a/app/Livewire/Server/Proxy/Deploy.php +++ b/app/Livewire/Server/Proxy/Deploy.php @@ -6,6 +6,8 @@ use App\Actions\Proxy\CheckProxy; use App\Actions\Proxy\StartProxy; use App\Events\ProxyStatusChanged; use App\Models\Server; +use Illuminate\Process\InvokedProcess; +use Illuminate\Support\Facades\Process; use Livewire\Component; class Deploy extends Component @@ -29,6 +31,7 @@ class Deploy extends Component 'serverRefresh' => 'proxyStatusUpdated', 'checkProxy', 'startProxy', + 'proxyChanged' => 'proxyStatusUpdated', ]; } @@ -94,21 +97,43 @@ class Deploy extends Component public function stop(bool $forceStop = true) { try { - if ($this->server->isSwarm()) { - instant_remote_process([ - 'docker service rm coolify-proxy_traefik', - ], $this->server); - } else { - instant_remote_process([ - 'docker rm -f coolify-proxy', - ], $this->server); + $containerName = $this->server->isSwarm() ? 'coolify-proxy_traefik' : 'coolify-proxy'; + $timeout = 30; + + $process = $this->stopContainer($containerName, $timeout); + + $startTime = time(); + while ($process->running()) { + if (time() - $startTime >= $timeout) { + $this->forceStopContainer($containerName); + break; + } + usleep(100000); } - $this->server->proxy->status = 'exited'; - $this->server->proxy->force_stop = $forceStop; - $this->server->save(); - $this->dispatch('proxyStatusUpdated'); + + $this->removeContainer($containerName); } catch (\Throwable $e) { return handleError($e, $this); + } finally { + $this->server->proxy->force_stop = $forceStop; + $this->server->proxy->status = 'exited'; + $this->server->save(); + $this->dispatch('proxyStatusUpdated'); } } + + private function stopContainer(string $containerName, int $timeout): InvokedProcess + { + return Process::timeout($timeout)->start("docker stop --time=$timeout $containerName"); + } + + private function forceStopContainer(string $containerName) + { + instant_remote_process(["docker kill $containerName"], $this->server, throwError: false); + } + + private function removeContainer(string $containerName) + { + instant_remote_process(["docker rm -f $containerName"], $this->server, throwError: false); + } } diff --git a/app/Livewire/Server/Proxy/Show.php b/app/Livewire/Server/Proxy/Show.php index cef909a45..d70e44e55 100644 --- a/app/Livewire/Server/Proxy/Show.php +++ b/app/Livewire/Server/Proxy/Show.php @@ -11,7 +11,7 @@ class Show extends Component public $parameters = []; - protected $listeners = ['proxyStatusUpdated']; + protected $listeners = ['proxyStatusUpdated', 'proxyChanged' => 'proxyStatusUpdated']; public function proxyStatusUpdated() { diff --git a/app/Livewire/Server/Proxy/Status.php b/app/Livewire/Server/Proxy/Status.php index d23d7fc20..20db4dad4 100644 --- a/app/Livewire/Server/Proxy/Status.php +++ b/app/Livewire/Server/Proxy/Status.php @@ -49,6 +49,10 @@ class Status extends Component if ($this->server->proxy->status === 'running') { $this->polling = false; $notification && $this->dispatch('success', 'Proxy is running.'); + } elseif ($this->server->proxy->status === 'exited' and ! $this->server->proxy->force_stop) { + $notification && $this->dispatch('error', 'Proxy has exited.'); + } elseif ($this->server->proxy->force_stop) { + $notification && $this->dispatch('error', 'Proxy is stopped manually.'); } else { $notification && $this->dispatch('error', 'Proxy is not running.'); } diff --git a/app/Livewire/Server/ShowPrivateKey.php b/app/Livewire/Server/ShowPrivateKey.php index 578a08967..92869c44b 100644 --- a/app/Livewire/Server/ShowPrivateKey.php +++ b/app/Livewire/Server/ShowPrivateKey.php @@ -2,6 +2,7 @@ namespace App\Livewire\Server; +use App\Models\PrivateKey; use App\Models\Server; use Livewire\Component; @@ -13,25 +14,15 @@ class ShowPrivateKey extends Component public $parameters; - public function setPrivateKey($newPrivateKeyId) + public function setPrivateKey($privateKeyId) { try { - $oldPrivateKeyId = $this->server->private_key_id; - refresh_server_connection($this->server->privateKey); - $this->server->update([ - 'private_key_id' => $newPrivateKeyId, - ]); + $privateKey = PrivateKey::findOrFail($privateKeyId); + $this->server->update(['private_key_id' => $privateKey->id]); $this->server->refresh(); - refresh_server_connection($this->server->privateKey); - $this->checkConnection(); - } catch (\Throwable $e) { - $this->server->update([ - 'private_key_id' => $oldPrivateKeyId, - ]); - $this->server->refresh(); - refresh_server_connection($this->server->privateKey); - - return handleError($e, $this); + $this->dispatch('success', 'Private key updated successfully.'); + } catch (\Exception $e) { + $this->dispatch('error', 'Failed to update private key: '.$e->getMessage()); } } @@ -43,7 +34,7 @@ class ShowPrivateKey extends Component $this->dispatch('success', 'Server is reachable.'); } else { ray($error); - $this->dispatch('error', 'Server is not reachable.
Please validate your configuration and connection.

Check this documentation for further help.'); + $this->dispatch('error', 'Server is not reachable.

Check this documentation for further help.

Error: '.$error); return; } diff --git a/app/Livewire/Team/AdminView.php b/app/Livewire/Team/AdminView.php index 97d4fcdbf..3026cb297 100644 --- a/app/Livewire/Team/AdminView.php +++ b/app/Livewire/Team/AdminView.php @@ -4,6 +4,8 @@ namespace App\Livewire\Team; use App\Models\Team; use App\Models\User; +use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Hash; use Livewire\Component; class AdminView extends Component @@ -73,8 +75,13 @@ class AdminView extends Component $team->delete(); } - public function delete($id) + public function delete($id, $password) { + if (! Hash::check($password, Auth::user()->password)) { + $this->addError('password', 'The provided password is incorrect.'); + + return; + } if (! auth()->user()->isInstanceAdmin()) { return $this->dispatch('error', 'You are not authorized to delete users'); } diff --git a/app/Models/Application.php b/app/Models/Application.php index d0cc34a06..55006745a 100644 --- a/app/Models/Application.php +++ b/app/Models/Application.php @@ -6,7 +6,9 @@ use App\Enums\ApplicationDeploymentStatus; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Process\InvokedProcess; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Process; use Illuminate\Support\Str; use OpenApi\Attributes as OA; use RuntimeException; @@ -149,12 +151,64 @@ class Application extends BaseModel return Application::whereRelation('environment.project.team', 'id', $teamId)->orderBy('name'); } + public function getContainersToStop(bool $previewDeployments = false): array + { + $containers = $previewDeployments + ? getCurrentApplicationContainerStatus($this->destination->server, $this->id, includePullrequests: true) + : getCurrentApplicationContainerStatus($this->destination->server, $this->id, 0); + + return $containers->pluck('Names')->toArray(); + } + + public function stopContainers(array $containerNames, $server, int $timeout = 600) + { + $processes = []; + foreach ($containerNames as $containerName) { + $processes[$containerName] = $this->stopContainer($containerName, $server, $timeout); + } + + $startTime = time(); + while (count($processes) > 0) { + $finishedProcesses = array_filter($processes, function ($process) { + return ! $process->running(); + }); + foreach ($finishedProcesses as $containerName => $process) { + unset($processes[$containerName]); + $this->removeContainer($containerName, $server); + } + + if (time() - $startTime >= $timeout) { + $this->forceStopRemainingContainers(array_keys($processes), $server); + break; + } + + usleep(100000); + } + } + + public function stopContainer(string $containerName, $server, int $timeout): InvokedProcess + { + return Process::timeout($timeout)->start("docker stop --time=$timeout $containerName"); + } + + public function removeContainer(string $containerName, $server) + { + instant_remote_process(command: ["docker rm -f $containerName"], server: $server, throwError: false); + } + + public function forceStopRemainingContainers(array $containerNames, $server) + { + foreach ($containerNames as $containerName) { + instant_remote_process(command: ["docker kill $containerName"], server: $server, throwError: false); + $this->removeContainer($containerName, $server); + } + } + public function delete_configurations() { $server = data_get($this, 'destination.server'); $workdir = $this->workdir(); if (str($workdir)->endsWith($this->uuid)) { - ray('Deleting workdir'); instant_remote_process(['rm -rf '.$this->workdir()], $server, false); } } @@ -176,6 +230,13 @@ class Application extends BaseModel } } + public function delete_connected_networks($uuid) + { + $server = data_get($this, 'destination.server'); + instant_remote_process(["docker network disconnect {$uuid} coolify-proxy"], $server, false); + instant_remote_process(["docker network rm {$uuid}"], $server, false); + } + public function additional_servers() { return $this->belongsToMany(Server::class, 'additional_destinations') @@ -1034,6 +1095,7 @@ class Application extends BaseModel throw new \Exception($e->getMessage()); } $services = data_get($yaml, 'services'); + $commands = collect([]); $services = collect($services)->map(function ($service) use ($commands) { $serviceVolumes = collect(data_get($service, 'volumes', [])); @@ -1166,7 +1228,6 @@ class Application extends BaseModel } else { throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile

Check if you used the right extension (.yaml or .yml) in the compose file name."); } - } public function parseContainerLabels(?ApplicationPreview $preview = null) diff --git a/app/Models/PrivateKey.php b/app/Models/PrivateKey.php index 45bc6bc84..065746ede 100644 --- a/app/Models/PrivateKey.php +++ b/app/Models/PrivateKey.php @@ -2,6 +2,9 @@ namespace App\Models; +use DanHarrin\LivewireRateLimiting\WithRateLimiting; +use Illuminate\Support\Facades\Storage; +use Illuminate\Validation\ValidationException; use OpenApi\Attributes as OA; use phpseclib3\Crypt\PublicKeyLoader; @@ -22,48 +25,144 @@ use phpseclib3\Crypt\PublicKeyLoader; )] class PrivateKey extends BaseModel { + use WithRateLimiting; + protected $fillable = [ 'name', 'description', 'private_key', 'is_git_related', 'team_id', + 'fingerprint', + ]; + + protected $casts = [ + 'private_key' => 'encrypted', ]; protected static function booted() { static::saving(function ($key) { - $privateKey = data_get($key, 'private_key'); - if (substr($privateKey, -1) !== "\n") { - $key->private_key = $privateKey."\n"; + $key->private_key = formatPrivateKey($key->private_key); + + if (! self::validatePrivateKey($key->private_key)) { + throw ValidationException::withMessages([ + 'private_key' => ['The private key is invalid.'], + ]); + } + + $key->fingerprint = self::generateFingerprint($key->private_key); + if (self::fingerprintExists($key->fingerprint, $key->id)) { + throw ValidationException::withMessages([ + 'private_key' => ['This private key already exists.'], + ]); } }); + static::deleted(function ($key) { + self::deleteFromStorage($key); + }); + } + + public function getPublicKey() + { + return self::extractPublicKeyFromPrivate($this->private_key) ?? 'Error loading private key'; } public static function ownedByCurrentTeam(array $select = ['*']) { $selectArray = collect($select)->concat(['id']); - return PrivateKey::whereTeamId(currentTeam()->id)->select($selectArray->all()); + return self::whereTeamId(currentTeam()->id)->select($selectArray->all()); } - public function publicKey() + public static function validatePrivateKey($privateKey) { try { - return PublicKeyLoader::load($this->private_key)->getPublicKey()->toString('OpenSSH', ['comment' => '']); + PublicKeyLoader::load($privateKey); + + return true; } catch (\Throwable $e) { - return 'Error loading private key'; + return false; } } - public function isEmpty() + public static function createAndStore(array $data) { - if ($this->servers()->count() === 0 && $this->applications()->count() === 0 && $this->githubApps()->count() === 0 && $this->gitlabApps()->count() === 0) { - return true; - } + $privateKey = new self($data); + $privateKey->save(); + $privateKey->storeInFileSystem(); - return false; + return $privateKey; + } + + public static function generateNewKeyPair($type = 'rsa') + { + try { + $instance = new self; + $instance->rateLimit(10); + $name = generate_random_name(); + $description = 'Created by Coolify'; + $keyPair = generateSSHKey($type === 'ed25519' ? 'ed25519' : 'rsa'); + + return [ + 'name' => $name, + 'description' => $description, + 'private_key' => $keyPair['private'], + 'public_key' => $keyPair['public'], + ]; + } catch (\Throwable $e) { + throw new \Exception("Failed to generate new {$type} key: ".$e->getMessage()); + } + } + + public static function extractPublicKeyFromPrivate($privateKey) + { + try { + $key = PublicKeyLoader::load($privateKey); + + return $key->getPublicKey()->toString('OpenSSH', ['comment' => '']); + } catch (\Throwable $e) { + return null; + } + } + + public static function validateAndExtractPublicKey($privateKey) + { + $isValid = self::validatePrivateKey($privateKey); + $publicKey = $isValid ? self::extractPublicKeyFromPrivate($privateKey) : ''; + + return [ + 'isValid' => $isValid, + 'publicKey' => $publicKey, + ]; + } + + public function storeInFileSystem() + { + $filename = "ssh_key@{$this->uuid}"; + Storage::disk('ssh-keys')->put($filename, $this->private_key); + + return "/var/www/html/storage/app/ssh/keys/{$filename}"; + } + + public static function deleteFromStorage(self $privateKey) + { + $filename = "ssh_key@{$privateKey->uuid}"; + Storage::disk('ssh-keys')->delete($filename); + } + + public function getKeyLocation() + { + return "/var/www/html/storage/app/ssh/keys/ssh_key@{$this->uuid}"; + } + + public function updatePrivateKey(array $data) + { + $this->update($data); + $this->storeInFileSystem(); + + return $this; } public function servers() @@ -85,4 +184,53 @@ class PrivateKey extends BaseModel { return $this->hasMany(GitlabApp::class); } + + public function isInUse() + { + return $this->servers()->exists() + || $this->applications()->exists() + || $this->githubApps()->exists() + || $this->gitlabApps()->exists(); + } + + public function safeDelete() + { + if (! $this->isInUse()) { + $this->delete(); + + return true; + } + + return false; + } + + public static function generateFingerprint($privateKey) + { + try { + $key = PublicKeyLoader::load($privateKey); + $publicKey = $key->getPublicKey(); + + return $publicKey->getFingerprint('sha256'); + } catch (\Throwable $e) { + return null; + } + } + + private static function fingerprintExists($fingerprint, $excludeId = null) + { + $query = self::where('fingerprint', $fingerprint); + + if (! is_null($excludeId)) { + $query->where('id', '!=', $excludeId); + } + + return $query->exists(); + } + + public static function cleanupUnusedKeys() + { + self::ownedByCurrentTeam()->each(function ($privateKey) { + $privateKey->safeDelete(); + }); + } } diff --git a/app/Models/ScheduledDatabaseBackup.php b/app/Models/ScheduledDatabaseBackup.php index 50a0c8173..ce5d3a87f 100644 --- a/app/Models/ScheduledDatabaseBackup.php +++ b/app/Models/ScheduledDatabaseBackup.php @@ -35,14 +35,17 @@ class ScheduledDatabaseBackup extends BaseModel { return $this->hasMany(ScheduledDatabaseBackupExecution::class)->where('created_at', '>=', now()->subDays($days))->get(); } + public function server() { if ($this->database) { if ($this->database->destination && $this->database->destination->server) { $server = $this->database->destination->server; + return $server; } } + return null; } } diff --git a/app/Models/ScheduledTask.php b/app/Models/ScheduledTask.php index 82f0036a5..3cee5a875 100644 --- a/app/Models/ScheduledTask.php +++ b/app/Models/ScheduledTask.php @@ -4,8 +4,6 @@ namespace App\Models; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; -use App\Models\Service; -use App\Models\Application; class ScheduledTask extends BaseModel { @@ -37,19 +35,23 @@ class ScheduledTask extends BaseModel if ($this->application) { if ($this->application->destination && $this->application->destination->server) { $server = $this->application->destination->server; + return $server; } } elseif ($this->service) { if ($this->service->destination && $this->service->destination->server) { $server = $this->service->destination->server; + return $server; } } elseif ($this->database) { if ($this->database->destination && $this->database->destination->server) { $server = $this->database->destination->server; + return $server; } } + return null; } } diff --git a/app/Models/Server.php b/app/Models/Server.php index 8a30b1a4d..ebed91123 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -5,7 +5,6 @@ namespace App\Models; use App\Actions\Server\InstallDocker; use App\Enums\ProxyTypes; use App\Jobs\PullSentinelImageJob; -use App\Notifications\Server\Revived; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Support\Collection; @@ -160,6 +159,11 @@ class Server extends BaseModel return $this->hasOne(ServerSetting::class); } + public function proxySet() + { + return $this->proxyType() && $this->proxyType() !== 'NONE' && $this->isFunctional() && ! $this->isSwarmWorker() && ! $this->settings->is_build_server; + } + public function setupDefault404Redirect() { $dynamic_conf_path = $this->proxyPath().'/dynamic'; @@ -167,11 +171,11 @@ class Server extends BaseModel $redirect_url = $this->proxy->redirect_url; if ($proxy_type === ProxyTypes::TRAEFIK->value) { $default_redirect_file = "$dynamic_conf_path/default_redirect_404.yaml"; - } elseif ($proxy_type === 'CADDY') { + } elseif ($proxy_type === ProxyTypes::CADDY->value) { $default_redirect_file = "$dynamic_conf_path/default_redirect_404.caddy"; } if (empty($redirect_url)) { - if ($proxy_type === 'CADDY') { + if ($proxy_type === ProxyTypes::CADDY->value) { $conf = ':80, :443 { respond 404 }'; @@ -241,7 +245,7 @@ respond 404 $conf; $base64 = base64_encode($conf); - } elseif ($proxy_type === 'CADDY') { + } elseif ($proxy_type === ProxyTypes::CADDY->value) { $conf = ":80, :443 { redir $redirect_url }"; @@ -257,9 +261,6 @@ respond 404 "echo '$base64' | base64 -d | tee $default_redirect_file > /dev/null", ], $this); - if (config('app.env') == 'local') { - ray($conf); - } if ($proxy_type === 'CADDY') { $this->reloadCaddy(); } @@ -837,9 +838,9 @@ $schema://$host { $clickhouses = data_get($standaloneDocker, 'clickhouses', collect([])); return $postgresqls->concat($redis)->concat($mongodbs)->concat($mysqls)->concat($mariadbs)->concat($keydbs)->concat($dragonflies)->concat($clickhouses); - })->filter(function ($item) { + })->flatten()->filter(function ($item) { return data_get($item, 'name') !== 'coolify-db'; - })->flatten(); + }); } public function applications() @@ -883,6 +884,35 @@ $schema://$host { return $this->hasMany(Service::class); } + public function port(): Attribute + { + return Attribute::make( + get: function ($value) { + return preg_replace('/[^0-9]/', '', $value); + } + ); + } + + public function user(): Attribute + { + return Attribute::make( + get: function ($value) { + $sanitizedValue = preg_replace('/[^A-Za-z0-9\-_]/', '', $value); + + return $sanitizedValue; + } + ); + } + + public function ip(): Attribute + { + return Attribute::make( + get: function ($value) { + return preg_replace('/[^0-9a-zA-Z.:%-]/', '', $value); + } + ); + } + public function getIp(): Attribute { return Attribute::make( @@ -955,10 +985,9 @@ $schema://$host { public function isFunctional() { $isFunctional = $this->settings->is_reachable && $this->settings->is_usable && ! $this->settings->force_disabled; - ['private_key_filename' => $private_key_filename, 'mux_filename' => $mux_filename] = server_ssh_configuration($this); + if (! $isFunctional) { - Storage::disk('ssh-keys')->delete($private_key_filename); - Storage::disk('ssh-mux')->delete($mux_filename); + Storage::disk('ssh-mux')->delete($this->muxFilename()); } return $isFunctional; @@ -1010,9 +1039,10 @@ $schema://$host { return data_get($this, 'settings.is_swarm_worker'); } - public function validateConnection() + public function validateConnection($isManualCheck = true) { - config()->set('constants.ssh.mux_enabled', false); + config()->set('constants.ssh.mux_enabled', ! $isManualCheck); + // ray('Manual Check: ' . ($isManualCheck ? 'true' : 'false')); $server = Server::find($this->id); if (! $server) { @@ -1022,7 +1052,6 @@ $schema://$host { return ['uptime' => false, 'error' => 'Server skipped.']; } try { - // EC2 does not have `uptime` command, lol instant_remote_process(['ls /'], $server); $server->settings()->update([ 'is_reachable' => true, @@ -1031,7 +1060,6 @@ $schema://$host { 'unreachable_count' => 0, ]); if (data_get($server, 'unreachable_notification_sent') === true) { - // $server->team?->notify(new Revived($server)); $server->update(['unreachable_notification_sent' => false]); } @@ -1160,4 +1188,24 @@ $schema://$host { { return $this->settings->is_build_server; } + + public static function createWithPrivateKey(array $data, PrivateKey $privateKey) + { + $server = new self($data); + $server->privateKey()->associate($privateKey); + $server->save(); + + return $server; + } + + public function updateWithPrivateKey(array $data, ?PrivateKey $privateKey = null) + { + $this->update($data); + if ($privateKey) { + $this->privateKey()->associate($privateKey); + $this->save(); + } + + return $this; + } } diff --git a/app/Models/Service.php b/app/Models/Service.php index d8def6663..d236869ba 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -6,7 +6,9 @@ use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; +use Illuminate\Process\InvokedProcess; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Process; use Illuminate\Support\Facades\Storage; use OpenApi\Attributes as OA; use Spatie\Url\Url; @@ -131,15 +133,81 @@ class Service extends BaseModel return $this->morphToMany(Tag::class, 'taggable'); } + public function getContainersToStop(): array + { + $containersToStop = []; + $applications = $this->applications()->get(); + foreach ($applications as $application) { + $containersToStop[] = "{$application->name}-{$this->uuid}"; + } + $dbs = $this->databases()->get(); + foreach ($dbs as $db) { + $containersToStop[] = "{$db->name}-{$this->uuid}"; + } + + return $containersToStop; + } + + public function stopContainers(array $containerNames, $server, int $timeout = 300) + { + $processes = []; + foreach ($containerNames as $containerName) { + $processes[$containerName] = $this->stopContainer($containerName, $timeout); + } + + $startTime = time(); + while (count($processes) > 0) { + $finishedProcesses = array_filter($processes, function ($process) { + return ! $process->running(); + }); + foreach (array_keys($finishedProcesses) as $containerName) { + unset($processes[$containerName]); + $this->removeContainer($containerName, $server); + } + + if (time() - $startTime >= $timeout) { + $this->forceStopRemainingContainers(array_keys($processes), $server); + break; + } + + usleep(100000); + } + } + + public function stopContainer(string $containerName, int $timeout): InvokedProcess + { + return Process::timeout($timeout)->start("docker stop --time=$timeout $containerName"); + } + + public function removeContainer(string $containerName, $server) + { + instant_remote_process(command: ["docker rm -f $containerName"], server: $server, throwError: false); + } + + public function forceStopRemainingContainers(array $containerNames, $server) + { + foreach ($containerNames as $containerName) { + instant_remote_process(command: ["docker kill $containerName"], server: $server, throwError: false); + $this->removeContainer($containerName, $server); + } + } + public function delete_configurations() { - $server = data_get($this, 'server'); + $server = data_get($this, 'destination.server'); $workdir = $this->workdir(); if (str($workdir)->endsWith($this->uuid)) { instant_remote_process(['rm -rf '.$this->workdir()], $server, false); } } + public function delete_connected_networks($uuid) + { + $server = data_get($this, 'destination.server'); + instant_remote_process(["docker network disconnect {$uuid} coolify-proxy"], $server, false); + instant_remote_process(["docker network rm {$uuid}"], $server, false); + } + public function status() { $applications = $this->applications; diff --git a/app/Notifications/Server/ForceDisabled.php b/app/Notifications/Server/ForceDisabled.php index c0e2a3c31..6377f2f15 100644 --- a/app/Notifications/Server/ForceDisabled.php +++ b/app/Notifications/Server/ForceDisabled.php @@ -52,7 +52,7 @@ class ForceDisabled extends Notification implements ShouldQueue public function toDiscord(): string { - $message = "Coolify: Server ({$this->server->name}) disabled because it is not paid!\n All automations and integrations are stopped.\nPlease update your subscription to enable the server again [here](https://app.coolify.io/subsciprtions)."; + $message = "Coolify: Server ({$this->server->name}) disabled because it is not paid!\n All automations and integrations are stopped.\nPlease update your subscription to enable the server again [here](https://app.coolify.io/subscriptions)."; return $message; } @@ -60,7 +60,7 @@ class ForceDisabled extends Notification implements ShouldQueue public function toTelegram(): array { return [ - 'message' => "Coolify: Server ({$this->server->name}) disabled because it is not paid!\n All automations and integrations are stopped.\nPlease update your subscription to enable the server again [here](https://app.coolify.io/subsciprtions).", + 'message' => "Coolify: Server ({$this->server->name}) disabled because it is not paid!\n All automations and integrations are stopped.\nPlease update your subscription to enable the server again [here](https://app.coolify.io/subscriptions).", ]; } } diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php index 9b58882eb..f8ccee9db 100644 --- a/app/Traits/ExecuteRemoteCommand.php +++ b/app/Traits/ExecuteRemoteCommand.php @@ -3,6 +3,7 @@ namespace App\Traits; use App\Enums\ApplicationDeploymentStatus; +use App\Helpers\SshMultiplexingHelper; use App\Models\Server; use Carbon\Carbon; use Illuminate\Support\Collection; @@ -42,7 +43,7 @@ trait ExecuteRemoteCommand $command = parseLineForSudo($command, $this->server); } } - $remote_command = generateSshCommand($this->server, $command); + $remote_command = SshMultiplexingHelper::generateSshCommand($this->server, $command); $process = Process::timeout(3600)->idleTimeout(3600)->start($remote_command, function (string $type, string $output) use ($command, $hidden, $customType, $append) { $output = str($output)->trim(); if ($output->startsWith('â•”')) { diff --git a/app/View/Components/Forms/Input.php b/app/View/Components/Forms/Input.php index 6c9378cac..fbd7b0b15 100644 --- a/app/View/Components/Forms/Input.php +++ b/app/View/Components/Forms/Input.php @@ -22,6 +22,7 @@ class Input extends Component public bool $allowToPeak = true, public bool $isMultiline = false, public string $defaultClass = 'input', + public string $autocomplete = 'off', ) {} public function render(): View|Closure|string diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 8dce52f15..e252bda10 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -134,6 +134,9 @@ function getContainerStatus(Server $server, string $container_id, bool $all_data return 'exited'; } $container = format_docker_command_output_to_json($container); + if ($container->isEmpty()) { + return 'exited'; + } if ($all_data) { return $container[0]; } diff --git a/bootstrap/helpers/proxy.php b/bootstrap/helpers/proxy.php index c4c15b8fe..e90d54a78 100644 --- a/bootstrap/helpers/proxy.php +++ b/bootstrap/helpers/proxy.php @@ -96,6 +96,8 @@ function connectProxyToNetworks(Server $server) "echo 'Connecting coolify-proxy to $network network...'", "docker network ls --format '{{.Name}}' | grep '^$network$' >/dev/null || docker network create --driver overlay --attachable $network >/dev/null", "docker network connect $network coolify-proxy >/dev/null 2>&1 || true", + "echo 'Successfully connected coolify-proxy to $network network.'", + "echo 'Proxy started and configured successfully!'", ]; }); } else { @@ -104,6 +106,8 @@ function connectProxyToNetworks(Server $server) "echo 'Connecting coolify-proxy to $network network...'", "docker network ls --format '{{.Name}}' | grep '^$network$' >/dev/null || docker network create --attachable $network >/dev/null", "docker network connect $network coolify-proxy >/dev/null 2>&1 || true", + "echo 'Successfully connected coolify-proxy to $network network.'", + "echo 'Proxy started and configured successfully!'", ]; }); } @@ -217,7 +221,6 @@ function generate_default_proxy_configuration(Server $server) } } elseif ($proxy_type === 'CADDY') { $config = [ - 'version' => '3.8', 'networks' => $array_of_networks->toArray(), 'services' => [ 'caddy' => [ @@ -236,12 +239,9 @@ function generate_default_proxy_configuration(Server $server) '80:80', '443:443', ], - // "healthcheck" => [ - // "test" => "wget -qO- http://localhost:80|| exit 1", - // "interval" => "4s", - // "timeout" => "2s", - // "retries" => 5, - // ], + 'labels' => [ + 'coolify.managed=true', + ], 'volumes' => [ '/var/run/docker.sock:/var/run/docker.sock:ro', "{$proxy_path}/dynamic:/dynamic", diff --git a/bootstrap/helpers/remoteProcess.php b/bootstrap/helpers/remoteProcess.php index 4ba378e67..67b60d6b7 100644 --- a/bootstrap/helpers/remoteProcess.php +++ b/bootstrap/helpers/remoteProcess.php @@ -3,6 +3,7 @@ use App\Actions\CoolifyTask\PrepareCoolifyTask; use App\Data\CoolifyTaskArgs; use App\Enums\ActivityTypes; +use App\Helpers\SshMultiplexingHelper; use App\Models\Application; use App\Models\ApplicationDeploymentQueue; use App\Models\PrivateKey; @@ -10,9 +11,8 @@ use App\Models\Server; use Carbon\Carbon; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Process; -use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; use Spatie\Activitylog\Contracts\Activity; @@ -26,29 +26,28 @@ function remote_process( $callEventOnFinish = null, $callEventData = null ): Activity { - if (is_null($type)) { - $type = ActivityTypes::INLINE->value; - } - if ($command instanceof Collection) { - $command = $command->toArray(); - } + $type = $type ?? ActivityTypes::INLINE->value; + $command = $command instanceof Collection ? $command->toArray() : $command; + if ($server->isNonRoot()) { $command = parseCommandsByLineForSudo(collect($command), $server); } + $command_string = implode("\n", $command); - if (auth()->user()) { - $teams = auth()->user()->teams->pluck('id'); + + if (Auth::check()) { + $teams = Auth::user()->teams->pluck('id'); if (! $teams->contains($server->team_id) && ! $teams->contains(0)) { throw new \Exception('User is not part of the team that owns this server'); } } + SshMultiplexingHelper::ensureMultiplexedConnection($server); + return resolve(PrepareCoolifyTask::class, [ 'remoteProcessArgs' => new CoolifyTaskArgs( server_uuid: $server->uuid, - command: <<muxFilename(); - return [ - 'location' => $location, - 'mux_filename' => $mux_filename, - 'private_key_filename' => $private_key_filename, - ]; -} -function savePrivateKeyToFs(Server $server) -{ - if (data_get($server, 'privateKey.private_key') === null) { - throw new \Exception("Server {$server->name} does not have a private key"); - } - ['location' => $location, 'private_key_filename' => $private_key_filename] = server_ssh_configuration($server); - Storage::disk('ssh-keys')->makeDirectory('.'); - Storage::disk('ssh-mux')->makeDirectory('.'); - Storage::disk('ssh-keys')->put($private_key_filename, $server->privateKey->private_key); - - return $location; -} - -function generateScpCommand(Server $server, string $source, string $dest) -{ - $user = $server->user; - $port = $server->port; - $privateKeyLocation = savePrivateKeyToFs($server); - $timeout = config('constants.ssh.command_timeout'); - $connectionTimeout = config('constants.ssh.connection_timeout'); - $serverInterval = config('constants.ssh.server_interval'); - $muxPersistTime = config('constants.ssh.mux_persist_time'); - - $scp_command = "timeout $timeout scp "; - $muxEnabled = config('constants.ssh.mux_enabled', true) && config('coolify.is_windows_docker_desktop') == false; - // ray('SSH Multiplexing Enabled:', $muxEnabled)->blue(); - - if ($muxEnabled) { - $muxSocket = "/var/www/html/storage/app/ssh/mux/{$server->muxFilename()}"; - $scp_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} "; - ensureMultiplexedConnection($server); - // ray('Using SSH Multiplexing')->green(); - } else { - // ray('Not using SSH Multiplexing')->red(); - } - - if (data_get($server, 'settings.is_cloudflare_tunnel')) { - $scp_command .= '-o ProxyCommand="/usr/local/bin/cloudflared access ssh --hostname %h" '; - } - $scp_command .= "-i {$privateKeyLocation} " - .'-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ' - .'-o PasswordAuthentication=no ' - ."-o ConnectTimeout=$connectionTimeout " - ."-o ServerAliveInterval=$serverInterval " - .'-o RequestTTY=no ' - .'-o LogLevel=ERROR ' - ."-P {$port} " - ."{$source} " - ."{$user}@{$server->ip}:{$dest}"; - - return $scp_command; -} function instant_scp(string $source, string $dest, Server $server, $throwError = true) { - $timeout = config('constants.ssh.command_timeout'); - $scp_command = generateScpCommand($server, $source, $dest); - $process = Process::timeout($timeout)->run($scp_command); + $scp_command = SshMultiplexingHelper::generateScpCommand($server, $source, $dest); + $process = Process::timeout(config('constants.ssh.command_timeout'))->run($scp_command); $output = trim($process->output()); $exitCode = $process->exitCode(); if ($exitCode !== 0) { - if (! $throwError) { - return null; - } - - return excludeCertainErrors($process->errorOutput(), $exitCode); - } - if ($output === 'null') { - $output = null; + return $throwError ? excludeCertainErrors($process->errorOutput(), $exitCode) : null; } - return $output; -} -function generateSshCommand(Server $server, string $command) -{ - if ($server->settings->force_disabled) { - throw new \RuntimeException('Server is disabled.'); - } - $user = $server->user; - $port = $server->port; - $privateKeyLocation = savePrivateKeyToFs($server); - $timeout = config('constants.ssh.command_timeout'); - $connectionTimeout = config('constants.ssh.connection_timeout'); - $serverInterval = config('constants.ssh.server_interval'); - $muxPersistTime = config('constants.ssh.mux_persist_time'); - - $ssh_command = "timeout $timeout ssh "; - - $muxEnabled = config('constants.ssh.mux_enabled') && config('coolify.is_windows_docker_desktop') == false; - // ray('SSH Multiplexing Enabled:', $muxEnabled)->blue(); - if ($muxEnabled) { - // Always use multiplexing when enabled - $muxSocket = "/var/www/html/storage/app/ssh/mux/{$server->muxFilename()}"; - $ssh_command .= "-o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} "; - ensureMultiplexedConnection($server); - // ray('Using SSH Multiplexing')->green(); - } else { - // ray('Not using SSH Multiplexing')->red(); - } - - if (data_get($server, 'settings.is_cloudflare_tunnel')) { - $ssh_command .= '-o ProxyCommand="/usr/local/bin/cloudflared access ssh --hostname %h" '; - } - $command = "PATH=\$PATH:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/host/usr/local/sbin:/host/usr/local/bin:/host/usr/sbin:/host/usr/bin:/host/sbin:/host/bin && $command"; - $delimiter = Hash::make($command); - $command = str_replace($delimiter, '', $command); - $ssh_command .= "-i {$privateKeyLocation} " - .'-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ' - .'-o PasswordAuthentication=no ' - ."-o ConnectTimeout=$connectionTimeout " - ."-o ServerAliveInterval=$serverInterval " - .'-o RequestTTY=no ' - .'-o LogLevel=ERROR ' - ."-p {$port} " - ."{$user}@{$server->ip} " - ." 'bash -se' << \\$delimiter".PHP_EOL - .$command.PHP_EOL - .$delimiter; - - return $ssh_command; -} - -function ensureMultiplexedConnection(Server $server) -{ - if (! (config('constants.ssh.mux_enabled') && config('coolify.is_windows_docker_desktop') == false)) { - return; - } - - static $ensuredConnections = []; - - if (isset($ensuredConnections[$server->id])) { - if (! shouldResetMultiplexedConnection($server)) { - // ray('Using Existing Multiplexed Connection')->green(); - - return; - } - } - - $muxSocket = "/var/www/html/storage/app/ssh/mux/{$server->muxFilename()}"; - $checkCommand = "ssh -O check -o ControlPath=$muxSocket "; - if (data_get($server, 'settings.is_cloudflare_tunnel')) { - $checkCommand .= '-o ProxyCommand="/usr/local/bin/cloudflared access ssh --hostname %h" '; - } - $checkCommand .= " {$server->user}@{$server->ip}"; - - $process = Process::run($checkCommand); - - if ($process->exitCode() === 0) { - // ray('Existing Multiplexed Connection is Valid')->green(); - $ensuredConnections[$server->id] = [ - 'timestamp' => now(), - 'muxSocket' => $muxSocket, - ]; - - return; - } - - // ray('Establishing New Multiplexed Connection')->orange(); - - $privateKeyLocation = savePrivateKeyToFs($server); - $connectionTimeout = config('constants.ssh.connection_timeout'); - $serverInterval = config('constants.ssh.server_interval'); - $muxPersistTime = config('constants.ssh.mux_persist_time'); - - $establishCommand = "ssh -fNM -o ControlMaster=auto -o ControlPath=$muxSocket -o ControlPersist={$muxPersistTime} "; - - if (data_get($server, 'settings.is_cloudflare_tunnel')) { - $establishCommand .= '-o ProxyCommand="/usr/local/bin/cloudflared access ssh --hostname %h" '; - } - $establishCommand .= "-i {$privateKeyLocation} " - .'-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ' - .'-o PasswordAuthentication=no ' - ."-o ConnectTimeout=$connectionTimeout " - ."-o ServerAliveInterval=$serverInterval " - .'-o RequestTTY=no ' - .'-o LogLevel=ERROR ' - ."-p {$server->port} " - ."{$server->user}@{$server->ip}"; - - $establishProcess = Process::run($establishCommand); - - if ($establishProcess->exitCode() !== 0) { - throw new \RuntimeException('Failed to establish multiplexed connection: '.$establishProcess->errorOutput()); - } - - $ensuredConnections[$server->id] = [ - 'timestamp' => now(), - 'muxSocket' => $muxSocket, - ]; - - // ray('Established New Multiplexed Connection')->green(); -} - -function shouldResetMultiplexedConnection(Server $server) -{ - if (! (config('constants.ssh.mux_enabled') && config('coolify.is_windows_docker_desktop') == false)) { - return false; - } - - static $ensuredConnections = []; - - if (! isset($ensuredConnections[$server->id])) { - return true; - } - - $lastEnsured = $ensuredConnections[$server->id]['timestamp']; - $muxPersistTime = config('constants.ssh.mux_persist_time'); - $resetInterval = strtotime($muxPersistTime) - time(); - - return $lastEnsured->addSeconds($resetInterval)->isPast(); -} - -function resetMultiplexedConnection(Server $server) -{ - if (! (config('constants.ssh.mux_enabled') && config('coolify.is_windows_docker_desktop') == false)) { - return; - } - - static $ensuredConnections = []; - - if (isset($ensuredConnections[$server->id])) { - $muxSocket = $ensuredConnections[$server->id]['muxSocket']; - $closeCommand = "ssh -O exit -o ControlPath=$muxSocket {$server->user}@{$server->ip}"; - Process::run($closeCommand); - unset($ensuredConnections[$server->id]); - } + return $output === 'null' ? null : $output; } function instant_remote_process(Collection|array $command, Server $server, bool $throwError = true, bool $no_sudo = false): ?string { - static $processCount = 0; - $processCount++; - - $timeout = config('constants.ssh.command_timeout'); - if ($command instanceof Collection) { - $command = $command->toArray(); - } + $command = $command instanceof Collection ? $command->toArray() : $command; if ($server->isNonRoot() && ! $no_sudo) { $command = parseCommandsByLineForSudo(collect($command), $server); } $command_string = implode("\n", $command); - $start_time = microtime(true); - $sshCommand = generateSshCommand($server, $command_string); - $process = Process::timeout($timeout)->run($sshCommand); - $end_time = microtime(true); + // $start_time = microtime(true); + $sshCommand = SshMultiplexingHelper::generateSshCommand($server, $command_string); + $process = Process::timeout(config('constants.ssh.command_timeout'))->run($sshCommand); + // $end_time = microtime(true); - $execution_time = ($end_time - $start_time) * 1000; // Convert to milliseconds + // $execution_time = ($end_time - $start_time) * 1000; // Convert to milliseconds // ray('SSH command execution time:', $execution_time.' ms')->orange(); $output = trim($process->output()); $exitCode = $process->exitCode(); if ($exitCode !== 0) { - if (! $throwError) { - return null; - } - - return excludeCertainErrors($process->errorOutput(), $exitCode); - } - if ($output === 'null') { - $output = null; + return $throwError ? excludeCertainErrors($process->errorOutput(), $exitCode) : null; } - return $output; + return $output === 'null' ? null : $output; } + function excludeCertainErrors(string $errorOutput, ?int $exitCode = null) { $ignoredErrors = collect([ 'Permission denied (publickey', 'Could not resolve hostname', ]); - $ignored = false; - foreach ($ignoredErrors as $ignoredError) { - if (Str::contains($errorOutput, $ignoredError)) { - $ignored = true; - break; - } - } + $ignored = $ignoredErrors->contains(fn ($error) => Str::contains($errorOutput, $error)); if ($ignored) { // TODO: Create new exception and disable in sentry throw new \RuntimeException($errorOutput, $exitCode); } throw new \RuntimeException($errorOutput, $exitCode); } + function decode_remote_command_output(?ApplicationDeploymentQueue $application_deployment_queue = null): Collection { - $application = Application::find(data_get($application_deployment_queue, 'application_id')); - $is_debug_enabled = data_get($application, 'settings.is_debug_enabled'); if (is_null($application_deployment_queue)) { return collect([]); } + $application = Application::find(data_get($application_deployment_queue, 'application_id')); + $is_debug_enabled = data_get($application, 'settings.is_debug_enabled'); try { $decoded = json_decode( data_get($application_deployment_queue, 'logs'), @@ -379,7 +132,8 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d if (! $is_debug_enabled) { $formatted = $formatted->filter(fn ($i) => $i['hidden'] === false ?? false); } - $formatted = $formatted + + return $formatted ->sortBy(fn ($i) => data_get($i, 'order')) ->map(function ($i) { data_set($i, 'timestamp', Carbon::parse(data_get($i, 'timestamp'))->format('Y-M-d H:i:s.u')); @@ -421,36 +175,22 @@ function decode_remote_command_output(?ApplicationDeploymentQueue $application_d return $deploymentLogLines; }, collect()); - - return $formatted; } + function remove_iip($text) { $text = preg_replace('/x-access-token:.*?(?=@)/', 'x-access-token:'.REDACTED, $text); return preg_replace('/\x1b\[[0-9;]*m/', '', $text); } -function remove_mux_and_private_key(Server $server) -{ - $muxFilename = $server->muxFilename(); - $privateKeyLocation = savePrivateKeyToFs($server); - $closeCommand = "ssh -O exit -o ControlPath=/var/www/html/storage/app/ssh/mux/{$muxFilename} {$server->user}@{$server->ip}"; - Process::run($closeCommand); - - Storage::disk('ssh-mux')->delete($muxFilename); - Storage::disk('ssh-keys')->delete($privateKeyLocation); -} function refresh_server_connection(?PrivateKey $private_key = null) { if (is_null($private_key)) { return; } foreach ($private_key->servers as $server) { - $muxFilename = $server->muxFilename(); - $closeCommand = "ssh -O exit -o ControlPath=/var/www/html/storage/app/ssh/mux/{$muxFilename} {$server->user}@{$server->ip}"; - Process::run($closeCommand); - Storage::disk('ssh-mux')->delete($muxFilename); + SshMultiplexingHelper::removeMuxFile($server); } } @@ -468,9 +208,8 @@ function checkRequiredCommands(Server $server) break; } $commandFound = instant_remote_process(["docker run --rm --privileged --net=host --pid=host --ipc=host --volume /:/host busybox chroot /host bash -c 'command -v {$command}'"], $server, false); - if ($commandFound) { - continue; + if (! $commandFound) { + break; } - break; } } diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index cd2779466..350f16837 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -505,6 +505,12 @@ function sslip(Server $server) return "http://$baseIp.sslip.io"; } + // ipv6 + if (str($server->ip)->contains(':')) { + $ipv6 = str($server->ip)->replace(':', '-'); + + return "http://{$ipv6}.sslip.io"; + } return "http://{$server->ip}.sslip.io"; } @@ -1247,6 +1253,10 @@ function get_public_ips() } $settings->update(['public_ipv4' => $ipv4]); } + } catch (\Exception $e) { + echo "Error: {$e->getMessage()}\n"; + } + try { $ipv6 = $second->output(); if ($ipv6) { $ipv6 = trim($ipv6); @@ -2924,10 +2934,11 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int } $parsedServices = collect([]); - ray()->clearAll(); + // ray()->clearAll(); $allMagicEnvironments = collect([]); foreach ($services as $serviceName => $service) { + $predefinedPort = null; $magicEnvironments = collect([]); $image = data_get_str($service, 'image'); $environment = collect(data_get($service, 'environment', [])); @@ -2936,6 +2947,24 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int $isDatabase = isDatabaseImage(data_get_str($service, 'image')); if ($isService) { + $containerName = "$serviceName-{$resource->uuid}"; + + if ($serviceName === 'registry') { + $tempServiceName = 'docker-registry'; + } else { + $tempServiceName = $serviceName; + } + if (str(data_get($service, 'image'))->contains('glitchtip')) { + $tempServiceName = 'glitchtip'; + } + if ($serviceName === 'supabase-kong') { + $tempServiceName = 'supabase'; + } + $serviceDefinition = data_get($allServices, $tempServiceName); + $predefinedPort = data_get($serviceDefinition, 'port'); + if ($serviceName === 'plausible') { + $predefinedPort = '8000'; + } if ($isDatabase) { $savedService = ServiceDatabase::firstOrCreate([ 'name' => $serviceName, @@ -2987,8 +3016,10 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int // SERVICE_FQDN_APP or SERVICE_FQDN_APP_3000 if (substr_count(str($key)->value(), '_') === 3) { $fqdnFor = $key->after('SERVICE_FQDN_')->beforeLast('_')->lower()->value(); + $port = $key->afterLast('_')->value(); } else { $fqdnFor = $key->after('SERVICE_FQDN_')->lower()->value(); + $port = null; } if ($isApplication) { $fqdn = generateFqdn($server, "{$resource->name}-$uuid"); @@ -2999,19 +3030,24 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int $fqdn = generateFqdn($server, "{$savedService->name}-$uuid"); } } + if ($value && get_class($value) === 'Illuminate\Support\Stringable' && $value->startsWith('/')) { $path = $value->value(); if ($path !== '/') { $fqdn = "$fqdn$path"; } } + $fqdnWithPort = $fqdn; + if ($port) { + $fqdnWithPort = "$fqdn:$port"; + } if ($isApplication && is_null($resource->fqdn)) { data_forget($resource, 'environment_variables'); data_forget($resource, 'environment_variables_preview'); - $resource->fqdn = $fqdn; + $resource->fqdn = $fqdnWithPort; $resource->save(); } elseif ($isService && is_null($savedService->fqdn)) { - $savedService->fqdn = $fqdn; + $savedService->fqdn = $fqdnWithPort; $savedService->save(); } @@ -3040,7 +3076,6 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int } $allMagicEnvironments = $allMagicEnvironments->merge($magicEnvironments); - if ($magicEnvironments->count() > 0) { foreach ($magicEnvironments as $key => $value) { $key = str($key); @@ -3455,6 +3490,18 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int $value = $value->after('?'); } if ($originalValue->value() === $value->value()) { + // This means the variable does not have a default value, so it needs to be created in Coolify + $parsedKeyValue = replaceVariables($value); + $resource->environment_variables()->where('key', $parsedKeyValue)->where($nameOfId, $resource->id)->firstOrCreate([ + 'key' => $parsedKeyValue, + $nameOfId => $resource->id, + ], [ + 'is_build_time' => false, + 'is_preview' => false, + ]); + // Add the variable to the environment so it will be shown in the deployable compose file + $environment[$parsedKeyValue->value()] = $resource->environment_variables()->where('key', $parsedKeyValue)->where($nameOfId, $resource->id)->first()->value; + continue; } $resource->environment_variables()->where('key', $key)->where($nameOfId, $resource->id)->firstOrCreate([ @@ -3547,6 +3594,17 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int if ($environment->count() > 0) { $environment = $environment->filter(function ($value, $key) { return ! str($key)->startsWith('SERVICE_FQDN_'); + })->map(function ($value, $key) use ($resource) { + // if value is empty, set it to null so if you set the environment variable in the .env file (Coolify's UI), it will used + if (str($value)->isEmpty()) { + if ($resource->environment_variables()->where('key', $key)->exists()) { + $value = $resource->environment_variables()->where('key', $key)->first()->value; + } else { + $value = null; + } + } + + return $value; }); } $serviceLabels = $labels->merge($defaultLabels); @@ -3631,6 +3689,14 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int data_forget($service, 'volumes.*.is_directory'); data_forget($service, 'exclude_from_hc'); + $volumesParsed = $volumesParsed->map(function ($volume) { + data_forget($volume, 'content'); + data_forget($volume, 'is_directory'); + data_forget($volume, 'isDirectory'); + + return $volume; + }); + $payload = collect($service)->merge([ 'container_name' => $containerName, 'restart' => $restart->value(), @@ -3661,6 +3727,7 @@ function newParser(Application|Service $resource, int $pull_request_id = 0, ?int $parsedServices->put($serviceName, $payload); } $topLevel->put('services', $parsedServices); + $customOrder = ['services', 'volumes', 'networks', 'configs', 'secrets']; $topLevel = $topLevel->sortBy(function ($value, $key) use ($customOrder) { diff --git a/composer.json b/composer.json index e8b46105d..17432c532 100644 --- a/composer.json +++ b/composer.json @@ -84,7 +84,11 @@ "@php artisan vendor:publish --tag=laravel-assets --ansi --force", "Illuminate\\Foundation\\ComposerScripts::postUpdate" ], - "post-install-cmd": [], + "post-install-cmd": [ + "cp -r 'hooks/' '.git/hooks/'", + "php -r \"copy('hooks/pre-commit', '.git/hooks/pre-commit');\"", + "php -r \"chmod('.git/hooks/pre-commit', 0777);\"" + ], "post-root-package-install": [ "@php -r \"file_exists('.env') || copy('.env.example', '.env');\"" ], diff --git a/config/clockwork.php b/config/clockwork.php deleted file mode 100644 index ce880464a..000000000 --- a/config/clockwork.php +++ /dev/null @@ -1,424 +0,0 @@ - env('CLOCKWORK_ENABLE', null), - - /* - |------------------------------------------------------------------------------------------------------------------ - | Features - |------------------------------------------------------------------------------------------------------------------ - | - | You can enable or disable various Clockwork features here. Some features have additional settings (eg. slow query - | threshold for database queries). - | - */ - - 'features' => [ - - // Cache usage stats and cache queries including results - 'cache' => [ - 'enabled' => env('CLOCKWORK_CACHE_ENABLED', true), - - // Collect cache queries - 'collect_queries' => env('CLOCKWORK_CACHE_QUERIES', true), - - // Collect values from cache queries (high performance impact with a very high number of queries) - 'collect_values' => env('CLOCKWORK_CACHE_COLLECT_VALUES', false) - ], - - // Database usage stats and queries - 'database' => [ - 'enabled' => env('CLOCKWORK_DATABASE_ENABLED', true), - - // Collect database queries (high performance impact with a very high number of queries) - 'collect_queries' => env('CLOCKWORK_DATABASE_COLLECT_QUERIES', true), - - // Collect details of models updates (high performance impact with a lot of model updates) - 'collect_models_actions' => env('CLOCKWORK_DATABASE_COLLECT_MODELS_ACTIONS', true), - - // Collect details of retrieved models (very high performance impact with a lot of models retrieved) - 'collect_models_retrieved' => env('CLOCKWORK_DATABASE_COLLECT_MODELS_RETRIEVED', false), - - // Query execution time threshold in milliseconds after which the query will be marked as slow - 'slow_threshold' => env('CLOCKWORK_DATABASE_SLOW_THRESHOLD'), - - // Collect only slow database queries - 'slow_only' => env('CLOCKWORK_DATABASE_SLOW_ONLY', false), - - // Detect and report duplicate queries - 'detect_duplicate_queries' => env('CLOCKWORK_DATABASE_DETECT_DUPLICATE_QUERIES', false) - ], - - // Dispatched events - 'events' => [ - 'enabled' => env('CLOCKWORK_EVENTS_ENABLED', true), - - // Ignored events (framework events are ignored by default) - 'ignored_events' => [ - // App\Events\UserRegistered::class, - // 'user.registered' - ], - ], - - // Laravel log (you can still log directly to Clockwork with laravel log disabled) - 'log' => [ - 'enabled' => env('CLOCKWORK_LOG_ENABLED', true) - ], - - // Sent notifications - 'notifications' => [ - 'enabled' => env('CLOCKWORK_NOTIFICATIONS_ENABLED', true), - ], - - // Performance metrics - 'performance' => [ - // Allow collecting of client metrics. Requires separate clockwork-browser npm package. - 'client_metrics' => env('CLOCKWORK_PERFORMANCE_CLIENT_METRICS', true) - ], - - // Dispatched queue jobs - 'queue' => [ - 'enabled' => env('CLOCKWORK_QUEUE_ENABLED', true) - ], - - // Redis commands - 'redis' => [ - 'enabled' => env('CLOCKWORK_REDIS_ENABLED', true) - ], - - // Routes list - 'routes' => [ - 'enabled' => env('CLOCKWORK_ROUTES_ENABLED', false), - - // Collect only routes from particular namespaces (only application routes by default) - 'only_namespaces' => [ 'App' ] - ], - - // Rendered views - 'views' => [ - 'enabled' => env('CLOCKWORK_VIEWS_ENABLED', true), - - // Collect views including view data (high performance impact with a high number of views) - 'collect_data' => env('CLOCKWORK_VIEWS_COLLECT_DATA', false), - - // Use Twig profiler instead of Laravel events for apps using laravel-twigbridge (more precise, but does - // not support collecting view data) - 'use_twig_profiler' => env('CLOCKWORK_VIEWS_USE_TWIG_PROFILER', false) - ] - - ], - - /* - |------------------------------------------------------------------------------------------------------------------ - | Enable web UI - |------------------------------------------------------------------------------------------------------------------ - | - | Clockwork comes with a web UI accessible via http://your.app/clockwork. Here you can enable or disable this - | feature. You can also set a custom path for the web UI. - | - */ - - 'web' => env('CLOCKWORK_WEB', true), - - /* - |------------------------------------------------------------------------------------------------------------------ - | Enable toolbar - |------------------------------------------------------------------------------------------------------------------ - | - | Clockwork can show a toolbar with basic metrics on all responses. Here you can enable or disable this feature. - | Requires a separate clockwork-browser npm library. - | For installation instructions see https://underground.works/clockwork/#docs-viewing-data - | - */ - - 'toolbar' => env('CLOCKWORK_TOOLBAR', true), - - /* - |------------------------------------------------------------------------------------------------------------------ - | HTTP requests collection - |------------------------------------------------------------------------------------------------------------------ - | - | Clockwork collects data about HTTP requests to your app. Here you can choose which requests should be collected. - | - */ - - 'requests' => [ - // With on-demand mode enabled, Clockwork will only profile requests when the browser extension is open or you - // manually pass a "clockwork-profile" cookie or get/post data key. - // Optionally you can specify a "secret" that has to be passed as the value to enable profiling. - 'on_demand' => env('CLOCKWORK_REQUESTS_ON_DEMAND', false), - - // Collect only errors (requests with HTTP 4xx and 5xx responses) - 'errors_only' => env('CLOCKWORK_REQUESTS_ERRORS_ONLY', false), - - // Response time threshold in milliseconds after which the request will be marked as slow - 'slow_threshold' => env('CLOCKWORK_REQUESTS_SLOW_THRESHOLD'), - - // Collect only slow requests - 'slow_only' => env('CLOCKWORK_REQUESTS_SLOW_ONLY', false), - - // Sample the collected requests (e.g. set to 100 to collect only 1 in 100 requests) - 'sample' => env('CLOCKWORK_REQUESTS_SAMPLE', false), - - // List of URIs that should not be collected - 'except' => [ - '/horizon/.*', // Laravel Horizon requests - '/telescope/.*', // Laravel Telescope requests - '/_tt/.*', // Laravel Telescope toolbar - '/_debugbar/.*', // Laravel DebugBar requests - ], - - // List of URIs that should be collected, any other URI will not be collected if not empty - 'only' => [ - // '/api/.*' - ], - - // Don't collect OPTIONS requests, mostly used in the CSRF pre-flight requests and are rarely of interest - 'except_preflight' => env('CLOCKWORK_REQUESTS_EXCEPT_PREFLIGHT', true) - ], - - /* - |------------------------------------------------------------------------------------------------------------------ - | Artisan commands collection - |------------------------------------------------------------------------------------------------------------------ - | - | Clockwork can collect data about executed artisan commands. Here you can enable and configure which commands - | should be collected. - | - */ - - 'artisan' => [ - // Enable or disable collection of executed Artisan commands - 'collect' => env('CLOCKWORK_ARTISAN_COLLECT', false), - - // List of commands that should not be collected (built-in commands are not collected by default) - 'except' => [ - // 'inspire' - ], - - // List of commands that should be collected, any other command will not be collected if not empty - 'only' => [ - // 'inspire' - ], - - // Enable or disable collection of command output - 'collect_output' => env('CLOCKWORK_ARTISAN_COLLECT_OUTPUT', false), - - // Enable or disable collection of built-in Laravel commands - 'except_laravel_commands' => env('CLOCKWORK_ARTISAN_EXCEPT_LARAVEL_COMMANDS', true) - ], - - /* - |------------------------------------------------------------------------------------------------------------------ - | Queue jobs collection - |------------------------------------------------------------------------------------------------------------------ - | - | Clockwork can collect data about executed queue jobs. Here you can enable and configure which queue jobs should - | be collected. - | - */ - - 'queue' => [ - // Enable or disable collection of executed queue jobs - 'collect' => env('CLOCKWORK_QUEUE_COLLECT', false), - - // List of queue jobs that should not be collected - 'except' => [ - // App\Jobs\ExpensiveJob::class - ], - - // List of queue jobs that should be collected, any other queue job will not be collected if not empty - 'only' => [ - // App\Jobs\BuggyJob::class - ] - ], - - /* - |------------------------------------------------------------------------------------------------------------------ - | Tests collection - |------------------------------------------------------------------------------------------------------------------ - | - | Clockwork can collect data about executed tests. Here you can enable and configure which tests should be - | collected. - | - */ - - 'tests' => [ - // Enable or disable collection of ran tests - 'collect' => env('CLOCKWORK_TESTS_COLLECT', false), - - // List of tests that should not be collected - 'except' => [ - // Tests\Unit\ExampleTest::class - ] - ], - - /* - |------------------------------------------------------------------------------------------------------------------ - | Enable data collection when Clockwork is disabled - |------------------------------------------------------------------------------------------------------------------ - | - | You can enable this setting to collect data even when Clockwork is disabled, e.g. for future analysis. - | - */ - - 'collect_data_always' => env('CLOCKWORK_COLLECT_DATA_ALWAYS', false), - - /* - |------------------------------------------------------------------------------------------------------------------ - | Metadata storage - |------------------------------------------------------------------------------------------------------------------ - | - | Configure how is the metadata collected by Clockwork stored. Three options are available: - | - files - A simple fast storage implementation storing data in one-per-request files. - | - sql - Stores requests in a sql database. Supports MySQL, PostgreSQL and SQLite. Requires PDO. - | - redis - Stores requests in redis. Requires phpredis. - */ - - 'storage' => env('CLOCKWORK_STORAGE', 'files'), - - // Path where the Clockwork metadata is stored - 'storage_files_path' => env('CLOCKWORK_STORAGE_FILES_PATH', storage_path('clockwork')), - - // Compress the metadata files using gzip, trading a little bit of performance for lower disk usage - 'storage_files_compress' => env('CLOCKWORK_STORAGE_FILES_COMPRESS', false), - - // SQL database to use, can be a name of database configured in database.php or a path to a SQLite file - 'storage_sql_database' => env('CLOCKWORK_STORAGE_SQL_DATABASE', storage_path('clockwork.sqlite')), - - // SQL table name to use, the table is automatically created and updated when needed - 'storage_sql_table' => env('CLOCKWORK_STORAGE_SQL_TABLE', 'clockwork'), - - // Redis connection, name of redis connection or cluster configured in database.php - 'storage_redis' => env('CLOCKWORK_STORAGE_REDIS', 'default'), - - // Redis prefix for Clockwork keys ("clockwork" if not set) - 'storage_redis_prefix' => env('CLOCKWORK_STORAGE_REDIS_PREFIX', 'clockwork'), - - // Maximum lifetime of collected metadata in minutes, older requests will automatically be deleted, false to disable - 'storage_expiration' => env('CLOCKWORK_STORAGE_EXPIRATION', 60 * 24 * 7), - - /* - |------------------------------------------------------------------------------------------------------------------ - | Authentication - |------------------------------------------------------------------------------------------------------------------ - | - | Clockwork can be configured to require authentication before allowing access to the collected data. This might be - | useful when the application is publicly accessible. Setting to true will enable a simple authentication with a - | pre-configured password. You can also pass a class name of a custom implementation. - | - */ - - 'authentication' => env('CLOCKWORK_AUTHENTICATION', false), - - // Password for the simple authentication - 'authentication_password' => env('CLOCKWORK_AUTHENTICATION_PASSWORD', 'VerySecretPassword'), - - /* - |------------------------------------------------------------------------------------------------------------------ - | Stack traces collection - |------------------------------------------------------------------------------------------------------------------ - | - | Clockwork can collect stack traces for log messages and certain data like database queries. Here you can set - | whether to collect stack traces, limit the number of collected frames and set further configuration. Collecting - | long stack traces considerably increases metadata size. - | - */ - - 'stack_traces' => [ - // Enable or disable collecting of stack traces - 'enabled' => env('CLOCKWORK_STACK_TRACES_ENABLED', true), - - // Limit the number of frames to be collected - 'limit' => env('CLOCKWORK_STACK_TRACES_LIMIT', 10), - - // List of vendor names to skip when determining caller, common vendors are automatically added - 'skip_vendors' => [ - // 'phpunit' - ], - - // List of namespaces to skip when determining caller - 'skip_namespaces' => [ - // 'Laravel' - ], - - // List of class names to skip when determining caller - 'skip_classes' => [ - // App\CustomLog::class - ] - - ], - - /* - |------------------------------------------------------------------------------------------------------------------ - | Serialization - |------------------------------------------------------------------------------------------------------------------ - | - | Clockwork serializes the collected data to json for storage and transfer. Here you can configure certain aspects - | of serialization. Serialization has a large effect on the cpu time and memory usage. - | - */ - - // Maximum depth of serialized multi-level arrays and objects - 'serialization_depth' => env('CLOCKWORK_SERIALIZATION_DEPTH', 10), - - // A list of classes that will never be serialized (e.g. a common service container class) - 'serialization_blackbox' => [ - \Illuminate\Container\Container::class, - \Illuminate\Foundation\Application::class, - \Laravel\Lumen\Application::class - ], - - /* - |------------------------------------------------------------------------------------------------------------------ - | Register helpers - |------------------------------------------------------------------------------------------------------------------ - | - | Clockwork comes with a "clock" global helper function. You can use this helper to quickly log something and to - | access the Clockwork instance. - | - */ - - 'register_helpers' => env('CLOCKWORK_REGISTER_HELPERS', true), - - /* - |------------------------------------------------------------------------------------------------------------------ - | Send headers for AJAX request - |------------------------------------------------------------------------------------------------------------------ - | - | When trying to collect data, the AJAX method can sometimes fail if it is missing required headers. For example, an - | API might require a version number using Accept headers to route the HTTP request to the correct codebase. - | - */ - - 'headers' => [ - // 'Accept' => 'application/vnd.com.whatever.v1+json', - ], - - /* - |------------------------------------------------------------------------------------------------------------------ - | Server timing - |------------------------------------------------------------------------------------------------------------------ - | - | Clockwork supports the W3C Server Timing specification, which allows for collecting a simple performance metrics - | in a cross-browser way. E.g. in Chrome, your app, database and timeline event timings will be shown in the Dev - | Tools network tab. This setting specifies the max number of timeline events that will be sent. Setting to false - | will disable the feature. - | - */ - - 'server_timing' => env('CLOCKWORK_SERVER_TIMING', 10) - -]; diff --git a/config/constants.php b/config/constants.php index 906ef3ba2..5792b358c 100644 --- a/config/constants.php +++ b/config/constants.php @@ -6,9 +6,8 @@ return [ 'contact' => 'https://coolify.io/docs/contact', ], 'ssh' => [ - // Using MUX - 'mux_enabled' => env('MUX_ENABLED', env('SSH_MUX_ENABLED', true), true), - 'mux_persist_time' => env('SSH_MUX_PERSIST_TIME', '1h'), + 'mux_enabled' => env('MUX_ENABLED', env('SSH_MUX_ENABLED', true)), + 'mux_persist_time' => env('SSH_MUX_PERSIST_TIME', 3600), 'connection_timeout' => 10, 'server_interval' => 20, 'command_timeout' => 7200, diff --git a/config/sentry.php b/config/sentry.php index 471a1e0fc..1e83eae9e 100644 --- a/config/sentry.php +++ b/config/sentry.php @@ -7,7 +7,7 @@ return [ // The release version of your application // Example with dynamic git hash: trim(exec('git --git-dir ' . base_path('.git') . ' log --pretty="%h" -n1 HEAD')) - 'release' => '4.0.0-beta.341', + 'release' => '4.0.0-beta.347', // When left empty or `null` the Laravel environment will be used 'environment' => config('app.env'), diff --git a/config/version.php b/config/version.php index 32eb01cd0..6e7f911ae 100644 --- a/config/version.php +++ b/config/version.php @@ -1,3 +1,3 @@ chunkById(100, function ($keys) { + foreach ($keys as $key) { + DB::table('private_keys') + ->where('id', $key->id) + ->update(['private_key' => Crypt::encryptString($key->private_key)]); + } + }); + } catch (\Exception $e) { + echo 'Encrypting private keys failed.'; + echo $e->getMessage(); + } + + } +} diff --git a/database/migrations/2024_09_17_111226_add_ssh_key_fingerprint_to_private_keys_table.php b/database/migrations/2024_09_17_111226_add_ssh_key_fingerprint_to_private_keys_table.php new file mode 100644 index 000000000..dfce5682a --- /dev/null +++ b/database/migrations/2024_09_17_111226_add_ssh_key_fingerprint_to_private_keys_table.php @@ -0,0 +1,39 @@ +string('fingerprint')->after('private_key')->nullable(); + }); + + try { + DB::table('private_keys')->chunkById(100, function ($keys) { + foreach ($keys as $key) { + $fingerprint = PrivateKey::generateFingerprint($key->private_key); + if ($fingerprint) { + $key->fingerprint = $fingerprint; + $key->save(); + } + } + }); + } catch (\Exception $e) { + echo 'Generating fingerprints failed.'; + echo $e->getMessage(); + } + } + + public function down() + { + Schema::table('private_keys', function (Blueprint $table) { + $table->dropColumn('fingerprint'); + }); + } +} diff --git a/database/seeders/ApplicationPreviewSeeder.php b/database/seeders/ApplicationPreviewSeeder.php deleted file mode 100644 index 764939073..000000000 --- a/database/seeders/ApplicationPreviewSeeder.php +++ /dev/null @@ -1,20 +0,0 @@ - $application_1->id, - // 'pull_request_id' => 1 - // ]); - } -} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index b3fac350f..be5083108 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -13,25 +13,18 @@ class DatabaseSeeder extends Seeder UserSeeder::class, TeamSeeder::class, PrivateKeySeeder::class, + PopulateSshKeysDirectorySeeder::class, ServerSeeder::class, ServerSettingSeeder::class, ProjectSeeder::class, - ProjectSettingSeeder::class, - EnvironmentSeeder::class, StandaloneDockerSeeder::class, - SwarmDockerSeeder::class, - KubernetesSeeder::class, GithubAppSeeder::class, GitlabAppSeeder::class, ApplicationSeeder::class, ApplicationSettingsSeeder::class, - ApplicationPreviewSeeder::class, - EnvironmentVariableSeeder::class, LocalPersistentVolumeSeeder::class, S3StorageSeeder::class, StandalonePostgresqlSeeder::class, - ScheduledDatabaseBackupSeeder::class, - ScheduledDatabaseBackupExecutionSeeder::class, OauthSettingSeeder::class, ]); } diff --git a/database/seeders/EnvironmentSeeder.php b/database/seeders/EnvironmentSeeder.php deleted file mode 100644 index 1c6d562a9..000000000 --- a/database/seeders/EnvironmentSeeder.php +++ /dev/null @@ -1,13 +0,0 @@ - 'NODE_ENV', - // 'value' => 'production', - // 'is_build_time' => true, - // 'application_id' => 1, - // ]); - } -} diff --git a/database/seeders/GitSeeder.php b/database/seeders/GitSeeder.php deleted file mode 100644 index c8dc3ab6d..000000000 --- a/database/seeders/GitSeeder.php +++ /dev/null @@ -1,26 +0,0 @@ - 'https://api.github.com', - // 'html_url' => 'https://github.com', - // 'is_public' => false, - // 'private_key_id' => $private_key_1->id, - // 'project_id' => $project->id, - // ]); - } -} diff --git a/database/seeders/GithubAppSeeder.php b/database/seeders/GithubAppSeeder.php index 4aa5ec753..2ece7a05b 100644 --- a/database/seeders/GithubAppSeeder.php +++ b/database/seeders/GithubAppSeeder.php @@ -31,7 +31,7 @@ class GithubAppSeeder extends Seeder 'client_id' => 'Iv1.220e564d2b0abd8c', 'client_secret' => '116d1d80289f378410dd70ab4e4b81dd8d2c52b6', 'webhook_secret' => '326a47b49054f03288f800d81247ec9414d0abf3', - 'private_key_id' => 1, + 'private_key_id' => 2, 'team_id' => 0, ]); } diff --git a/database/seeders/GitlabAppSeeder.php b/database/seeders/GitlabAppSeeder.php index af63f2ed7..ec2b7ec5e 100644 --- a/database/seeders/GitlabAppSeeder.php +++ b/database/seeders/GitlabAppSeeder.php @@ -20,19 +20,5 @@ class GitlabAppSeeder extends Seeder 'is_public' => true, 'team_id' => 0, ]); - GitlabApp::create([ - 'id' => 2, - 'name' => 'coolify-laravel-development-private-gitlab', - 'api_url' => 'https://gitlab.com/api/v4', - 'html_url' => 'https://gitlab.com', - 'app_id' => 1234, - 'app_secret' => '1234', - 'oauth_id' => 1234, - 'deploy_key_id' => '1234', - 'public_key' => 'dfjasiourj', - 'webhook_token' => '4u3928u4y392', - 'private_key_id' => 2, - 'team_id' => 0, - ]); } } diff --git a/database/seeders/KubernetesSeeder.php b/database/seeders/KubernetesSeeder.php deleted file mode 100644 index f6a852e05..000000000 --- a/database/seeders/KubernetesSeeder.php +++ /dev/null @@ -1,16 +0,0 @@ -deleteDirectory(''); + Storage::disk('ssh-keys')->makeDirectory(''); + Storage::disk('ssh-mux')->deleteDirectory(''); + Storage::disk('ssh-mux')->makeDirectory(''); + + PrivateKey::chunk(100, function ($keys) { + foreach ($keys as $key) { + echo 'Storing key: '.$key->name."\n"; + $key->storeInFileSystem(); + } + }); + + if (isDev()) { + $user = env('PUID').':'.env('PGID'); + Process::run("chown -R $user ".storage_path('app/ssh/keys')); + Process::run("chown -R $user ".storage_path('app/ssh/mux')); + } else { + Process::run('chown -R 9999:root '.storage_path('app/ssh/keys')); + Process::run('chown -R 9999:root '.storage_path('app/ssh/mux')); + } + } catch (\Throwable $e) { + echo "Error: {$e->getMessage()}\n"; + ray($e->getMessage()); + } + } +} diff --git a/database/seeders/PrivateKeySeeder.php b/database/seeders/PrivateKeySeeder.php index 8a70cf56d..6b44d0867 100644 --- a/database/seeders/PrivateKeySeeder.php +++ b/database/seeders/PrivateKeySeeder.php @@ -13,9 +13,8 @@ class PrivateKeySeeder extends Seeder public function run(): void { PrivateKey::create([ - 'id' => 0, 'team_id' => 0, - 'name' => 'Testing-host', + 'name' => 'Testing Host Key', 'description' => 'This is a test docker container', 'private_key' => '-----BEGIN OPENSSH PRIVATE KEY----- b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW @@ -25,10 +24,9 @@ AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== -----END OPENSSH PRIVATE KEY----- ', - ]); + PrivateKey::create([ - 'id' => 1, 'team_id' => 0, 'name' => 'development-github-app', 'description' => 'This is the key for using the development GitHub app', @@ -61,12 +59,5 @@ a1C8EDKapCw5hAhizEFOUQKOygL8Ipn+tmEUkORYdZ8Q8cWFCv9nIw== -----END RSA PRIVATE KEY-----', 'is_git_related' => true, ]); - PrivateKey::create([ - 'id' => 2, - 'team_id' => 0, - 'name' => 'development-gitlab-app', - 'description' => 'This is the key for using the development Gitlab app', - 'private_key' => 'asdf', - ]); } } diff --git a/database/seeders/ProductionSeeder.php b/database/seeders/ProductionSeeder.php index 799dd0440..d0f0f10f4 100644 --- a/database/seeders/ProductionSeeder.php +++ b/database/seeders/ProductionSeeder.php @@ -64,32 +64,8 @@ class ProductionSeeder extends Seeder 'team_id' => 0, ]); } - - if (! isCloud() && config('coolify.is_windows_docker_desktop') == false) { - echo "Checking localhost key.\n"; - // Save SSH Keys for the Coolify Host - $coolify_key_name = 'id.root@host.docker.internal'; - $coolify_key = Storage::disk('ssh-keys')->get("{$coolify_key_name}"); - - if ($coolify_key) { - PrivateKey::updateOrCreate( - [ - 'id' => 0, - 'team_id' => 0, - ], - [ - 'name' => 'localhost\'s key', - 'description' => 'The private key for the Coolify host machine (localhost).', - 'private_key' => $coolify_key, - ] - ); - } else { - echo "No SSH key found for the Coolify host machine (localhost).\n"; - echo "Please generate one and save it in /data/coolify/ssh/keys/{$coolify_key_name}\n"; - echo "Then try to install again.\n"; - exit(1); - } - // Add Coolify host (localhost) as Server if it doesn't exist + // Add Coolify host (localhost) as Server if it doesn't exist + if (! isCloud()) { if (Server::find(0) == null) { $server_details = [ 'id' => 0, @@ -123,6 +99,50 @@ class ProductionSeeder extends Seeder ]); } } + + if (! isCloud() && config('coolify.is_windows_docker_desktop') == false) { + echo "Checking localhost key.\n"; + $coolify_key_name = '@host.docker.internal'; + $ssh_keys_directory = Storage::disk('ssh-keys')->files(); + $coolify_key = collect($ssh_keys_directory)->firstWhere(fn ($item) => str($item)->contains($coolify_key_name)); + + $found = PrivateKey::find(0); + if ($found) { + echo 'Private Key found in database.\n'; + if ($coolify_key) { + echo "SSH key found for the Coolify host machine (localhost).\n"; + } + } else { + if ($coolify_key) { + $user = str($coolify_key)->before('@')->after('id.'); + $coolify_key = Storage::disk('ssh-keys')->get($coolify_key); + PrivateKey::create([ + 'id' => 0, + 'team_id' => 0, + 'name' => 'localhost\'s key', + 'description' => 'The private key for the Coolify host machine (localhost).', + 'private_key' => $coolify_key, + ]); + $server->update(['user' => $user]); + echo "SSH key found for the Coolify host machine (localhost).\n"; + + } else { + PrivateKey::create( + [ + 'id' => 0, + 'team_id' => 0, + 'name' => 'localhost\'s key', + 'description' => 'The private key for the Coolify host machine (localhost).', + 'private_key' => 'Paste here you private key!!', + ] + ); + echo "No SSH key found for the Coolify host machine (localhost).\n"; + echo "Please read the following documentation (point 3) to fix it: https://coolify.io/docs/knowledge-base/server/openssh/\n"; + echo "Your localhost connection won't work until then."; + } + } + + } if (config('coolify.is_windows_docker_desktop')) { PrivateKey::updateOrCreate( [ @@ -179,8 +199,8 @@ uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== get_public_ips(); - $oauth_settings_seeder = new OauthSettingSeeder; - $oauth_settings_seeder->run(); + $this->call(OauthSettingSeeder::class); + $this->call(PopulateSshKeysDirectorySeeder::class); } } diff --git a/database/seeders/ProjectSettingSeeder.php b/database/seeders/ProjectSettingSeeder.php deleted file mode 100644 index 2a8cdfdb4..000000000 --- a/database/seeders/ProjectSettingSeeder.php +++ /dev/null @@ -1,15 +0,0 @@ -settings->wildcard_domain = 'wildcard.example.com'; - // $first_project->settings->save(); - } -} diff --git a/database/seeders/ScheduledDatabaseBackupExecutionSeeder.php b/database/seeders/ScheduledDatabaseBackupExecutionSeeder.php deleted file mode 100644 index 7e4c33764..000000000 --- a/database/seeders/ScheduledDatabaseBackupExecutionSeeder.php +++ /dev/null @@ -1,28 +0,0 @@ - 'success', - // 'message' => 'Backup created successfully.', - // 'size' => '10243467789556', - // 'scheduled_database_backup_id' => 1, - // ]); - // ScheduledDatabaseBackupExecution::create([ - // 'status' => 'failed', - // 'message' => 'Backup failed.', - // 'size' => '10243456', - // 'scheduled_database_backup_id' => 1, - // ]); - } -} diff --git a/database/seeders/ScheduledDatabaseBackupSeeder.php b/database/seeders/ScheduledDatabaseBackupSeeder.php deleted file mode 100644 index fefbada0d..000000000 --- a/database/seeders/ScheduledDatabaseBackupSeeder.php +++ /dev/null @@ -1,33 +0,0 @@ - true, - // 'frequency' => '* * * * *', - // 'number_of_backups_locally' => 2, - // 'database_id' => 1, - // 'database_type' => 'App\Models\StandalonePostgresql', - // 's3_storage_id' => 1, - // 'team_id' => 0, - // ]); - // ScheduledDatabaseBackup::create([ - // 'enabled' => true, - // 'frequency' => '* * * * *', - // 'number_of_backups_locally' => 3, - // 'database_id' => 1, - // 'database_type' => 'App\Models\StandalonePostgresql', - // 'team_id' => 0, - // ]); - } -} diff --git a/database/seeders/ServerSeeder.php b/database/seeders/ServerSeeder.php index 12594bcb9..d32843107 100644 --- a/database/seeders/ServerSeeder.php +++ b/database/seeders/ServerSeeder.php @@ -2,6 +2,8 @@ namespace Database\Seeders; +use App\Enums\ProxyStatus; +use App\Enums\ProxyTypes; use App\Models\Server; use Illuminate\Database\Seeder; @@ -15,7 +17,11 @@ class ServerSeeder extends Seeder 'description' => 'This is a test docker container in development mode', 'ip' => 'coolify-testing-host', 'team_id' => 0, - 'private_key_id' => 0, + 'private_key_id' => 1, + 'proxy' => [ + 'type' => ProxyTypes::TRAEFIK->value, + 'status' => ProxyStatus::EXITED->value, + ], ]); } } diff --git a/database/seeders/ServiceApplicationSeeder.php b/database/seeders/ServiceApplicationSeeder.php deleted file mode 100644 index 04648f83c..000000000 --- a/database/seeders/ServiceApplicationSeeder.php +++ /dev/null @@ -1,16 +0,0 @@ - 'Swarm Docker 1', - // 'server_id' => 1, - // ]); - } -} diff --git a/database/seeders/WaitlistSeeder.php b/database/seeders/WaitlistSeeder.php deleted file mode 100644 index de6837c60..000000000 --- a/database/seeders/WaitlistSeeder.php +++ /dev/null @@ -1,16 +0,0 @@ - { const messageHandlers = { message: (session, data) => session.ptyProcess.write(data), - resize: (session, { cols, rows }) => session.ptyProcess.resize(cols, rows), + resize: (session, { cols, rows }) => { + cols = cols > 0 ? cols : 80; + rows = rows > 0 ? rows : 30; + session.ptyProcess.resize(cols, rows) + }, pause: (session) => session.ptyProcess.pause(), resume: (session) => session.ptyProcess.resume(), checkActive: (session, data) => { @@ -140,6 +144,7 @@ async function handleCommand(ws, command, userId) { ptyProcess.onData((data) => ws.send(data)); + // when parent closes ptyProcess.onExit(({ exitCode, signal }) => { console.error(`Process exited with code ${exitCode} and signal ${signal}`); userSession.isActive = false; diff --git a/docker/prod/etc/s6-overlay/s6-rc.d/init-script/up b/docker/prod/etc/s6-overlay/s6-rc.d/init-script/up index ea960df95..3b252b782 100644 --- a/docker/prod/etc/s6-overlay/s6-rc.d/init-script/up +++ b/docker/prod/etc/s6-overlay/s6-rc.d/init-script/up @@ -1,3 +1,3 @@ #!/command/execlineb -P s6-setuidgid webuser -php /var/www/html/artisan app:init --full-cleanup +php /var/www/html/artisan app:init diff --git a/hooks/pre-commit b/hooks/pre-commit new file mode 100644 index 000000000..69a5a9d41 --- /dev/null +++ b/hooks/pre-commit @@ -0,0 +1,21 @@ +#!/bin/sh +# Detect whether /dev/tty is available & functional +if sh -c ": >/dev/tty" >/dev/null 2>/dev/null; then + exec < /dev/tty +fi + +# Get list of stashed PHP files +stashed_files=$(git diff --cached --name-only --diff-filter=ACM -- '*.php') + +# If there are no stashed PHP files, exit early +if [ -z "$stashed_files" ]; then + exit 0 +fi + +# Set files variable to only include stashed PHP files +files="$stashed_files" + +$(pwd)/vendor/bin/pint $files -q +if [ $? -eq 0 ]; then + git add $files +fi diff --git a/lang/en.json b/lang/en.json index 461a96e9a..45fd72743 100644 --- a/lang/en.json +++ b/lang/en.json @@ -26,5 +26,11 @@ "input.code": "One-time code", "input.recovery_code": "Recovery code", "button.save": "Save", - "repository.url": "Examples
For Public repositories, use https://....
For Private repositories, use git@....

https://github.com/coollabsio/coolify-examples main branch will be selected
https://github.com/coollabsio/coolify-examples/tree/nodejs-fastify nodejs-fastify branch will be selected.
https://gitea.com/sedlav/expressjs.git main branch will be selected.
https://gitlab.com/andrasbacsai/nodejs-example.git main branch will be selected." + "repository.url": "Examples
For Public repositories, use https://....
For Private repositories, use git@....

https://github.com/coollabsio/coolify-examples main branch will be selected
https://github.com/coollabsio/coolify-examples/tree/nodejs-fastify nodejs-fastify branch will be selected.
https://gitea.com/sedlav/expressjs.git main branch will be selected.
https://gitlab.com/andrasbacsai/nodejs-example.git main branch will be selected.", + "service.stop": "This service will be stopped.", + "resource.docker_cleanup": "Run Docker Cleanup (remove unused images and builder cache).", + "resource.non_persistent": "All non-persistent data will be deleted.", + "resource.delete_volumes": "Permanently delete all volumes associated with this resource.", + "resource.delete_connected_networks": "Permanently delete all non-predefined networks associated with this resource.", + "resource.delete_configurations": "Permanently delete all configuration files from the server." } diff --git a/other/nightly/.env.development.example b/other/nightly/.env.development.example index 3023a21a6..4de434df2 100644 --- a/other/nightly/.env.development.example +++ b/other/nightly/.env.development.example @@ -19,11 +19,7 @@ DB_PORT=5432 # Set to true to enable Ray RAY_ENABLED=false # Set custom ray port -RAY_PORT= - -# Clockwork Configuration -CLOCKWORK_ENABLED=false -CLOCKWORK_QUEUE_COLLECT=true +# RAY_PORT= # Enable Laravel Telescope for debugging TELESCOPE_ENABLED=false diff --git a/other/nightly/install.sh b/other/nightly/install.sh index 09613e12b..04faf50ea 100755 --- a/other/nightly/install.sh +++ b/other/nightly/install.sh @@ -8,8 +8,10 @@ set -o pipefail # Cause a pipeline to return the status of the last command that CDN="https://cdn.coollabs.io/coolify-nightly" DATE=$(date +"%Y%m%d-%H%M%S") -VERSION="1.5" +VERSION="1.6" DOCKER_VERSION="26.0" +# TODO: Ask for a user +CURRENT_USER=$USER mkdir -p /data/coolify/{source,ssh,applications,databases,backups,services,proxy,webhooks-during-maintenance,metrics,logs} mkdir -p /data/coolify/ssh/{keys,mux} @@ -23,7 +25,7 @@ INSTALLATION_LOG_WITH_DATE="/data/coolify/source/installation-${DATE}.log" exec > >(tee -a $INSTALLATION_LOG_WITH_DATE) 2>&1 getAJoke() { - JOKES=$(curl -s --max-time 2 https://v2.jokeapi.dev/joke/Programming?format=txt&type=single&amount=1 || true) + JOKES=$(curl -s --max-time 2 "https://v2.jokeapi.dev/joke/Programming?blacklistFlags=nsfw,religious,political,racist,sexist,explicit&format=txt&type=single" || true) if [ "$JOKES" != "" ]; then echo -e " - Until then, here's a joke for you:\n" echo -e "$JOKES\n" @@ -37,6 +39,11 @@ if [ "$OS_TYPE" = "manjaro" ] || [ "$OS_TYPE" = "manjaro-arm" ]; then OS_TYPE="arch" fi +# Check if the OS is Asahi Linux, if so, change it to fedora +if [ "$OS_TYPE" = "fedora-asahi-remix" ]; then + OS_TYPE="fedora" +fi + # Check if the OS is popOS, if so, change it to ubuntu if [ "$OS_TYPE" = "pop" ]; then OS_TYPE="ubuntu" @@ -396,88 +403,18 @@ if [ ! -f ~/.ssh/authorized_keys ]; then chmod 600 ~/.ssh/authorized_keys fi -checkSshKeyInAuthorizedKeys() { - grep -qw "root@coolify" ~/.ssh/authorized_keys - return $? -} +set +e +IF_COOLIFY_VOLUME_EXISTS=$(docker volume ls | grep coolify-db | wc -l) +set -e -checkSshKeyInCoolifyData() { - [ -s /data/coolify/ssh/keys/id.root@host.docker.internal ] - return $? -} - -generateAuthorizedKeys() { - sed -i "/root@coolify/d" ~/.ssh/authorized_keys - cat /data/coolify/ssh/keys/id.root@host.docker.internal.pub >> ~/.ssh/authorized_keys - rm -f /data/coolify/ssh/keys/id.root@host.docker.internal.pub -} -generateSshKey() { +if [ "$IF_COOLIFY_VOLUME_EXISTS" -eq 0 ]; then echo " - Generating SSH key." - ssh-keygen -t ed25519 -a 100 -f /data/coolify/ssh/keys/id.root@host.docker.internal -q -N "" -C root@coolify - chown 9999 /data/coolify/ssh/keys/id.root@host.docker.internal - generateAuthorizedKeys -} - -syncSshKeys() { - DB_RUNNING=$(docker inspect coolify-db --format '{{ .State.Status }}' 2>/dev/null) - # Check if SSH key exists in Coolify data but not in authorized_keys - if checkSshKeyInCoolifyData && ! checkSshKeyInAuthorizedKeys; then - # Add the existing Coolify SSH key to authorized_keys - cat /data/coolify/ssh/keys/id.root@host.docker.internal.pub >> ~/.ssh/authorized_keys - # Check if SSH key exists in authorized_keys but not in Coolify data - elif checkSshKeyInAuthorizedKeys && ! checkSshKeyInCoolifyData; then - # Ensure Coolify DB is running before proceeding - if [ "$DB_RUNNING" = "running" ]; then - # Retrieve DB user and SSH key from Coolify database - DB_USER=$(docker inspect coolify-db --format '{{ .Config.Env }}' | grep -oP 'POSTGRES_USER=\K[^ ]+') - DB_SSH_KEY=$(docker exec coolify-db psql -U $DB_USER -d coolify -t -c "SELECT \"private_key\" FROM \"private_keys\" WHERE id = 0 AND team_id = 0 LIMIT 1;" -A -t) - - if [ -z "$DB_SSH_KEY" ]; then - # If no key found in DB, generate a new one - echo " - SSH key not found in database. Generating new key." - generateSshKey - else - # If key found in DB, save it and update authorized_keys - echo " - SSH key found in database. Saving to file." - echo "$DB_SSH_KEY" > /data/coolify/ssh/keys/id.root@host.docker.internal - chmod 600 /data/coolify/ssh/keys/id.root@host.docker.internal - chown 9999 /data/coolify/ssh/keys/id.root@host.docker.internal - - # Generate public key from private key and update authorized_keys - ssh-keygen -y -f /data/coolify/ssh/keys/id.root@host.docker.internal -C root@coolify > /data/coolify/ssh/keys/id.root@host.docker.internal.pub - sed -i "/root@coolify/d" ~/.ssh/authorized_keys - cat /data/coolify/ssh/keys/id.root@host.docker.internal.pub >> ~/.ssh/authorized_keys - rm -f /data/coolify/ssh/keys/id.root@host.docker.internal.pub - chmod 600 ~/.ssh/authorized_keys - fi - fi - # If SSH key doesn't exist in either location - elif ! checkSshKeyInAuthorizedKeys && ! checkSshKeyInCoolifyData; then - # Ensure Coolify DB is running before proceeding - if [ "$DB_RUNNING" = "running" ]; then - # Retrieve DB user and SSH key from Coolify database - DB_USER=$(docker inspect coolify-db --format '{{ .Config.Env }}' | grep -oP 'POSTGRES_USER=\K[^ ]+') - DB_SSH_KEY=$(docker exec coolify-db psql -U $DB_USER -d coolify -t -c "SELECT \"private_key\" FROM \"private_keys\" WHERE id = 0 AND team_id = 0 LIMIT 1;" -A -t) - if [ -z "$DB_SSH_KEY" ]; then - # If no key found in DB, generate a new one - echo " - SSH key not found in database. Generating new key." - generateSshKey - else - # If key found in DB, save it and update authorized_keys - echo " - SSH key found in database. Saving to file." - echo "$DB_SSH_KEY" > /data/coolify/ssh/keys/id.root@host.docker.internal - chmod 600 /data/coolify/ssh/keys/id.root@host.docker.internal - ssh-keygen -y -f /data/coolify/ssh/keys/id.root@host.docker.internal -C root@coolify > /data/coolify/ssh/keys/id.root@host.docker.internal.pub - sed -i "/root@coolify/d" ~/.ssh/authorized_keys - cat /data/coolify/ssh/keys/id.root@host.docker.internal.pub >> ~/.ssh/authorized_keys - fi - else - generateSshKey - fi - fi -} - -syncSshKeys || true + ssh-keygen -t ed25519 -a 100 -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal -q -N "" -C coolify + chown 9999 /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal + sed -i "/coolify/d" ~/.ssh/authorized_keys + cat /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal.pub >> ~/.ssh/authorized_keys + rm -f /data/coolify/ssh/keys/id.$CURRENT_USER@host.docker.internal.pub +fi chown -R 9999:root /data/coolify chmod -R 700 /data/coolify diff --git a/other/nightly/versions.json b/other/nightly/versions.json index b7e48c698..74cbecb19 100644 --- a/other/nightly/versions.json +++ b/other/nightly/versions.json @@ -1,10 +1,10 @@ { "coolify": { "v4": { - "version": "4.0.0-beta.339" + "version": "4.0.0-beta.347" }, "nightly": { - "version": "4.0.0-beta.340" + "version": "4.0.0-beta.348" }, "helper": { "version": "1.0.1" @@ -13,4 +13,4 @@ "version": "1.0.1" } } -} \ No newline at end of file +} diff --git a/package-lock.json b/package-lock.json index ad1a3cc31..8f6fbde08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1860,9 +1860,9 @@ } }, "node_modules/rollup": { - "version": "3.29.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.4.tgz", - "integrity": "sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw==", + "version": "3.29.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.5.tgz", + "integrity": "sha512-GVsDdsbJzzy4S/v3dqWPJ7EfvZJfCHiDqe80IyrF59LYuP+e6U1LJoUqeuqRbwAWoMNoXivMNeNAOf5E22VA1w==", "dev": true, "bin": { "rollup": "dist/bin/rollup" diff --git a/resources/js/app.js b/resources/js/app.js index bbf8104c6..613b80069 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -5,17 +5,13 @@ // app.component("magic-bar", MagicBar); // app.mount("#vue"); -import { Terminal } from '@xterm/xterm'; -import '@xterm/xterm/css/xterm.css'; -import { FitAddon } from '@xterm/addon-fit'; +import { initializeTerminalComponent } from './terminal.js'; -if (!window.term) { - window.term = new Terminal({ - cols: 80, - rows: 30, - fontFamily: '"Fira Code", courier-new, courier, monospace, "Powerline Extra Symbols"', - cursorBlink: true, +['livewire:navigated', 'alpine:init'].forEach((event) => { + document.addEventListener(event, () => { + // tree-shaking + if (document.getElementById('terminal-container')) { + initializeTerminalComponent() + } }); - window.fitAddon = new FitAddon(); - window.term.loadAddon(window.fitAddon); -} +}); diff --git a/resources/js/terminal.js b/resources/js/terminal.js new file mode 100644 index 000000000..21854ed63 --- /dev/null +++ b/resources/js/terminal.js @@ -0,0 +1,228 @@ +import { Terminal } from '@xterm/xterm'; +import '@xterm/xterm/css/xterm.css'; +import { FitAddon } from '@xterm/addon-fit'; + +export function initializeTerminalComponent() { + function terminalData() { + return { + fullscreen: false, + terminalActive: false, + message: '(connection closed)', + term: null, + fitAddon: null, + socket: null, + commandBuffer: '', + pendingWrites: 0, + paused: false, + MAX_PENDING_WRITES: 5, + keepAliveInterval: null, + + init() { + this.setupTerminal(); + this.initializeWebSocket(); + this.setupTerminalEventListeners(); + + this.$wire.on('send-back-command', (command) => { + this.socket.send(JSON.stringify({ + command: command + })); + }); + + this.keepAliveInterval = setInterval(this.keepAlive.bind(this), 30000); + + this.$watch('terminalActive', (active) => { + if (!active && this.keepAliveInterval) { + clearInterval(this.keepAliveInterval); + } + this.$nextTick(() => { + if (active) { + this.$refs.terminalWrapper.style.display = 'block'; + this.resizeTerminal(); + } else { + this.$refs.terminalWrapper.style.display = 'none'; + } + }); + }); + + ['livewire:navigated', 'beforeunload'].forEach((event) => { + document.addEventListener(event, () => { + this.checkIfProcessIsRunningAndKillIt(); + clearInterval(this.keepAliveInterval); + }, { once: true }); + }); + + window.onresize = () => { + this.resizeTerminal() + }; + + }, + + setupTerminal() { + const terminalElement = document.getElementById('terminal'); + if (terminalElement) { + this.term = new Terminal({ + cols: 80, + rows: 30, + fontFamily: '"Fira Code", courier-new, courier, monospace, "Powerline Extra Symbols"', + cursorBlink: true, + }); + this.fitAddon = new FitAddon(); + this.term.loadAddon(this.fitAddon); + } + }, + + initializeWebSocket() { + if (!this.socket || this.socket.readyState === WebSocket.CLOSED) { + const predefined = window.terminalConfig + const connectionString = { + protocol: window.location.protocol === 'https:' ? 'wss' : 'ws', + host: window.location.hostname, + port: ":6002", + path: '/terminal/ws' + } + if (!window.location.port) { + connectionString.port = '' + } + if (predefined.host) { + connectionString.host = predefined.host + } + if (predefined.port) { + connectionString.port = `:${predefined.port}` + } + if (predefined.protocol) { + connectionString.protocol = predefined.protocol + } + + const url = + `${connectionString.protocol}://${connectionString.host}${connectionString.port}${connectionString.path}` + this.socket = new WebSocket(url); + + this.socket.onmessage = this.handleSocketMessage.bind(this); + this.socket.onerror = (e) => { + console.error('WebSocket error:', e); + }; + this.socket.onclose = () => { + console.log('WebSocket connection closed'); + + }; + } + }, + + handleSocketMessage(event) { + this.message = '(connection closed)'; + if (event.data === 'pty-ready') { + if (!this.term._initialized) { + this.term.open(document.getElementById('terminal')); + this.term._initialized = true; + } else { + this.term.reset(); + } + this.terminalActive = true; + this.term.focus(); + document.querySelector('.xterm-viewport').classList.add('scrollbar', 'rounded'); + this.resizeTerminal(); + } else if (event.data === 'unprocessable') { + if (this.term) this.term.reset(); + this.terminalActive = false; + this.message = '(sorry, something went wrong, please try again)'; + } else { + this.pendingWrites++; + this.term.write(event.data, this.flowControlCallback.bind(this)); + } + }, + + flowControlCallback() { + this.pendingWrites--; + if (this.pendingWrites > this.MAX_PENDING_WRITES && !this.paused) { + this.paused = true; + this.socket.send(JSON.stringify({ pause: true })); + } else if (this.pendingWrites <= this.MAX_PENDING_WRITES && this.paused) { + this.paused = false; + this.socket.send(JSON.stringify({ resume: true })); + } + }, + + setupTerminalEventListeners() { + if (!this.term) return; + + this.term.onData((data) => { + this.socket.send(JSON.stringify({ message: data })); + // Handle CTRL + D or exit command + if (data === '\x04' || (data === '\r' && this.stripAnsiCommands(this.commandBuffer).trim().includes('exit'))) { + this.checkIfProcessIsRunningAndKillIt(); + setTimeout(() => { + this.terminalActive = false; + this.term.reset(); + }, 500); + this.commandBuffer = ''; + } else if (data === '\r') { + this.commandBuffer = ''; + } else { + this.commandBuffer += data; + } + }); + + // Copy and paste functionality + this.term.attachCustomKeyEventHandler((arg) => { + if (arg.ctrlKey && arg.code === "KeyV" && arg.type === "keydown") { + navigator.clipboard.readText() + .then(text => { + this.socket.send(JSON.stringify({ message: text })); + }); + return false; + } + + if (arg.ctrlKey && arg.code === "KeyC" && arg.type === "keydown") { + const selection = this.term.getSelection(); + if (selection) { + navigator.clipboard.writeText(selection); + return false; + } + } + return true; + }); + }, + + stripAnsiCommands(input) { + return input.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ''); + }, + + keepAlive() { + if (this.socket && this.socket.readyState === WebSocket.OPEN) { + this.socket.send(JSON.stringify({ ping: true })); + } + }, + + checkIfProcessIsRunningAndKillIt() { + if (this.socket && this.socket.readyState == WebSocket.OPEN) { + this.socket.send(JSON.stringify({ checkActive: 'force' })); + } + }, + + makeFullscreen() { + this.fullscreen = !this.fullscreen; + this.$nextTick(() => { + this.resizeTerminal(); + }); + }, + + resizeTerminal() { + if (!this.terminalActive || !this.term || !this.fitAddon) return; + + this.fitAddon.fit(); + const height = this.$refs.terminalWrapper.clientHeight; + const width = this.$refs.terminalWrapper.clientWidth; + const rows = Math.floor(height / this.term._core._renderService._charSizeService.height) - 1; + const cols = Math.floor(width / this.term._core._renderService._charSizeService.width) - 1; + const termWidth = cols; + const termHeight = rows; + this.term.resize(termWidth, termHeight); + this.socket.send(JSON.stringify({ + resize: { cols: termWidth, rows: termHeight } + })); + }, + }; + } + + window.Alpine.data('terminalData', terminalData); +} diff --git a/resources/views/components/forms/checkbox.blade.php b/resources/views/components/forms/checkbox.blade.php index f3e3d5c9e..439fc4ad2 100644 --- a/resources/views/components/forms/checkbox.blade.php +++ b/resources/views/components/forms/checkbox.blade.php @@ -1,16 +1,32 @@ -
- +@props([ + 'id', + 'label' => null, + 'helper' => null, + 'disabled' => false, + 'instantSave' => false, + 'value' => null, + 'hideLabel' => false, + 'fullWidth' => false, +]) + +
$fullWidth, +])> + @if (!$hideLabel) + + @endif merge(['class' => $defaultClass]) }} @if ($instantSave) wire:loading.attr="disabled" wire:click='{{ $instantSave === 'instantSave' || $instantSave == '1' ? 'instantSave' : $instantSave }}' diff --git a/resources/views/components/forms/input.blade.php b/resources/views/components/forms/input.blade.php index 04b4a41c6..fb206fac4 100644 --- a/resources/views/components/forms/input.blade.php +++ b/resources/views/components/forms/input.blade.php @@ -3,7 +3,7 @@ 'w-full' => !$isMultiline, ])> @if ($label) -