Merge branch 'next' into fix-dev-volumes

This commit is contained in:
Andras Bacsai 2024-09-27 17:13:02 +02:00 committed by GitHub
commit 691f606fa0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
218 changed files with 3790 additions and 2958 deletions

View file

@ -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

View file

@ -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 #

View file

@ -1,44 +0,0 @@
name: Docker Image CI
on:
# push:
# branches: [ "main" ]
# pull_request:
# branches: [ "*" ]
push:
branches: ["this-does-not-exist"]
pull_request:
branches: ["this-does-not-exist"]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Cache Docker layers
uses: actions/cache@v2
with:
path: |
/usr/local/share/ca-certificates
/var/cache/apt/archives
/var/lib/apt/lists
~/.cache
key: ${{ runner.os }}-docker-${{ hashFiles('**/Dockerfile') }}
restore-keys: |
${{ runner.os }}-docker-
- name: Build the Docker image
run: |
cp .env.example .env
docker run --rm -u "$(id -u):$(id -g)" \
-v "$(pwd):/app" \
-w /app composer:2 \
composer install --ignore-platform-reqs
./vendor/bin/spin build
- name: Start the stack
run: |
./vendor/bin/spin up -d
./vendor/bin/spin exec coolify php artisan key:generate
./vendor/bin/spin exec coolify php artisan migrate:fresh --seed
- name: Test (missing E2E tests)
run: |
./vendor/bin/spin exec coolify php artisan test

View file

@ -1,25 +0,0 @@
name: Fix PHP code style issues
on: [push]
permissions:
contents: write
jobs:
php-code-styling:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
- name: Fix PHP code style issues
uses: aglipanci/laravel-pint-action@2.4
- name: Commit changes
uses: stefanzweifel/git-auto-commit-action@v5
with:
commit_message: Fix styling

View file

@ -0,0 +1,17 @@
name: Lock closed Issues, Discussions, and PRs
on:
schedule:
- cron: '0 1 * * *'
jobs:
lock-threads:
runs-on: ubuntu-latest
steps:
- name: Lock threads after 30 days of inactivity
uses: dessant/lock-threads@v5
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
issue-inactive-days: '30'
pr-inactive-days: '30'
discussion-inactive-days: '30'

View file

@ -0,0 +1,28 @@
name: Manage Stale Issues and PRs
on:
schedule:
- cron: '0 2 * * *'
jobs:
manage-stale:
runs-on: ubuntu-latest
steps:
- name: Manage stale issues and PRs
uses: actions/stale@v9
id: stale
with:
stale-issue-message: 'This issue will be automatically closed in a few days if no response is received. Please provide an update with the requested information.'
stale-pr-message: 'This pull request will be automatically closed in a few days if no response is received. Please update your PR or comment if you would like to continue working on it.'
close-issue-message: 'This issue has been automatically closed due to inactivity.'
close-pr-message: 'This pull request has been automatically closed due to inactivity.'
days-before-stale: 14
days-before-close: 7
stale-issue-label: '⏱︎ Stale'
stale-pr-label: '⏱︎ Stale'
only-labels: '💤 Waiting for feedback'
remove-stale-when-updated: true
operations-per-run: 100
labels-to-remove-when-unstale: '⏱︎ Stale, 💤 Waiting for feedback'
close-issue-reason: 'not_planned'
exempt-all-milestones: false

View file

@ -1,93 +0,0 @@
name: PR Build (v4)
on:
pull_request:
types:
- opened
branches-ignore: ["main", "v3"]
paths-ignore:
- .github/workflows/coolify-helper.yml
- docker/coolify-helper/Dockerfile
env:
REGISTRY: ghcr.io
IMAGE_NAME: "coollabsio/coolify"
jobs:
amd64:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
attestations: write
id-token: write
actions: write
steps:
- uses: actions/checkout@v4
- name: Login to ghcr.io
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build image and push to registry
uses: docker/build-push-action@v5
with:
context: .
file: docker/prod/Dockerfile
platforms: linux/amd64
push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.event.number }}
aarch64:
runs-on: [self-hosted, arm64]
permissions:
contents: read
packages: write
attestations: write
id-token: write
actions: write
steps:
- uses: actions/checkout@v4
- name: Login to ghcr.io
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build image and push to registry
uses: docker/build-push-action@v5
with:
context: .
file: docker/prod/Dockerfile
platforms: linux/aarch64
push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.event.number }}-aarch64
merge-manifest:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
attestations: write
id-token: write
actions: write
needs: [amd64, aarch64]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to ghcr.io
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create & publish manifest
run: |
docker buildx imagetools create --append ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.event.number }}-aarch64 --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.event.number }}
- uses: sarisia/actions-status-discord@v1
if: always()
with:
webhook: ${{ secrets.DISCORD_WEBHOOK_DEV_RELEASE_CHANNEL }}

View file

@ -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);
}
}
}
}

View file

@ -2,35 +2,120 @@ # Coolify Release Guide
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
<details>
<summary><strong>Stable (coming soon)</strong></summary>
- **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
```
</details>
<details>
<summary><strong>Nightly</strong></summary>
- **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
```
</details>
<details>
<summary><strong>Beta</strong></summary>
- **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
```
</details>
> [!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 @@ ## Manually Update to Specific Versions
```bash
curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash -s <version>
```
-> Replace `<version>` with the version you want to update to (for example `4.0.0-beta.332`).
Replace `<version>` with the version you want to update to (for example `4.0.0-beta.332`).

View file

@ -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();
}
}
}

View file

@ -4,6 +4,7 @@
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 @@ protected function getCommand(): string
$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)

View file

@ -23,7 +23,7 @@ public function handle(StandaloneDragonfly $database)
$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 @@ public function handle(StandaloneDragonfly $database)
],
],
];
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 @@ private function generate_local_persistent_volumes()
$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 @@ private function generate_environment_variables()
$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}");
}

View file

@ -24,7 +24,7 @@ public function handle(StandaloneKeydb $database)
$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 @@ public function handle(StandaloneKeydb $database)
],
],
];
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 @@ public function handle(StandaloneKeydb $database)
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 @@ private function generate_local_persistent_volumes()
$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 @@ private function generate_environment_variables()
$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}");
}

View file

@ -21,7 +21,7 @@ public function handle(StandaloneMariadb $database)
$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 @@ public function handle(StandaloneMariadb $database)
],
],
];
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 @@ public function handle(StandaloneMariadb $database)
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 @@ private function generate_local_persistent_volumes()
$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 @@ private function generate_environment_variables()
$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}");
}

View file

@ -23,7 +23,7 @@ public function handle(StandaloneMongodb $database)
$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 @@ public function handle(StandaloneMongodb $database)
],
],
];
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 @@ public function handle(StandaloneMongodb $database)
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 @@ private function generate_local_persistent_volumes()
$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 @@ private function generate_environment_variables()
$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}");
}

View file

@ -21,7 +21,7 @@ public function handle(StandaloneMysql $database)
$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 @@ public function handle(StandaloneMysql $database)
],
],
];
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 @@ public function handle(StandaloneMysql $database)
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 @@ private function generate_local_persistent_volumes()
$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 @@ private function generate_environment_variables()
$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}");
}

View file

@ -37,7 +37,6 @@ public function handle(StandalonePostgresql $database)
$this->generate_init_scripts();
$this->add_custom_conf();
$docker_compose = [
'services' => [
$container_name => [

View file

@ -24,7 +24,7 @@ public function handle(StandaloneRedis $database)
$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 @@ public function handle(StandaloneRedis $database)
],
],
];
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 @@ public function handle(StandaloneRedis $database)
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 @@ private function generate_local_persistent_volumes()
$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 @@ private function generate_environment_variables()
$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}");
}

View file

@ -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\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);
}
}

View file

@ -543,7 +543,7 @@ private function old_way()
}
}
}
$exitedServices = $exitedServices->unique('id');
$exitedServices = $exitedServices->unique('uuid');
foreach ($exitedServices as $exitedService) {
if (str($exitedService->status)->startsWith('exited')) {
continue;
@ -651,8 +651,9 @@ private function old_way()
// $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';

View file

@ -26,7 +26,7 @@ public function handle(Server $server, $fromUI = false)
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);
}

View file

@ -35,7 +35,7 @@ public function handle(Server $server, bool $async = true, bool $force = false):
"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 @@ public function handle(Server $server, bool $async = true, bool $force = false):
"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));
}

View file

@ -12,28 +12,29 @@ class CleanupDocker
public function handle(Server $server)
{
$settings = InstanceSettings::get();
$helperImageVersion = data_get($settings, 'helper_version');
$helperImage = config('coolify.helper_image');
$helperImageWithVersion = "$helperImage:$helperImageVersion";
$commands = $this->getCommands();
$commands = [
'docker container prune -f --filter "label=coolify.managed=true"',
'docker image prune -af --filter "label!=coolify.managed=true"',
'docker builder prune -af',
"docker images --filter before=$helperImageWithVersion --filter reference=$helperImage | grep $helperImage | awk '{print $3}' | xargs -r docker rmi -f",
];
$serverSettings = $server->settings;
if ($serverSettings->delete_unused_volumes) {
$commands[] = 'docker volume prune -af';
}
if ($serverSettings->delete_unused_networks) {
$commands[] = 'docker network prune -f';
}
foreach ($commands as $command) {
instant_remote_process([$command], $server, false);
}
}
private function getCommands(): array
{
$settings = InstanceSettings::get();
$helperImageVersion = data_get($settings, 'helper_version');
$helperImage = config('coolify.helper_image');
$helperImageWithVersion = config('coolify.helper_image').':'.$helperImageVersion;
$commonCommands = [
'docker container prune -f --filter "label=coolify.managed=true"',
'docker image prune -af --filter "label!=coolify.managed=true"',
'docker builder prune -af',
"docker images --filter before=$helperImageWithVersion --filter reference=$helperImage | grep $helperImage | awk '{print $3}' | xargs -r docker rmi",
];
return $commonCommands;
}
}

View file

@ -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 @@ public function handle(Server $server, string $cloudflare_token)
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);
}
}
}

View file

@ -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 @@ public function handle(Service $service)
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 @@ public function handle(Service $service)
$task->delete();
}
$service->tags()->detach();
$service->forceDelete();
if ($dockerCleanup) {
CleanupDocker::dispatch($server, true);
}
}
}
}

View file

@ -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();
}
}
}

View file

@ -1,24 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Redis;
class CleanupQueue extends Command
{
protected $signature = 'cleanup:queue';
protected $description = 'Cleanup Queue';
public function handle()
{
echo "Running queue cleanup...\n";
$prefix = config('database.redis.options.prefix');
$keys = Redis::connection()->keys('*:laravel*');
foreach ($keys as $key) {
$keyWithoutPrefix = str_replace($prefix, '', $key);
Redis::connection()->del($keyWithoutPrefix);
}
}
}

View file

@ -0,0 +1,31 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Redis;
class CleanupRedis extends Command
{
protected $signature = 'cleanup:redis';
protected $description = 'Cleanup Redis';
public function handle()
{
echo "Cleanup Redis keys.\n";
$prefix = config('database.redis.options.prefix');
$keys = Redis::connection()->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);
});
}
}

View file

@ -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 @@ public function handle()
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) {

View file

@ -5,7 +5,6 @@
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 @@
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 @@ public function handle()
}
}
}
$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 @@ private function update_traefik_labels()
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 @@ private function cleanup_unused_network_from_coolify_proxy()
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 @@ private function alive()
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 @@ private function cleanup_in_progress_application_deployments()
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();
}
}
}
}

View file

@ -43,6 +43,8 @@ protected function schedule(Schedule $schedule): void
$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 @@ private function pull_images($schedule)
}
})->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)

View file

@ -0,0 +1,14 @@
<?php
namespace App\Enums;
enum ContainerStatusTypes: string
{
case PAUSED = 'paused';
case RESTARTING = 'restarting';
case REMOVING = 'removing';
case RUNNING = 'running';
case DEAD = 'dead';
case CREATED = 'created';
case EXITED = 'exited';
}

View file

@ -0,0 +1,34 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class CloudflareTunnelConfigured implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public $teamId;
public function __construct($teamId = null)
{
if (is_null($teamId)) {
$teamId = auth()->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}"),
];
}
}

View file

@ -0,0 +1,184 @@
<?php
namespace App\Helpers;
use App\Models\PrivateKey;
use App\Models\Server;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Process;
class SshMultiplexingHelper
{
public static function serverSshConfiguration(Server $server)
{
$privateKey = PrivateKey::findOrFail($server->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;
}
}

View file

@ -2529,6 +2529,131 @@ public function action_restart(Request $request)
}
#[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();

View file

@ -27,6 +27,7 @@
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 @@ public function __construct(int $application_deployment_queue_id)
}
ray('New container name: ', $this->container_name)->green();
savePrivateKeyToFs($this->server);
$this->saved_outputs = collect();
// Set preview fqdn
@ -1456,10 +1456,10 @@ private function check_git_if_build_needed()
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 @@ private function build_image()
$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]
);

View file

@ -9,8 +9,8 @@
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
{

View file

@ -21,11 +21,10 @@ public function handle(): void
{
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);
}

View file

@ -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);
}
}

View file

@ -9,7 +9,6 @@
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 @@ public function backoff(): int
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);

View file

@ -23,7 +23,6 @@
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 @@ public function __construct($backup)
}
}
public function middleware(): array
{
return [new WithoutOverlapping($this->backup->id)];
}
public function uniqueId(): int
{
return $this->backup->id;
}
public function handle(): void
{
try {

View file

@ -1,62 +0,0 @@
<?php
namespace App\Jobs;
use App\Models\ScheduledDatabaseBackup;
use App\Models\Team;
use App\Notifications\Database\DailyBackup;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class DatabaseBackupStatusJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 1;
public function __construct() {}
public function handle()
{
// $teams = Team::all();
// foreach ($teams as $team) {
// $scheduled_backups = $team->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));
// }
}
}

View file

@ -4,6 +4,7 @@
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 @@ public function handle()
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 @@ public function handle()
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');
}
}

View file

@ -10,7 +10,6 @@
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;
@ -24,17 +23,7 @@ class DockerCleanupJob implements ShouldBeEncrypted, ShouldQueue
public ?string $usageBefore = null;
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 __construct(public Server $server, public bool $manualCleanup = false) {}
public function handle(): void
{
@ -42,8 +31,9 @@ public function handle(): void
if (! $this->server->isFunctional()) {
return;
}
if ($this->server->settings->force_docker_cleanup) {
Log::info('DockerCleanupJob force cleanup on '.$this->server->name);
if ($this->manualCleanup || $this->server->settings->force_docker_cleanup) {
Log::info('DockerCleanupJob '.($this->manualCleanup ? 'manual' : 'force').' cleanup on '.$this->server->name);
CleanupDocker::run(server: $this->server);
return;

View file

@ -8,7 +8,6 @@
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 @@ public function backoff(): int
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 {

View file

@ -9,7 +9,6 @@
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
{

View file

@ -9,7 +9,6 @@
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

View file

@ -13,7 +13,6 @@
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 @@ private function getServerTimezone(): string
{
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 @@ public function handle(): void
} 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 @@ public function handle(): void
}
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([

View file

@ -16,7 +16,6 @@
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 @@ public function backoff(): int
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 @@ public function handle()
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 @@ public function handle()
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 @@ private function serverStatus()
private function checkLogDrainContainer()
{
if (! $this->server->isLogDrainEnabled()) {
return;
}
$foundLogDrainContainer = $this->containers->filter(function ($value, $key) {
return data_get($value, 'Name') === '/coolify-log-drain';
})->first();

View file

@ -10,7 +10,6 @@
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
class ServerLimitCheckJob implements ShouldBeEncrypted, ShouldQueue
@ -26,16 +25,6 @@ public function backoff(): int
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 {

View file

@ -8,7 +8,6 @@
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 @@ public function backoff(): int
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)) {

View file

@ -141,7 +141,7 @@ public function setServerType(string $type)
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 @@ public function selectExistingServer()
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 @@ public function setPrivateKey(string $type)
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()

View file

@ -30,7 +30,6 @@ public function mount()
public function cleanup_queue()
{
$this->dispatch('success', 'Cleanup started.');
Artisan::queue('cleanup:application-deployment-queue', [
'--team-id' => currentTeam()->id,
]);

View file

@ -38,7 +38,7 @@ public function delete()
}
$this->destination->delete();
return redirect()->route('dashboard');
return redirect()->route('destination.all');
} catch (\Throwable $e) {
return handleError($e, $this);
}

View file

@ -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();

View file

@ -4,7 +4,6 @@
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use Illuminate\Support\Collection;
use Livewire\Component;
class Show extends Component

View file

@ -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 @@ protected function setDeploymentUuid()
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 @@ public function restart()
'environment_name' => $this->parameters['environment_name'],
]);
}
public function render()
{
return view('livewire.project.application.heading', [
'checkboxes' => [
['id' => 'docker_cleanup', 'label' => __('resource.docker_cleanup')],
],
]);
}
}

View file

@ -5,7 +5,9 @@
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 @@ protected function setDeploymentUuid()
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 @@ public function stop(int $pull_request_id)
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 @@ public function delete(int $pull_request_id)
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);
}
}
}

View file

@ -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 @@ public function mount()
}
}
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 @@ public function submit()
$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.']
],
]);
}
}

View file

@ -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 @@ public function cleanupFailed()
}
}
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 @@ public function server()
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 @@ public function formatDateInServerTimezone($date)
} 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'],
],
]);
}
}

View file

@ -56,7 +56,7 @@ public function mount()
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 @@ public function instantSaveAdvanced()
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 @@ public function instantSave()
$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);
}

View file

@ -54,7 +54,7 @@ public function mount()
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 @@ public function submit()
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 @@ public function instantSave()
$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);
}

View file

@ -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 @@ public function mount()
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 @@ public function start()
$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).'],
],
]);
}
}

View file

@ -57,7 +57,7 @@ public function mount()
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 @@ public function submit()
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 @@ public function instantSave()
$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);
}

View file

@ -63,7 +63,7 @@ public function mount()
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 @@ public function submit()
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 @@ public function instantSave()
$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);
}

View file

@ -61,7 +61,7 @@ public function mount()
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 @@ public function submit()
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 @@ public function instantSave()
$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);
}

View file

@ -62,7 +62,7 @@ public function mount()
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 @@ public function submit()
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 @@ public function instantSave()
$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);
}

View file

@ -57,7 +57,7 @@ public function mount()
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 @@ public function submit()
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 @@ public function instantSave()
$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);
}

View file

@ -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()

View file

@ -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()

View file

@ -52,7 +52,7 @@ public function restartApplication($id)
$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 @@ public function restartDatabase($id)
$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);

View file

@ -14,6 +14,8 @@
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 @@ public function convertToFile()
}
}
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 @@ public function instantSave()
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.'],
],
]);
}
}

View file

@ -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 @@ public function getListeners()
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 @@ public function check_status()
$this->dispatch('success', 'Service status updated.');
}
public function render()
{
return view('livewire.project.service.navbar');
}
public function checkDeployments()
{
try {
@ -97,14 +94,9 @@ public function start()
$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 @@ public function restart()
$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')],
],
]);
}
}

View file

@ -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 @@ public function instantSaveAdvanced()
$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 @@ public function submit()
$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.']
],
]);
}
}

View file

@ -33,7 +33,7 @@ public function mount()
$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 @@ public function mount()
$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)

View file

@ -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 @@ public function delete()
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.']
],
]);
}
}

View file

@ -8,6 +8,8 @@
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 @@ public function addServer(int $network_id, int $server_id)
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.');

View file

@ -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 @@ public function getLogs($refresh = false)
$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 @@ public function getLogs($refresh = false)
$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) {

View file

@ -7,7 +7,9 @@
class Executions extends Component
{
public $executions = [];
public $selectedKey;
public $task;
public function getListeners()
@ -29,7 +31,7 @@ public function selectTask($key): void
public function server()
{
if (!$this->task) {
if (! $this->task) {
return null;
}
@ -42,16 +44,18 @@ public function server()
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 @@ public function formatDateInServerTimezone($date)
} catch (\Exception $e) {
$dateObj->setTimezone(new \DateTimeZone('UTC'));
}
return $dateObj->format('Y-m-d H:i:s T');
}
}

View file

@ -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 @@ public function mount()
$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 @@ public function delete()
$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);

View file

@ -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 @@ public function submit()
$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');
}

View file

@ -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 @@ public function sendTerminalCommand($isContainer, $identifier, $serverUuid)
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

View file

@ -2,6 +2,7 @@
namespace App\Livewire\Security;
use App\Models\InstanceSettings;
use Livewire\Component;
class ApiTokens extends Component
@ -16,6 +17,8 @@ class ApiTokens extends Component
public array $permissions = ['read-only'];
public $isApiEnabled;
public function render()
{
return view('livewire.security.api-tokens');
@ -23,6 +26,7 @@ public function render()
public function mount()
{
$this->isApiEnabled = InstanceSettings::get()->is_api_enabled;
$this->tokens = auth()->user()->tokens->sortByDesc('created_at');
}

View file

@ -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]);
}
}

View file

@ -0,0 +1,24 @@
<?php
namespace App\Livewire\Security\PrivateKey;
use App\Models\PrivateKey;
use Livewire\Component;
class Index extends Component
{
public function render()
{
$privateKeys = PrivateKey::ownedByCurrentTeam(['name', 'uuid', 'is_git_related', 'description'])->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.');
}
}

View file

@ -29,25 +29,27 @@ public function mount()
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 @@ public function delete()
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) {

View file

@ -31,13 +31,12 @@ public function submit()
{
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);
}

View file

@ -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()) {

View file

@ -4,6 +4,7 @@
use App\Actions\Server\StartSentinel;
use App\Actions\Server\StopSentinel;
use App\Jobs\DockerCleanupJob;
use App\Jobs\PullSentinelImageJob;
use App\Models\Server;
use Livewire\Component;
@ -24,11 +25,20 @@ class Form extends Component
public $timezones;
protected $listeners = [
'serverInstalled',
'refreshServerShow' => 'serverInstalled',
'revalidate' => '$refresh',
];
public $delete_unused_volumes = false;
public $delete_unused_networks = false;
public function getListeners()
{
$teamId = auth()->user()->currentTeam()->id;
return [
"echo-private:team.{$teamId},CloudflareTunnelConfigured" => 'cloudflareTunnelConfigured',
'refreshServerShow' => 'serverInstalled',
'revalidate' => '$refresh',
];
}
protected $rules = [
'server.name' => 'required',
@ -53,6 +63,8 @@ class Form extends Component
'server.settings.force_docker_cleanup' => 'required|boolean',
'server.settings.docker_cleanup_frequency' => 'required_if:server.settings.force_docker_cleanup,true|string',
'server.settings.docker_cleanup_threshold' => 'required_if:server.settings.force_docker_cleanup,false|integer|min:1|max:100',
'server.settings.delete_unused_volumes' => 'boolean',
'server.settings.delete_unused_networks' => 'boolean',
];
protected $validationAttributes = [
@ -74,6 +86,8 @@ class Form extends Component
'server.settings.metrics_history_days' => 'Metrics History',
'server.settings.is_server_api_enabled' => 'Server API',
'server.settings.server_timezone' => 'Server Timezone',
'server.settings.delete_unused_volumes' => 'Delete Unused Volumes',
'server.settings.delete_unused_networks' => 'Delete Unused Networks',
];
public function mount(Server $server)
@ -83,6 +97,8 @@ public function mount(Server $server)
$this->wildcard_domain = $this->server->settings->wildcard_domain;
$this->server->settings->docker_cleanup_threshold = $this->server->settings->docker_cleanup_threshold;
$this->server->settings->docker_cleanup_frequency = $this->server->settings->docker_cleanup_frequency;
$this->server->settings->delete_unused_volumes = $server->settings->delete_unused_volumes;
$this->server->settings->delete_unused_networks = $server->settings->delete_unused_networks;
}
public function updated($field)
@ -96,6 +112,12 @@ public function updated($field)
}
}
public function cloudflareTunnelConfigured()
{
$this->serverInstalled();
$this->dispatch('success', 'Cloudflare Tunnels configured successfully.');
}
public function serverInstalled()
{
$this->server->refresh();
@ -126,6 +148,7 @@ public function instantSave()
try {
refresh_server_connection($this->server->privateKey);
$this->validateServer(false);
$this->server->settings->save();
$this->server->save();
$this->dispatch('success', 'Server updated.');
@ -143,6 +166,7 @@ public function instantSave()
ray('Sentinel is not enabled');
StopSentinel::dispatch($this->server);
}
$this->server->settings->save();
// $this->checkPortForServerApi();
} catch (\Throwable $e) {
@ -223,9 +247,9 @@ public function submit()
$this->server->settings->server_timezone = $newTimezone;
$this->server->settings->save();
}
$this->server->settings->save();
$this->server->save();
$this->dispatch('success', 'Server updated.');
} catch (\Throwable $e) {
return handleError($e, $this);
@ -238,4 +262,22 @@ public function updatedServerSettingsServerTimezone($value)
$this->server->settings->save();
$this->dispatch('success', 'Server timezone updated.');
}
public function manualCleanup()
{
try {
DockerCleanupJob::dispatch($this->server, true);
$this->dispatch('success', 'Manual cleanup job started. Depending on the amount of data, this might take a while.');
} catch (\Throwable $e) {
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.');
}
}

View file

@ -39,6 +39,7 @@ public function changeProxy()
{
$this->server->proxy = null;
$this->server->save();
$this->dispatch('proxyChanged');
}
public function selectProxy($proxy_type)
@ -47,7 +48,7 @@ public function selectProxy($proxy_type)
$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');

View file

@ -6,6 +6,8 @@
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 @@ public function getListeners()
'serverRefresh' => 'proxyStatusUpdated',
'checkProxy',
'startProxy',
'proxyChanged' => 'proxyStatusUpdated',
];
}
@ -94,21 +97,43 @@ public function startProxy()
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);
}
}

View file

@ -11,7 +11,7 @@ class Show extends Component
public $parameters = [];
protected $listeners = ['proxyStatusUpdated'];
protected $listeners = ['proxyStatusUpdated', 'proxyChanged' => 'proxyStatusUpdated'];
public function proxyStatusUpdated()
{

View file

@ -49,6 +49,10 @@ public function checkProxy(bool $notification = false)
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.');
}

View file

@ -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 @@ public function checkConnection()
$this->dispatch('success', 'Server is reachable.');
} else {
ray($error);
$this->dispatch('error', 'Server is not reachable.<br>Please validate your configuration and connection.<br><br>Check this <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/openssh">documentation</a> for further help.');
$this->dispatch('error', 'Server is not reachable.<br><br>Check this <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/openssh">documentation</a> for further help.<br><br>Error: '.$error);
return;
}

View file

@ -4,6 +4,8 @@
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 @@ private function finalizeDeletion(User $user, Team $team)
$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');
}

View file

@ -6,7 +6,9 @@
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 @@ public static function ownedByCurrentTeamAPI(int $teamId)
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 @@ public function delete_volumes(?Collection $persistentStorages)
}
}
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 @@ public function oldRawParser()
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 @@ public function loadComposeFile($isInit = false)
} else {
throw new \RuntimeException("Docker Compose file not found at: $workdir$composeFile<br><br>Check if you used the right extension (.yaml or .yml) in the compose file name.");
}
}
public function parseContainerLabels(?ApplicationPreview $preview = null)

View file

@ -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 @@
)]
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 @@ public function gitlabApps()
{
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();
});
}
}

View file

@ -35,14 +35,17 @@ public function get_last_days_backup_status($days = 7)
{
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;
}
}

Some files were not shown because too many files have changed in this diff Show more