mirror of
https://github.com/tiennm99/coolify.git
synced 2026-04-22 22:19:30 +00:00
Merge branch 'next' into v4.x
This commit is contained in:
@@ -283,14 +283,22 @@ class EnvironmentVariable extends Model
|
|||||||
|
|
||||||
### **Team-Based Soft Scoping**
|
### **Team-Based Soft Scoping**
|
||||||
|
|
||||||
All major resources include team-based query scoping:
|
All major resources include team-based query scoping with request-level caching:
|
||||||
|
|
||||||
```php
|
```php
|
||||||
// Automatic team filtering
|
// ✅ CORRECT - Use cached methods (request-level cache via once())
|
||||||
$applications = Application::ownedByCurrentTeam()->get();
|
$applications = Application::ownedByCurrentTeamCached();
|
||||||
$servers = Server::ownedByCurrentTeam()->get();
|
$servers = Server::ownedByCurrentTeamCached();
|
||||||
|
|
||||||
|
// ✅ CORRECT - Filter cached collection in memory
|
||||||
|
$activeServers = Server::ownedByCurrentTeamCached()->where('is_active', true);
|
||||||
|
|
||||||
|
// Only use query builder when you need eager loading or fresh data
|
||||||
|
$projects = Project::ownedByCurrentTeam()->with('environments')->get();
|
||||||
```
|
```
|
||||||
|
|
||||||
|
See [Database Patterns](.ai/patterns/database-patterns.md#request-level-caching-with-ownedbycurrentteamcached) for full documentation.
|
||||||
|
|
||||||
### **Configuration Inheritance**
|
### **Configuration Inheritance**
|
||||||
|
|
||||||
Environment variables cascade from:
|
Environment variables cascade from:
|
||||||
|
|||||||
@@ -270,6 +270,84 @@ Routes: [routes/api.php](mdc:routes/api.php)
|
|||||||
- **Build artifact** reuse
|
- **Build artifact** reuse
|
||||||
- **Parallel build** processing
|
- **Parallel build** processing
|
||||||
|
|
||||||
|
### Docker Build Cache Preservation
|
||||||
|
|
||||||
|
Coolify provides settings to preserve Docker build cache across deployments, addressing cache invalidation issues.
|
||||||
|
|
||||||
|
#### The Problem
|
||||||
|
|
||||||
|
By default, Coolify injects `ARG` statements into user Dockerfiles for build-time variables. This breaks Docker's cache mechanism because:
|
||||||
|
1. **ARG declarations invalidate cache** - Any change in ARG values after the `ARG` instruction invalidates all subsequent layers
|
||||||
|
2. **SOURCE_COMMIT changes every commit** - Causes full rebuilds even when code changes are minimal
|
||||||
|
|
||||||
|
#### Application Settings
|
||||||
|
|
||||||
|
Two toggles in **Advanced Settings** control this behavior:
|
||||||
|
|
||||||
|
| Setting | Default | Description |
|
||||||
|
|---------|---------|-------------|
|
||||||
|
| `inject_build_args_to_dockerfile` | `true` | Controls whether Coolify adds `ARG` statements to Dockerfile |
|
||||||
|
| `include_source_commit_in_build` | `false` | Controls whether `SOURCE_COMMIT` is included in build context |
|
||||||
|
|
||||||
|
**Database columns:** `application_settings.inject_build_args_to_dockerfile`, `application_settings.include_source_commit_in_build`
|
||||||
|
|
||||||
|
#### Buildpack Coverage
|
||||||
|
|
||||||
|
| Build Pack | ARG Injection | Method |
|
||||||
|
|------------|---------------|--------|
|
||||||
|
| **Dockerfile** | ✅ Yes | `add_build_env_variables_to_dockerfile()` |
|
||||||
|
| **Docker Compose** (with `build:`) | ✅ Yes | `modify_dockerfiles_for_compose()` |
|
||||||
|
| **PR Deployments** (Dockerfile only) | ✅ Yes | `add_build_env_variables_to_dockerfile()` |
|
||||||
|
| **Nixpacks** | ❌ No | Generates its own Dockerfile internally |
|
||||||
|
| **Static** | ❌ No | Uses internal Dockerfile |
|
||||||
|
| **Docker Image** | ❌ No | No build phase |
|
||||||
|
|
||||||
|
#### How It Works
|
||||||
|
|
||||||
|
**When `inject_build_args_to_dockerfile` is enabled (default):**
|
||||||
|
```dockerfile
|
||||||
|
# Coolify modifies your Dockerfile to add:
|
||||||
|
FROM node:20
|
||||||
|
ARG MY_VAR=value
|
||||||
|
ARG COOLIFY_URL=...
|
||||||
|
ARG SOURCE_COMMIT=abc123 # (if include_source_commit_in_build is true)
|
||||||
|
# ... rest of your Dockerfile
|
||||||
|
```
|
||||||
|
|
||||||
|
**When `inject_build_args_to_dockerfile` is disabled:**
|
||||||
|
- Coolify does NOT modify the Dockerfile
|
||||||
|
- `--build-arg` flags are still passed (harmless without matching `ARG` in Dockerfile)
|
||||||
|
- User must manually add `ARG` statements for any build-time variables they need
|
||||||
|
|
||||||
|
**When `include_source_commit_in_build` is disabled (default):**
|
||||||
|
- `SOURCE_COMMIT` is NOT included in build-time variables
|
||||||
|
- `SOURCE_COMMIT` is still available at **runtime** (in container environment)
|
||||||
|
- Docker cache preserved across different commits
|
||||||
|
|
||||||
|
#### Recommended Configuration
|
||||||
|
|
||||||
|
| Use Case | inject_build_args | include_source_commit | Cache Behavior |
|
||||||
|
|----------|-------------------|----------------------|----------------|
|
||||||
|
| Maximum cache preservation | `false` | `false` | Best cache retention |
|
||||||
|
| Need build-time vars, no commit | `true` | `false` | Cache breaks on var changes |
|
||||||
|
| Need commit at build-time | `true` | `true` | Cache breaks every commit |
|
||||||
|
| Manual ARG management | `false` | `true` | Cache preserved (no ARG in Dockerfile) |
|
||||||
|
|
||||||
|
#### Implementation Details
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `app/Jobs/ApplicationDeploymentJob.php`:
|
||||||
|
- `set_coolify_variables()` - Conditionally adds SOURCE_COMMIT to Docker build context based on `include_source_commit_in_build` setting
|
||||||
|
- `generate_coolify_env_variables(bool $forBuildTime)` - Distinguishes build-time vs. runtime variables; excludes cache-busting variables like SOURCE_COMMIT from build context unless explicitly enabled
|
||||||
|
- `generate_env_variables()` - Populates `$this->env_args` with build-time ARG values, respecting `include_source_commit_in_build` toggle
|
||||||
|
- `add_build_env_variables_to_dockerfile()` - Injects ARG statements into Dockerfiles after FROM instructions; skips injection if `inject_build_args_to_dockerfile` is disabled
|
||||||
|
- `modify_dockerfiles_for_compose()` - Applies ARG injection to Docker Compose service Dockerfiles; respects `inject_build_args_to_dockerfile` toggle
|
||||||
|
- `app/Models/ApplicationSetting.php` - Defines `inject_build_args_to_dockerfile` and `include_source_commit_in_build` boolean properties
|
||||||
|
- `app/Livewire/Project/Application/Advanced.php` - Livewire component providing UI bindings for cache preservation toggles
|
||||||
|
- `resources/views/livewire/project/application/advanced.blade.php` - Checkbox UI elements for user-facing toggles
|
||||||
|
|
||||||
|
**Note:** Docker Compose services without a `build:` section (image-only) are automatically skipped.
|
||||||
|
|
||||||
### Runtime Optimization
|
### Runtime Optimization
|
||||||
- **Container resource** limits
|
- **Container resource** limits
|
||||||
- **Auto-scaling** based on metrics
|
- **Auto-scaling** based on metrics
|
||||||
@@ -428,7 +506,7 @@ services:
|
|||||||
- `templates/compose/chaskiq.yaml` - Entrypoint script
|
- `templates/compose/chaskiq.yaml` - Entrypoint script
|
||||||
|
|
||||||
**Implementation:**
|
**Implementation:**
|
||||||
- Parsed: `bootstrap/helpers/parsers.php` (line 717)
|
- Parsed: `bootstrap/helpers/parsers.php` in `parseCompose()` function (handles `content` field extraction)
|
||||||
- Storage: `app/Models/LocalFileVolume.php`
|
- Storage: `app/Models/LocalFileVolume.php`
|
||||||
- Validation: `tests/Unit/StripCoolifyCustomFieldsTest.php`
|
- Validation: `tests/Unit/StripCoolifyCustomFieldsTest.php`
|
||||||
|
|
||||||
@@ -481,7 +559,7 @@ services:
|
|||||||
- Pre-creating mount points before container starts
|
- Pre-creating mount points before container starts
|
||||||
|
|
||||||
**Implementation:**
|
**Implementation:**
|
||||||
- Parsed: `bootstrap/helpers/parsers.php` (line 718)
|
- Parsed: `bootstrap/helpers/parsers.php` in `parseCompose()` function (handles `is_directory`/`isDirectory` field extraction)
|
||||||
- Storage: `app/Models/LocalFileVolume.php` (`is_directory` column)
|
- Storage: `app/Models/LocalFileVolume.php` (`is_directory` column)
|
||||||
- Validation: `tests/Unit/StripCoolifyCustomFieldsTest.php`
|
- Validation: `tests/Unit/StripCoolifyCustomFieldsTest.php`
|
||||||
|
|
||||||
|
|||||||
@@ -243,6 +243,59 @@ Server::chunk(100, function ($servers) {
|
|||||||
- **Composite indexes** for common queries
|
- **Composite indexes** for common queries
|
||||||
- **Unique constraints** for business rules
|
- **Unique constraints** for business rules
|
||||||
|
|
||||||
|
### Request-Level Caching with ownedByCurrentTeamCached()
|
||||||
|
|
||||||
|
Many models have both `ownedByCurrentTeam()` (returns query builder) and `ownedByCurrentTeamCached()` (returns cached collection). **Always prefer the cached version** to avoid duplicate database queries within the same request.
|
||||||
|
|
||||||
|
**Models with cached methods available:**
|
||||||
|
- `Server`, `PrivateKey`, `Project`
|
||||||
|
- `Application`
|
||||||
|
- `StandalonePostgresql`, `StandaloneMysql`, `StandaloneRedis`, `StandaloneMariadb`, `StandaloneMongodb`, `StandaloneKeydb`, `StandaloneDragonfly`, `StandaloneClickhouse`
|
||||||
|
- `Service`, `ServiceApplication`, `ServiceDatabase`
|
||||||
|
|
||||||
|
**Usage patterns:**
|
||||||
|
```php
|
||||||
|
// ✅ CORRECT - Uses request-level cache (via Laravel's once() helper)
|
||||||
|
$servers = Server::ownedByCurrentTeamCached();
|
||||||
|
|
||||||
|
// ❌ AVOID - Makes a new database query each time
|
||||||
|
$servers = Server::ownedByCurrentTeam()->get();
|
||||||
|
|
||||||
|
// ✅ CORRECT - Filter cached collection in memory
|
||||||
|
$activeServers = Server::ownedByCurrentTeamCached()->where('is_active', true);
|
||||||
|
$server = Server::ownedByCurrentTeamCached()->firstWhere('id', $serverId);
|
||||||
|
$serverIds = Server::ownedByCurrentTeamCached()->pluck('id');
|
||||||
|
|
||||||
|
// ❌ AVOID - Making filtered database queries when data is already cached
|
||||||
|
$activeServers = Server::ownedByCurrentTeam()->where('is_active', true)->get();
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to use which:**
|
||||||
|
- `ownedByCurrentTeamCached()` - **Default choice** for reading team data
|
||||||
|
- `ownedByCurrentTeam()` - Only when you need to chain query builder methods that can't be done on collections (like `with()` for eager loading), or when you explicitly need a fresh database query
|
||||||
|
|
||||||
|
**Implementation pattern for new models:**
|
||||||
|
```php
|
||||||
|
/**
|
||||||
|
* Get query builder for resources owned by current team.
|
||||||
|
* If you need all resources without further query chaining, use ownedByCurrentTeamCached() instead.
|
||||||
|
*/
|
||||||
|
public static function ownedByCurrentTeam()
|
||||||
|
{
|
||||||
|
return self::whereTeamId(currentTeam()->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all resources owned by current team (cached for request duration).
|
||||||
|
*/
|
||||||
|
public static function ownedByCurrentTeamCached()
|
||||||
|
{
|
||||||
|
return once(function () {
|
||||||
|
return self::ownedByCurrentTeam()->get();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Data Consistency Patterns
|
## Data Consistency Patterns
|
||||||
|
|
||||||
### Database Transactions
|
### Database Transactions
|
||||||
|
|||||||
8
.github/workflows/coolify-helper-next.yml
vendored
8
.github/workflows/coolify-helper-next.yml
vendored
@@ -44,8 +44,8 @@ jobs:
|
|||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.DOCKER_REGISTRY }}
|
registry: ${{ env.DOCKER_REGISTRY }}
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Get Version
|
- name: Get Version
|
||||||
id: version
|
id: version
|
||||||
@@ -86,8 +86,8 @@ jobs:
|
|||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.DOCKER_REGISTRY }}
|
registry: ${{ env.DOCKER_REGISTRY }}
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Get Version
|
- name: Get Version
|
||||||
id: version
|
id: version
|
||||||
|
|||||||
8
.github/workflows/coolify-helper.yml
vendored
8
.github/workflows/coolify-helper.yml
vendored
@@ -44,8 +44,8 @@ jobs:
|
|||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.DOCKER_REGISTRY }}
|
registry: ${{ env.DOCKER_REGISTRY }}
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Get Version
|
- name: Get Version
|
||||||
id: version
|
id: version
|
||||||
@@ -85,8 +85,8 @@ jobs:
|
|||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.DOCKER_REGISTRY }}
|
registry: ${{ env.DOCKER_REGISTRY }}
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Get Version
|
- name: Get Version
|
||||||
id: version
|
id: version
|
||||||
|
|||||||
@@ -51,8 +51,8 @@ jobs:
|
|||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.DOCKER_REGISTRY }}
|
registry: ${{ env.DOCKER_REGISTRY }}
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Get Version
|
- name: Get Version
|
||||||
id: version
|
id: version
|
||||||
@@ -91,8 +91,8 @@ jobs:
|
|||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.DOCKER_REGISTRY }}
|
registry: ${{ env.DOCKER_REGISTRY }}
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Get Version
|
- name: Get Version
|
||||||
id: version
|
id: version
|
||||||
|
|||||||
8
.github/workflows/coolify-realtime-next.yml
vendored
8
.github/workflows/coolify-realtime-next.yml
vendored
@@ -48,8 +48,8 @@ jobs:
|
|||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.DOCKER_REGISTRY }}
|
registry: ${{ env.DOCKER_REGISTRY }}
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Get Version
|
- name: Get Version
|
||||||
id: version
|
id: version
|
||||||
@@ -90,8 +90,8 @@ jobs:
|
|||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.DOCKER_REGISTRY }}
|
registry: ${{ env.DOCKER_REGISTRY }}
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Get Version
|
- name: Get Version
|
||||||
id: version
|
id: version
|
||||||
|
|||||||
8
.github/workflows/coolify-realtime.yml
vendored
8
.github/workflows/coolify-realtime.yml
vendored
@@ -48,8 +48,8 @@ jobs:
|
|||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.DOCKER_REGISTRY }}
|
registry: ${{ env.DOCKER_REGISTRY }}
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Get Version
|
- name: Get Version
|
||||||
id: version
|
id: version
|
||||||
@@ -90,8 +90,8 @@ jobs:
|
|||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.DOCKER_REGISTRY }}
|
registry: ${{ env.DOCKER_REGISTRY }}
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Get Version
|
- name: Get Version
|
||||||
id: version
|
id: version
|
||||||
|
|||||||
8
.github/workflows/coolify-staging-build.yml
vendored
8
.github/workflows/coolify-staging-build.yml
vendored
@@ -64,8 +64,8 @@ jobs:
|
|||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.DOCKER_REGISTRY }}
|
registry: ${{ env.DOCKER_REGISTRY }}
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and Push Image (${{ matrix.arch }})
|
- name: Build and Push Image (${{ matrix.arch }})
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -110,8 +110,8 @@ jobs:
|
|||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.DOCKER_REGISTRY }}
|
registry: ${{ env.DOCKER_REGISTRY }}
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
|
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
8
.github/workflows/coolify-testing-host.yml
vendored
8
.github/workflows/coolify-testing-host.yml
vendored
@@ -44,8 +44,8 @@ jobs:
|
|||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.DOCKER_REGISTRY }}
|
registry: ${{ env.DOCKER_REGISTRY }}
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and Push Image (${{ matrix.arch }})
|
- name: Build and Push Image (${{ matrix.arch }})
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -81,8 +81,8 @@ jobs:
|
|||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.DOCKER_REGISTRY }}
|
registry: ${{ env.DOCKER_REGISTRY }}
|
||||||
username: ${{ secrets.DOCKER_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
|
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
8446
CHANGELOG.md
8446
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@@ -222,6 +222,7 @@ class MyComponent extends Component
|
|||||||
- Queue heavy operations
|
- Queue heavy operations
|
||||||
- Optimize database queries with proper indexes
|
- Optimize database queries with proper indexes
|
||||||
- Use chunking for large data operations
|
- Use chunking for large data operations
|
||||||
|
- **CRITICAL**: Use `ownedByCurrentTeamCached()` instead of `ownedByCurrentTeam()->get()`
|
||||||
|
|
||||||
### Code Style
|
### Code Style
|
||||||
- Follow PSR-12 coding standards
|
- Follow PSR-12 coding standards
|
||||||
@@ -318,3 +319,4 @@ This file contains high-level guidelines for Claude Code. For **more detailed, t
|
|||||||
|
|
||||||
Random other things you should remember:
|
Random other things you should remember:
|
||||||
- App\Models\Application::team must return a relationship instance., always use team()
|
- App\Models\Application::team must return a relationship instance., always use team()
|
||||||
|
- Always use `Model::ownedByCurrentTeamCached()` instead of `Model::ownedByCurrentTeam()->get()` for team-scoped queries to avoid duplicate database queries
|
||||||
176
app/Actions/Application/CleanupPreviewDeployment.php
Normal file
176
app/Actions/Application/CleanupPreviewDeployment.php
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Actions\Application;
|
||||||
|
|
||||||
|
use App\Enums\ApplicationDeploymentStatus;
|
||||||
|
use App\Jobs\DeleteResourceJob;
|
||||||
|
use App\Models\Application;
|
||||||
|
use App\Models\ApplicationDeploymentQueue;
|
||||||
|
use App\Models\ApplicationPreview;
|
||||||
|
use Lorisleiva\Actions\Concerns\AsAction;
|
||||||
|
|
||||||
|
class CleanupPreviewDeployment
|
||||||
|
{
|
||||||
|
use AsAction;
|
||||||
|
|
||||||
|
public string $jobQueue = 'high';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up a PR preview deployment completely.
|
||||||
|
*
|
||||||
|
* This handles:
|
||||||
|
* 1. Cancelling active deployments for the PR (QUEUED/IN_PROGRESS → CANCELLED_BY_USER)
|
||||||
|
* 2. Killing helper containers by deployment_uuid
|
||||||
|
* 3. Stopping/removing all running PR containers
|
||||||
|
* 4. Dispatching DeleteResourceJob for thorough cleanup (volumes, networks, database records)
|
||||||
|
*
|
||||||
|
* This unifies the cleanup logic from GitHub webhook handler to be used across all providers.
|
||||||
|
*/
|
||||||
|
public function handle(
|
||||||
|
Application $application,
|
||||||
|
int $pull_request_id,
|
||||||
|
?ApplicationPreview $preview = null
|
||||||
|
): array {
|
||||||
|
$result = [
|
||||||
|
'cancelled_deployments' => 0,
|
||||||
|
'killed_containers' => 0,
|
||||||
|
'status' => 'success',
|
||||||
|
];
|
||||||
|
|
||||||
|
$server = $application->destination->server;
|
||||||
|
|
||||||
|
if (! $server->isFunctional()) {
|
||||||
|
return [
|
||||||
|
...$result,
|
||||||
|
'status' => 'failed',
|
||||||
|
'message' => 'Server is not functional',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Cancel all active deployments for this PR and kill helper containers
|
||||||
|
$result['cancelled_deployments'] = $this->cancelActiveDeployments(
|
||||||
|
$application,
|
||||||
|
$pull_request_id,
|
||||||
|
$server
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 2: Stop and remove all running PR containers
|
||||||
|
$result['killed_containers'] = $this->stopRunningContainers(
|
||||||
|
$application,
|
||||||
|
$pull_request_id,
|
||||||
|
$server
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 3: Find or use provided preview, then dispatch cleanup job for thorough cleanup
|
||||||
|
if (! $preview) {
|
||||||
|
$preview = ApplicationPreview::where('application_id', $application->id)
|
||||||
|
->where('pull_request_id', $pull_request_id)
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($preview) {
|
||||||
|
DeleteResourceJob::dispatch($preview);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel all active (QUEUED/IN_PROGRESS) deployments for this PR.
|
||||||
|
*/
|
||||||
|
private function cancelActiveDeployments(
|
||||||
|
Application $application,
|
||||||
|
int $pull_request_id,
|
||||||
|
$server
|
||||||
|
): int {
|
||||||
|
$activeDeployments = ApplicationDeploymentQueue::where('application_id', $application->id)
|
||||||
|
->where('pull_request_id', $pull_request_id)
|
||||||
|
->whereIn('status', [
|
||||||
|
ApplicationDeploymentStatus::QUEUED->value,
|
||||||
|
ApplicationDeploymentStatus::IN_PROGRESS->value,
|
||||||
|
])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$cancelled = 0;
|
||||||
|
foreach ($activeDeployments as $deployment) {
|
||||||
|
try {
|
||||||
|
// Mark deployment as cancelled
|
||||||
|
$deployment->update([
|
||||||
|
'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Add cancellation log entry
|
||||||
|
$deployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr');
|
||||||
|
|
||||||
|
// Try to kill helper container if it exists
|
||||||
|
$this->killHelperContainer($deployment->deployment_uuid, $server);
|
||||||
|
$cancelled++;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
\Log::warning("Failed to cancel deployment {$deployment->id}: {$e->getMessage()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $cancelled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kill the helper container used during deployment.
|
||||||
|
*/
|
||||||
|
private function killHelperContainer(string $deployment_uuid, $server): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$escapedUuid = escapeshellarg($deployment_uuid);
|
||||||
|
$checkCommand = "docker ps -a --filter name={$escapedUuid} --format '{{.Names}}'";
|
||||||
|
$containerExists = instant_remote_process([$checkCommand], $server);
|
||||||
|
|
||||||
|
if ($containerExists && str($containerExists)->trim()->isNotEmpty()) {
|
||||||
|
instant_remote_process(["docker rm -f {$escapedUuid}"], $server);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Silently handle - container may already be gone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop and remove all running containers for this PR.
|
||||||
|
*/
|
||||||
|
private function stopRunningContainers(
|
||||||
|
Application $application,
|
||||||
|
int $pull_request_id,
|
||||||
|
$server
|
||||||
|
): int {
|
||||||
|
$killed = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if ($server->isSwarm()) {
|
||||||
|
$escapedStackName = escapeshellarg("{$application->uuid}-{$pull_request_id}");
|
||||||
|
instant_remote_process(["docker stack rm {$escapedStackName}"], $server);
|
||||||
|
$killed++;
|
||||||
|
} else {
|
||||||
|
$containers = getCurrentApplicationContainerStatus(
|
||||||
|
$server,
|
||||||
|
$application->id,
|
||||||
|
$pull_request_id
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($containers->isNotEmpty()) {
|
||||||
|
foreach ($containers as $container) {
|
||||||
|
$containerName = data_get($container, 'Names');
|
||||||
|
if ($containerName) {
|
||||||
|
$escapedContainerName = escapeshellarg($containerName);
|
||||||
|
instant_remote_process(
|
||||||
|
["docker rm -f {$escapedContainerName}"],
|
||||||
|
$server
|
||||||
|
);
|
||||||
|
$killed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
\Log::warning("Error stopping containers for PR #{$pull_request_id}: {$e->getMessage()}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $killed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,7 +39,7 @@ class StopApplication
|
|||||||
|
|
||||||
foreach ($containersToStop as $containerName) {
|
foreach ($containersToStop as $containerName) {
|
||||||
instant_remote_process(command: [
|
instant_remote_process(command: [
|
||||||
"docker stop --time=30 $containerName",
|
"docker stop -t 30 $containerName",
|
||||||
"docker rm -f $containerName",
|
"docker rm -f $containerName",
|
||||||
], server: $server, throwError: false);
|
], server: $server, throwError: false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class StopApplicationOneServer
|
|||||||
if ($containerName) {
|
if ($containerName) {
|
||||||
instant_remote_process(
|
instant_remote_process(
|
||||||
[
|
[
|
||||||
"docker stop --time=30 $containerName",
|
"docker stop -t 30 $containerName",
|
||||||
"docker rm -f $containerName",
|
"docker rm -f $containerName",
|
||||||
],
|
],
|
||||||
$server
|
$server
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ class StartClickhouse
|
|||||||
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
|
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
|
||||||
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
||||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
||||||
$this->commands[] = "docker stop --time=10 $container_name 2>/dev/null || true";
|
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
|
||||||
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
|
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
|
||||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||||
$this->commands[] = "echo 'Database started.'";
|
$this->commands[] = "echo 'Database started.'";
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ class StartDragonfly
|
|||||||
if ($this->database->enable_ssl) {
|
if ($this->database->enable_ssl) {
|
||||||
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
|
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
|
||||||
}
|
}
|
||||||
$this->commands[] = "docker stop --time=10 $container_name 2>/dev/null || true";
|
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
|
||||||
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
|
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
|
||||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||||
$this->commands[] = "echo 'Database started.'";
|
$this->commands[] = "echo 'Database started.'";
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ class StartKeydb
|
|||||||
if ($this->database->enable_ssl) {
|
if ($this->database->enable_ssl) {
|
||||||
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
|
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
|
||||||
}
|
}
|
||||||
$this->commands[] = "docker stop --time=10 $container_name 2>/dev/null || true";
|
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
|
||||||
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
|
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
|
||||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||||
$this->commands[] = "echo 'Database started.'";
|
$this->commands[] = "echo 'Database started.'";
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ class StartMariadb
|
|||||||
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
|
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
|
||||||
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
||||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
||||||
$this->commands[] = "docker stop --time=10 $container_name 2>/dev/null || true";
|
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
|
||||||
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
|
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
|
||||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||||
$this->commands[] = "echo 'Database started.'";
|
$this->commands[] = "echo 'Database started.'";
|
||||||
|
|||||||
@@ -260,7 +260,7 @@ class StartMongodb
|
|||||||
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
|
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
|
||||||
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
||||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
||||||
$this->commands[] = "docker stop --time=10 $container_name 2>/dev/null || true";
|
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
|
||||||
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
|
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
|
||||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||||
if ($this->database->enable_ssl) {
|
if ($this->database->enable_ssl) {
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ class StartMysql
|
|||||||
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
|
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
|
||||||
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
||||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
||||||
$this->commands[] = "docker stop --time=10 $container_name 2>/dev/null || true";
|
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
|
||||||
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
|
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
|
||||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||||
|
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ class StartPostgresql
|
|||||||
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
|
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
|
||||||
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
||||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
|
||||||
$this->commands[] = "docker stop --time=10 $container_name 2>/dev/null || true";
|
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
|
||||||
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
|
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
|
||||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||||
if ($this->database->enable_ssl) {
|
if ($this->database->enable_ssl) {
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ class StartRedis
|
|||||||
if ($this->database->enable_ssl) {
|
if ($this->database->enable_ssl) {
|
||||||
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
|
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
|
||||||
}
|
}
|
||||||
$this->commands[] = "docker stop --time=10 $container_name 2>/dev/null || true";
|
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
|
||||||
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
|
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
|
||||||
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||||
$this->commands[] = "echo 'Database started.'";
|
$this->commands[] = "echo 'Database started.'";
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ class StopDatabase
|
|||||||
{
|
{
|
||||||
$server = $database->destination->server;
|
$server = $database->destination->server;
|
||||||
instant_remote_process(command: [
|
instant_remote_process(command: [
|
||||||
"docker stop --time=$timeout $containerName",
|
"docker stop -t $timeout $containerName",
|
||||||
"docker rm -f $containerName",
|
"docker rm -f $containerName",
|
||||||
], server: $server, throwError: false);
|
], server: $server, throwError: false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -461,9 +461,10 @@ class GetContainersStatus
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Use ContainerStatusAggregator service for state machine logic
|
// Use ContainerStatusAggregator service for state machine logic
|
||||||
|
// Use preserveRestarting: true so applications show "Restarting" instead of "Degraded"
|
||||||
$aggregator = new ContainerStatusAggregator;
|
$aggregator = new ContainerStatusAggregator;
|
||||||
|
|
||||||
return $aggregator->aggregateFromStrings($relevantStatuses, $maxRestartCount);
|
return $aggregator->aggregateFromStrings($relevantStatuses, $maxRestartCount, preserveRestarting: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function aggregateServiceContainerStatuses($services)
|
private function aggregateServiceContainerStatuses($services)
|
||||||
@@ -518,8 +519,9 @@ class GetContainersStatus
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Use ContainerStatusAggregator service for state machine logic
|
// Use ContainerStatusAggregator service for state machine logic
|
||||||
|
// Use preserveRestarting: true so individual sub-resources show "Restarting" instead of "Degraded"
|
||||||
$aggregator = new ContainerStatusAggregator;
|
$aggregator = new ContainerStatusAggregator;
|
||||||
$aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses);
|
$aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, preserveRestarting: true);
|
||||||
|
|
||||||
// Update service sub-resource status with aggregated result
|
// Update service sub-resource status with aggregated result
|
||||||
if ($aggregatedStatus) {
|
if ($aggregatedStatus) {
|
||||||
|
|||||||
@@ -75,6 +75,10 @@ class StartProxy
|
|||||||
' done',
|
' done',
|
||||||
" echo 'Successfully stopped and removed existing coolify-proxy.'",
|
" echo 'Successfully stopped and removed existing coolify-proxy.'",
|
||||||
'fi',
|
'fi',
|
||||||
|
]);
|
||||||
|
// Ensure required networks exist BEFORE docker compose up (networks are declared as external)
|
||||||
|
$commands = $commands->merge(ensureProxyNetworksExist($server));
|
||||||
|
$commands = $commands->merge([
|
||||||
"echo 'Starting coolify-proxy.'",
|
"echo 'Starting coolify-proxy.'",
|
||||||
'docker compose up -d --wait --remove-orphans',
|
'docker compose up -d --wait --remove-orphans',
|
||||||
"echo 'Successfully started coolify-proxy.'",
|
"echo 'Successfully started coolify-proxy.'",
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class StopProxy
|
|||||||
}
|
}
|
||||||
|
|
||||||
instant_remote_process(command: [
|
instant_remote_process(command: [
|
||||||
"docker stop --time=$timeout $containerName 2>/dev/null || true",
|
"docker stop -t=$timeout $containerName 2>/dev/null || true",
|
||||||
"docker rm -f $containerName 2>/dev/null || true",
|
"docker rm -f $containerName 2>/dev/null || true",
|
||||||
'# Wait for container to be fully removed',
|
'# Wait for container to be fully removed',
|
||||||
'for i in {1..10}; do',
|
'for i in {1..10}; do',
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ class CheckUpdates
|
|||||||
|
|
||||||
public function handle(Server $server)
|
public function handle(Server $server)
|
||||||
{
|
{
|
||||||
|
$osId = 'unknown';
|
||||||
|
$packageManager = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if ($server->serverStatus() === false) {
|
if ($server->serverStatus() === false) {
|
||||||
return [
|
return [
|
||||||
@@ -93,6 +96,16 @@ class CheckUpdates
|
|||||||
$out['osId'] = $osId;
|
$out['osId'] = $osId;
|
||||||
$out['package_manager'] = $packageManager;
|
$out['package_manager'] = $packageManager;
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
case 'pacman':
|
||||||
|
// Sync database first, then check for updates
|
||||||
|
// Using -Sy to refresh package database before querying available updates
|
||||||
|
instant_remote_process(['pacman -Sy'], $server);
|
||||||
|
$output = instant_remote_process(['pacman -Qu 2>/dev/null'], $server);
|
||||||
|
$out = $this->parsePacmanOutput($output);
|
||||||
|
$out['osId'] = $osId;
|
||||||
|
$out['package_manager'] = $packageManager;
|
||||||
|
|
||||||
return $out;
|
return $out;
|
||||||
default:
|
default:
|
||||||
return [
|
return [
|
||||||
@@ -219,4 +232,45 @@ class CheckUpdates
|
|||||||
'updates' => $updates,
|
'updates' => $updates,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function parsePacmanOutput(string $output): array
|
||||||
|
{
|
||||||
|
$updates = [];
|
||||||
|
$unparsedLines = [];
|
||||||
|
$lines = explode("\n", $output);
|
||||||
|
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
if (empty($line)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Format: package current_version -> new_version
|
||||||
|
if (preg_match('/^(\S+)\s+(\S+)\s+->\s+(\S+)$/', $line, $matches)) {
|
||||||
|
$updates[] = [
|
||||||
|
'package' => $matches[1],
|
||||||
|
'current_version' => $matches[2],
|
||||||
|
'new_version' => $matches[3],
|
||||||
|
'architecture' => 'unknown',
|
||||||
|
'repository' => 'unknown',
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
// Log unmatched lines for debugging purposes
|
||||||
|
$unparsedLines[] = $line;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = [
|
||||||
|
'total_updates' => count($updates),
|
||||||
|
'updates' => $updates,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Include unparsed lines in the result for debugging if any exist
|
||||||
|
if (! empty($unparsedLines)) {
|
||||||
|
$result['unparsed_lines'] = $unparsedLines;
|
||||||
|
\Illuminate\Support\Facades\Log::debug('Pacman output contained unparsed lines', [
|
||||||
|
'unparsed_lines' => $unparsedLines,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ class CleanupDocker
|
|||||||
|
|
||||||
public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $deleteUnusedNetworks = false)
|
public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $deleteUnusedNetworks = false)
|
||||||
{
|
{
|
||||||
$settings = instanceSettings();
|
|
||||||
$realtimeImage = config('constants.coolify.realtime_image');
|
$realtimeImage = config('constants.coolify.realtime_image');
|
||||||
$realtimeImageVersion = config('constants.coolify.realtime_version');
|
$realtimeImageVersion = config('constants.coolify.realtime_version');
|
||||||
$realtimeImageWithVersion = "$realtimeImage:$realtimeImageVersion";
|
$realtimeImageWithVersion = "$realtimeImage:$realtimeImageVersion";
|
||||||
@@ -26,9 +25,31 @@ class CleanupDocker
|
|||||||
$helperImageWithoutPrefix = 'coollabsio/coolify-helper';
|
$helperImageWithoutPrefix = 'coollabsio/coolify-helper';
|
||||||
$helperImageWithoutPrefixVersion = "coollabsio/coolify-helper:$helperImageVersion";
|
$helperImageWithoutPrefixVersion = "coollabsio/coolify-helper:$helperImageVersion";
|
||||||
|
|
||||||
|
$cleanupLog = [];
|
||||||
|
|
||||||
|
// Get all application image repositories to exclude from prune
|
||||||
|
$applications = $server->applications();
|
||||||
|
$applicationImageRepos = collect($applications)->map(function ($app) {
|
||||||
|
return $app->docker_registry_image_name ?? $app->uuid;
|
||||||
|
})->unique()->values();
|
||||||
|
|
||||||
|
// Clean up old application images while preserving N most recent for rollback
|
||||||
|
$applicationCleanupLog = $this->cleanupApplicationImages($server, $applications);
|
||||||
|
$cleanupLog = array_merge($cleanupLog, $applicationCleanupLog);
|
||||||
|
|
||||||
|
// Build image prune command that excludes application images and current Coolify infrastructure images
|
||||||
|
// This ensures we clean up non-Coolify images while preserving rollback images and current helper/realtime images
|
||||||
|
// Note: Only the current version is protected; old versions will be cleaned up by explicit commands below
|
||||||
|
// We pass the version strings so all registry variants are protected (ghcr.io, docker.io, no prefix)
|
||||||
|
$imagePruneCmd = $this->buildImagePruneCommand(
|
||||||
|
$applicationImageRepos,
|
||||||
|
$helperImageVersion,
|
||||||
|
$realtimeImageVersion
|
||||||
|
);
|
||||||
|
|
||||||
$commands = [
|
$commands = [
|
||||||
'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true"',
|
'docker container prune -f --filter "label=coolify.managed=true" --filter "label!=coolify.proxy=true"',
|
||||||
'docker image prune -af --filter "label!=coolify.managed=true"',
|
$imagePruneCmd,
|
||||||
'docker builder prune -af',
|
'docker builder prune -af',
|
||||||
"docker images --filter before=$helperImageWithVersion --filter reference=$helperImage | grep $helperImage | awk '{print $3}' | xargs -r docker rmi -f",
|
"docker images --filter before=$helperImageWithVersion --filter reference=$helperImage | grep $helperImage | awk '{print $3}' | xargs -r docker rmi -f",
|
||||||
"docker images --filter before=$realtimeImageWithVersion --filter reference=$realtimeImage | grep $realtimeImage | awk '{print $3}' | xargs -r docker rmi -f",
|
"docker images --filter before=$realtimeImageWithVersion --filter reference=$realtimeImage | grep $realtimeImage | awk '{print $3}' | xargs -r docker rmi -f",
|
||||||
@@ -44,7 +65,6 @@ class CleanupDocker
|
|||||||
$commands[] = 'docker network prune -f';
|
$commands[] = 'docker network prune -f';
|
||||||
}
|
}
|
||||||
|
|
||||||
$cleanupLog = [];
|
|
||||||
foreach ($commands as $command) {
|
foreach ($commands as $command) {
|
||||||
$commandOutput = instant_remote_process([$command], $server, false);
|
$commandOutput = instant_remote_process([$command], $server, false);
|
||||||
if ($commandOutput !== null) {
|
if ($commandOutput !== null) {
|
||||||
@@ -57,4 +77,140 @@ class CleanupDocker
|
|||||||
|
|
||||||
return $cleanupLog;
|
return $cleanupLog;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a docker image prune command that excludes application image repositories.
|
||||||
|
*
|
||||||
|
* Since docker image prune doesn't support excluding by repository name directly,
|
||||||
|
* we use a shell script approach to delete unused images while preserving application images.
|
||||||
|
*/
|
||||||
|
private function buildImagePruneCommand(
|
||||||
|
$applicationImageRepos,
|
||||||
|
string $helperImageVersion,
|
||||||
|
string $realtimeImageVersion
|
||||||
|
): string {
|
||||||
|
// Step 1: Always prune dangling images (untagged)
|
||||||
|
$commands = ['docker image prune -f'];
|
||||||
|
|
||||||
|
// Build grep pattern to exclude application image repositories (matches repo:tag and repo_service:tag)
|
||||||
|
$appExcludePatterns = $applicationImageRepos->map(function ($repo) {
|
||||||
|
// Escape special characters for grep extended regex (ERE)
|
||||||
|
// ERE special chars: . \ + * ? [ ^ ] $ ( ) { } |
|
||||||
|
return preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $repo);
|
||||||
|
})->implode('|');
|
||||||
|
|
||||||
|
// Build grep pattern to exclude Coolify infrastructure images (current version only)
|
||||||
|
// This pattern matches the image name regardless of registry prefix:
|
||||||
|
// - ghcr.io/coollabsio/coolify-helper:1.0.12
|
||||||
|
// - docker.io/coollabsio/coolify-helper:1.0.12
|
||||||
|
// - coollabsio/coolify-helper:1.0.12
|
||||||
|
// Pattern: (^|/)coollabsio/coolify-(helper|realtime):VERSION$
|
||||||
|
$escapedHelperVersion = preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $helperImageVersion);
|
||||||
|
$escapedRealtimeVersion = preg_replace('/([.\\\\+*?\[\]^$(){}|])/', '\\\\$1', $realtimeImageVersion);
|
||||||
|
$infraExcludePattern = "(^|/)coollabsio/coolify-helper:{$escapedHelperVersion}$|(^|/)coollabsio/coolify-realtime:{$escapedRealtimeVersion}$";
|
||||||
|
|
||||||
|
// Delete unused images that:
|
||||||
|
// - Are not application images (don't match app repos)
|
||||||
|
// - Are not current Coolify infrastructure images (any registry)
|
||||||
|
// - Don't have coolify.managed=true label
|
||||||
|
// Images in use by containers will fail silently with docker rmi
|
||||||
|
// Pattern matches both uuid:tag and uuid_servicename:tag (Docker Compose with build)
|
||||||
|
$grepCommands = "grep -v '<none>'";
|
||||||
|
|
||||||
|
// Add application repo exclusion if there are applications
|
||||||
|
if ($applicationImageRepos->isNotEmpty()) {
|
||||||
|
$grepCommands .= " | grep -v -E '^({$appExcludePatterns})[_:].+'";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add infrastructure image exclusion (matches any registry prefix)
|
||||||
|
$grepCommands .= " | grep -v -E '{$infraExcludePattern}'";
|
||||||
|
|
||||||
|
$commands[] = "docker images --format '{{.Repository}}:{{.Tag}}' | ".
|
||||||
|
$grepCommands.' | '.
|
||||||
|
"xargs -r -I {} sh -c 'docker inspect --format \"{{{{index .Config.Labels \\\"coolify.managed\\\"}}}}\" \"{}\" 2>/dev/null | grep -q true || docker rmi \"{}\" 2>/dev/null' || true";
|
||||||
|
|
||||||
|
return implode(' && ', $commands);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function cleanupApplicationImages(Server $server, $applications = null): array
|
||||||
|
{
|
||||||
|
$cleanupLog = [];
|
||||||
|
|
||||||
|
if ($applications === null) {
|
||||||
|
$applications = $server->applications();
|
||||||
|
}
|
||||||
|
|
||||||
|
$disableRetention = $server->settings->disable_application_image_retention ?? false;
|
||||||
|
|
||||||
|
foreach ($applications as $application) {
|
||||||
|
$imagesToKeep = $disableRetention ? 0 : ($application->settings->docker_images_to_keep ?? 2);
|
||||||
|
$imageRepository = $application->docker_registry_image_name ?? $application->uuid;
|
||||||
|
|
||||||
|
// Get the currently running image tag
|
||||||
|
$currentTagCommand = "docker inspect --format='{{.Config.Image}}' {$application->uuid} 2>/dev/null | grep -oP '(?<=:)[^:]+$' || true";
|
||||||
|
$currentTag = instant_remote_process([$currentTagCommand], $server, false);
|
||||||
|
$currentTag = trim($currentTag ?? '');
|
||||||
|
|
||||||
|
// List all images for this application with their creation timestamps
|
||||||
|
// Use wildcard to match both uuid:tag and uuid_servicename:tag (Docker Compose with build)
|
||||||
|
$listCommand = "docker images --format '{{.Repository}}:{{.Tag}}#{{.CreatedAt}}' --filter reference='{$imageRepository}*' 2>/dev/null || true";
|
||||||
|
$output = instant_remote_process([$listCommand], $server, false);
|
||||||
|
|
||||||
|
if (empty($output)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$images = collect(explode("\n", trim($output)))
|
||||||
|
->filter()
|
||||||
|
->map(function ($line) {
|
||||||
|
$parts = explode('#', $line);
|
||||||
|
$imageRef = $parts[0] ?? '';
|
||||||
|
$tagParts = explode(':', $imageRef);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'repository' => $tagParts[0] ?? '',
|
||||||
|
'tag' => $tagParts[1] ?? '',
|
||||||
|
'created_at' => $parts[1] ?? '',
|
||||||
|
'image_ref' => $imageRef,
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->filter(fn ($image) => ! empty($image['tag']));
|
||||||
|
|
||||||
|
// Separate images into categories
|
||||||
|
// PR images (pr-*) and build images (*-build) are excluded from retention
|
||||||
|
// Build images will be cleaned up by docker image prune -af
|
||||||
|
$prImages = $images->filter(fn ($image) => str_starts_with($image['tag'], 'pr-'));
|
||||||
|
$regularImages = $images->filter(fn ($image) => ! str_starts_with($image['tag'], 'pr-') && ! str_ends_with($image['tag'], '-build'));
|
||||||
|
|
||||||
|
// Always delete all PR images
|
||||||
|
foreach ($prImages as $image) {
|
||||||
|
$deleteCommand = "docker rmi {$image['image_ref']} 2>/dev/null || true";
|
||||||
|
$deleteOutput = instant_remote_process([$deleteCommand], $server, false);
|
||||||
|
$cleanupLog[] = [
|
||||||
|
'command' => $deleteCommand,
|
||||||
|
'output' => $deleteOutput ?? 'PR image removed or was in use',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out current running image from regular images and sort by creation date
|
||||||
|
$sortedRegularImages = $regularImages
|
||||||
|
->filter(fn ($image) => $image['tag'] !== $currentTag)
|
||||||
|
->sortByDesc('created_at')
|
||||||
|
->values();
|
||||||
|
|
||||||
|
// Keep only N images (imagesToKeep), delete the rest
|
||||||
|
$imagesToDelete = $sortedRegularImages->skip($imagesToKeep);
|
||||||
|
|
||||||
|
foreach ($imagesToDelete as $image) {
|
||||||
|
$deleteCommand = "docker rmi {$image['image_ref']} 2>/dev/null || true";
|
||||||
|
$deleteOutput = instant_remote_process([$deleteCommand], $server, false);
|
||||||
|
$cleanupLog[] = [
|
||||||
|
'command' => $deleteCommand,
|
||||||
|
'output' => $deleteOutput ?? 'Image removed or was in use',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $cleanupLog;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ class InstallDocker
|
|||||||
$command = $command->merge([$this->getRhelDockerInstallCommand()]);
|
$command = $command->merge([$this->getRhelDockerInstallCommand()]);
|
||||||
} elseif ($supported_os_type->contains('sles')) {
|
} elseif ($supported_os_type->contains('sles')) {
|
||||||
$command = $command->merge([$this->getSuseDockerInstallCommand()]);
|
$command = $command->merge([$this->getSuseDockerInstallCommand()]);
|
||||||
|
} elseif ($supported_os_type->contains('arch')) {
|
||||||
|
$command = $command->merge([$this->getArchDockerInstallCommand()]);
|
||||||
} else {
|
} else {
|
||||||
$command = $command->merge([$this->getGenericDockerInstallCommand()]);
|
$command = $command->merge([$this->getGenericDockerInstallCommand()]);
|
||||||
}
|
}
|
||||||
@@ -146,8 +148,19 @@ class InstallDocker
|
|||||||
')';
|
')';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function getArchDockerInstallCommand(): string
|
||||||
|
{
|
||||||
|
// Use -Syu to perform full system upgrade before installing Docker
|
||||||
|
// Partial upgrades (-Sy without -u) are discouraged on Arch Linux
|
||||||
|
// as they can lead to broken dependencies and system instability
|
||||||
|
// Use --needed to skip reinstalling packages that are already up-to-date (idempotent)
|
||||||
|
return 'pacman -Syu --noconfirm --needed docker docker-compose && '.
|
||||||
|
'systemctl enable docker.service && '.
|
||||||
|
'systemctl start docker.service';
|
||||||
|
}
|
||||||
|
|
||||||
private function getGenericDockerInstallCommand(): string
|
private function getGenericDockerInstallCommand(): string
|
||||||
{
|
{
|
||||||
return "curl https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl https://get.docker.com | sh -s -- --version {$this->dockerVersion}";
|
return "curl --max-time 300 --retry 3 https://releases.rancher.com/install-docker/{$this->dockerVersion}.sh | sh || curl --max-time 300 --retry 3 https://get.docker.com | sh -s -- --version {$this->dockerVersion}";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,13 @@ class InstallPrerequisites
|
|||||||
'command -v git >/dev/null || zypper install -y git',
|
'command -v git >/dev/null || zypper install -y git',
|
||||||
'command -v jq >/dev/null || zypper install -y jq',
|
'command -v jq >/dev/null || zypper install -y jq',
|
||||||
]);
|
]);
|
||||||
|
} elseif ($supported_os_type->contains('arch')) {
|
||||||
|
// Use -Syu for full system upgrade to avoid partial upgrade issues on Arch Linux
|
||||||
|
// --needed flag skips packages that are already installed and up-to-date
|
||||||
|
$command = $command->merge([
|
||||||
|
"echo 'Installing Prerequisites for Arch Linux...'",
|
||||||
|
'pacman -Syu --noconfirm --needed curl wget git jq',
|
||||||
|
]);
|
||||||
} else {
|
} else {
|
||||||
throw new \Exception('Unsupported OS type for prerequisites installation');
|
throw new \Exception('Unsupported OS type for prerequisites installation');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
namespace App\Actions\Server;
|
namespace App\Actions\Server;
|
||||||
|
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Sleep;
|
use Illuminate\Support\Sleep;
|
||||||
use Lorisleiva\Actions\Concerns\AsAction;
|
use Lorisleiva\Actions\Concerns\AsAction;
|
||||||
|
|
||||||
@@ -28,8 +30,59 @@ class UpdateCoolify
|
|||||||
if (! $this->server) {
|
if (! $this->server) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
CleanupDocker::dispatch($this->server, false, false);
|
|
||||||
$this->latestVersion = get_latest_version_of_coolify();
|
// Fetch fresh version from CDN instead of using cache
|
||||||
|
try {
|
||||||
|
$response = Http::retry(3, 1000)->timeout(10)
|
||||||
|
->get(config('constants.coolify.versions_url'));
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
$versions = $response->json();
|
||||||
|
$this->latestVersion = data_get($versions, 'coolify.v4.version');
|
||||||
|
} else {
|
||||||
|
// Fallback to cache if CDN unavailable
|
||||||
|
$cacheVersion = get_latest_version_of_coolify();
|
||||||
|
|
||||||
|
// Validate cache version against current running version
|
||||||
|
if ($cacheVersion && version_compare($cacheVersion, config('constants.coolify.version'), '<')) {
|
||||||
|
Log::error('Failed to fetch fresh version from CDN and cache is corrupted/outdated', [
|
||||||
|
'cached_version' => $cacheVersion,
|
||||||
|
'current_version' => config('constants.coolify.version'),
|
||||||
|
]);
|
||||||
|
throw new \Exception(
|
||||||
|
'Cannot determine latest version: CDN unavailable and cache version '.
|
||||||
|
"({$cacheVersion}) is older than running version (".config('constants.coolify.version').')'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->latestVersion = $cacheVersion;
|
||||||
|
Log::warning('Failed to fetch fresh version from CDN (unsuccessful response), using validated cache', [
|
||||||
|
'version' => $cacheVersion,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$cacheVersion = get_latest_version_of_coolify();
|
||||||
|
|
||||||
|
// Validate cache version against current running version
|
||||||
|
if ($cacheVersion && version_compare($cacheVersion, config('constants.coolify.version'), '<')) {
|
||||||
|
Log::error('Failed to fetch fresh version from CDN and cache is corrupted/outdated', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'cached_version' => $cacheVersion,
|
||||||
|
'current_version' => config('constants.coolify.version'),
|
||||||
|
]);
|
||||||
|
throw new \Exception(
|
||||||
|
'Cannot determine latest version: CDN unavailable and cache version '.
|
||||||
|
"({$cacheVersion}) is older than running version (".config('constants.coolify.version').')'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->latestVersion = $cacheVersion;
|
||||||
|
Log::warning('Failed to fetch fresh version from CDN, using validated cache', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'version' => $cacheVersion,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
$this->currentVersion = config('constants.coolify.version');
|
$this->currentVersion = config('constants.coolify.version');
|
||||||
if (! $manual_update) {
|
if (! $manual_update) {
|
||||||
if (! $settings->is_auto_update_enabled) {
|
if (! $settings->is_auto_update_enabled) {
|
||||||
@@ -42,6 +95,20 @@ class UpdateCoolify
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ALWAYS check for downgrades (even for manual updates)
|
||||||
|
if (version_compare($this->latestVersion, $this->currentVersion, '<')) {
|
||||||
|
Log::error('Downgrade prevented', [
|
||||||
|
'target_version' => $this->latestVersion,
|
||||||
|
'current_version' => $this->currentVersion,
|
||||||
|
'manual_update' => $manual_update,
|
||||||
|
]);
|
||||||
|
throw new \Exception(
|
||||||
|
"Cannot downgrade from {$this->currentVersion} to {$this->latestVersion}. ".
|
||||||
|
'If you need to downgrade, please do so manually via Docker commands.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$this->update();
|
$this->update();
|
||||||
$settings->new_version_available = false;
|
$settings->new_version_available = false;
|
||||||
$settings->save();
|
$settings->save();
|
||||||
@@ -49,16 +116,12 @@ class UpdateCoolify
|
|||||||
|
|
||||||
private function update()
|
private function update()
|
||||||
{
|
{
|
||||||
$helperImage = config('constants.coolify.helper_image');
|
$latestHelperImageVersion = getHelperVersion();
|
||||||
$latest_version = getHelperVersion();
|
$upgradeScriptUrl = config('constants.coolify.upgrade_script_url');
|
||||||
instant_remote_process(["docker pull -q {$helperImage}:{$latest_version}"], $this->server, false);
|
|
||||||
|
|
||||||
$image = config('constants.coolify.registry_url').'/coollabsio/coolify:'.$this->latestVersion;
|
|
||||||
instant_remote_process(["docker pull -q $image"], $this->server, false);
|
|
||||||
|
|
||||||
remote_process([
|
remote_process([
|
||||||
'curl -fsSL https://cdn.coollabs.io/coolify/upgrade.sh -o /data/coolify/source/upgrade.sh',
|
"curl -fsSL {$upgradeScriptUrl} -o /data/coolify/source/upgrade.sh",
|
||||||
"bash /data/coolify/source/upgrade.sh $this->latestVersion",
|
"bash /data/coolify/source/upgrade.sh $this->latestVersion $latestHelperImageVersion",
|
||||||
], $this->server);
|
], $this->server);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,18 +20,43 @@ class UpdatePackage
|
|||||||
'error' => 'Server is not reachable or not ready.',
|
'error' => 'Server is not reachable or not ready.',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate that package name is provided when not updating all packages
|
||||||
|
if (! $all && ($package === null || $package === '')) {
|
||||||
|
return [
|
||||||
|
'error' => "Package name required when 'all' is false.",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize package name to prevent command injection
|
||||||
|
// Only allow alphanumeric characters, hyphens, underscores, periods, plus signs, and colons
|
||||||
|
// These are valid characters in package names across most package managers
|
||||||
|
$sanitizedPackage = '';
|
||||||
|
if ($package !== null && ! $all) {
|
||||||
|
if (! preg_match('/^[a-zA-Z0-9._+:-]+$/', $package)) {
|
||||||
|
return [
|
||||||
|
'error' => 'Invalid package name. Package names can only contain alphanumeric characters, hyphens, underscores, periods, plus signs, and colons.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$sanitizedPackage = escapeshellarg($package);
|
||||||
|
}
|
||||||
|
|
||||||
switch ($packageManager) {
|
switch ($packageManager) {
|
||||||
case 'zypper':
|
case 'zypper':
|
||||||
$commandAll = 'zypper update -y';
|
$commandAll = 'zypper update -y';
|
||||||
$commandInstall = 'zypper install -y '.$package;
|
$commandInstall = 'zypper install -y '.$sanitizedPackage;
|
||||||
break;
|
break;
|
||||||
case 'dnf':
|
case 'dnf':
|
||||||
$commandAll = 'dnf update -y';
|
$commandAll = 'dnf update -y';
|
||||||
$commandInstall = 'dnf update -y '.$package;
|
$commandInstall = 'dnf update -y '.$sanitizedPackage;
|
||||||
break;
|
break;
|
||||||
case 'apt':
|
case 'apt':
|
||||||
$commandAll = 'apt update && apt upgrade -y';
|
$commandAll = 'apt update && apt upgrade -y';
|
||||||
$commandInstall = 'apt install -y '.$package;
|
$commandInstall = 'apt install -y '.$sanitizedPackage;
|
||||||
|
break;
|
||||||
|
case 'pacman':
|
||||||
|
$commandAll = 'pacman -Syu --noconfirm';
|
||||||
|
$commandInstall = 'pacman -S --noconfirm '.$sanitizedPackage;
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
namespace App\Actions\Service;
|
namespace App\Actions\Service;
|
||||||
|
|
||||||
use App\Actions\Server\CleanupDocker;
|
use App\Actions\Server\CleanupDocker;
|
||||||
|
use App\Enums\ProcessStatus;
|
||||||
use App\Events\ServiceStatusChanged;
|
use App\Events\ServiceStatusChanged;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use App\Models\Service;
|
use App\Models\Service;
|
||||||
use Lorisleiva\Actions\Concerns\AsAction;
|
use Lorisleiva\Actions\Concerns\AsAction;
|
||||||
|
use Spatie\Activitylog\Models\Activity;
|
||||||
|
|
||||||
class StopService
|
class StopService
|
||||||
{
|
{
|
||||||
@@ -17,6 +19,17 @@ class StopService
|
|||||||
public function handle(Service $service, bool $deleteConnectedNetworks = false, bool $dockerCleanup = true)
|
public function handle(Service $service, bool $deleteConnectedNetworks = false, bool $dockerCleanup = true)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
// Cancel any in-progress deployment activities so status doesn't stay stuck at "starting"
|
||||||
|
Activity::where('properties->type_uuid', $service->uuid)
|
||||||
|
->where(function ($q) {
|
||||||
|
$q->where('properties->status', ProcessStatus::IN_PROGRESS->value)
|
||||||
|
->orWhere('properties->status', ProcessStatus::QUEUED->value);
|
||||||
|
})
|
||||||
|
->each(function ($activity) {
|
||||||
|
$activity->properties = $activity->properties->put('status', ProcessStatus::CANCELLED->value);
|
||||||
|
$activity->save();
|
||||||
|
});
|
||||||
|
|
||||||
$server = $service->destination->server;
|
$server = $service->destination->server;
|
||||||
if (! $server->isFunctional()) {
|
if (! $server->isFunctional()) {
|
||||||
return 'Server is not functional';
|
return 'Server is not functional';
|
||||||
@@ -54,7 +67,7 @@ class StopService
|
|||||||
$timeout = count($containersToStop) > 5 ? 10 : 30;
|
$timeout = count($containersToStop) > 5 ? 10 : 30;
|
||||||
$commands = [];
|
$commands = [];
|
||||||
$containerList = implode(' ', $containersToStop);
|
$containerList = implode(' ', $containersToStop);
|
||||||
$commands[] = "docker stop --time=$timeout $containerList";
|
$commands[] = "docker stop -t $timeout $containerList";
|
||||||
$commands[] = "docker rm -f $containerList";
|
$commands[] = "docker rm -f $containerList";
|
||||||
instant_remote_process(
|
instant_remote_process(
|
||||||
command: $commands,
|
command: $commands,
|
||||||
|
|||||||
@@ -63,8 +63,6 @@ class CleanupNames extends Command
|
|||||||
|
|
||||||
public function handle(): int
|
public function handle(): int
|
||||||
{
|
{
|
||||||
$this->info('🔍 Scanning for invalid characters in name fields...');
|
|
||||||
|
|
||||||
if ($this->option('backup') && ! $this->option('dry-run')) {
|
if ($this->option('backup') && ! $this->option('dry-run')) {
|
||||||
$this->createBackup();
|
$this->createBackup();
|
||||||
}
|
}
|
||||||
@@ -75,7 +73,7 @@ class CleanupNames extends Command
|
|||||||
: $this->modelsToClean;
|
: $this->modelsToClean;
|
||||||
|
|
||||||
if ($modelFilter && ! isset($this->modelsToClean[$modelFilter])) {
|
if ($modelFilter && ! isset($this->modelsToClean[$modelFilter])) {
|
||||||
$this->error("❌ Unknown model: {$modelFilter}");
|
$this->error("Unknown model: {$modelFilter}");
|
||||||
$this->info('Available models: '.implode(', ', array_keys($this->modelsToClean)));
|
$this->info('Available models: '.implode(', ', array_keys($this->modelsToClean)));
|
||||||
|
|
||||||
return self::FAILURE;
|
return self::FAILURE;
|
||||||
@@ -88,19 +86,21 @@ class CleanupNames extends Command
|
|||||||
$this->processModel($modelName, $modelClass);
|
$this->processModel($modelName, $modelClass);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->displaySummary();
|
|
||||||
|
|
||||||
if (! $this->option('dry-run') && $this->totalCleaned > 0) {
|
if (! $this->option('dry-run') && $this->totalCleaned > 0) {
|
||||||
$this->logChanges();
|
$this->logChanges();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->option('dry-run')) {
|
||||||
|
$this->info("Name cleanup: would sanitize {$this->totalCleaned} records");
|
||||||
|
} else {
|
||||||
|
$this->info("Name cleanup: sanitized {$this->totalCleaned} records");
|
||||||
|
}
|
||||||
|
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function processModel(string $modelName, string $modelClass): void
|
protected function processModel(string $modelName, string $modelClass): void
|
||||||
{
|
{
|
||||||
$this->info("\n📋 Processing {$modelName}...");
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$records = $modelClass::all(['id', 'name']);
|
$records = $modelClass::all(['id', 'name']);
|
||||||
$cleaned = 0;
|
$cleaned = 0;
|
||||||
@@ -128,21 +128,17 @@ class CleanupNames extends Command
|
|||||||
$cleaned++;
|
$cleaned++;
|
||||||
$this->totalCleaned++;
|
$this->totalCleaned++;
|
||||||
|
|
||||||
$this->warn(" 🧹 {$modelName} #{$record->id}:");
|
// Only log in dry-run mode to preview changes
|
||||||
$this->line(' From: '.$this->truncate($originalName, 80));
|
if ($this->option('dry-run')) {
|
||||||
$this->line(' To: '.$this->truncate($sanitizedName, 80));
|
$this->warn(" 🧹 {$modelName} #{$record->id}:");
|
||||||
|
$this->line(' From: '.$this->truncate($originalName, 80));
|
||||||
|
$this->line(' To: '.$this->truncate($sanitizedName, 80));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($cleaned > 0) {
|
|
||||||
$action = $this->option('dry-run') ? 'would be sanitized' : 'sanitized';
|
|
||||||
$this->info(" ✅ {$cleaned}/{$records->count()} records {$action}");
|
|
||||||
} else {
|
|
||||||
$this->info(' ✨ No invalid characters found');
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->error(" ❌ Error processing {$modelName}: ".$e->getMessage());
|
$this->error("Error processing {$modelName}: ".$e->getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,28 +161,6 @@ class CleanupNames extends Command
|
|||||||
return $sanitized;
|
return $sanitized;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function displaySummary(): void
|
|
||||||
{
|
|
||||||
$this->info("\n".str_repeat('=', 60));
|
|
||||||
$this->info('📊 CLEANUP SUMMARY');
|
|
||||||
$this->info(str_repeat('=', 60));
|
|
||||||
|
|
||||||
$this->line("Records processed: {$this->totalProcessed}");
|
|
||||||
$this->line("Records with invalid characters: {$this->totalCleaned}");
|
|
||||||
|
|
||||||
if ($this->option('dry-run')) {
|
|
||||||
$this->warn("\n🔍 DRY RUN - No changes were made to the database");
|
|
||||||
$this->info('Run without --dry-run to apply these changes');
|
|
||||||
} else {
|
|
||||||
if ($this->totalCleaned > 0) {
|
|
||||||
$this->info("\n✅ Database successfully sanitized!");
|
|
||||||
$this->info('Changes logged to storage/logs/name-cleanup.log');
|
|
||||||
} else {
|
|
||||||
$this->info("\n✨ No cleanup needed - all names are valid!");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function logChanges(): void
|
protected function logChanges(): void
|
||||||
{
|
{
|
||||||
$logFile = storage_path('logs/name-cleanup.log');
|
$logFile = storage_path('logs/name-cleanup.log');
|
||||||
@@ -208,8 +182,6 @@ class CleanupNames extends Command
|
|||||||
|
|
||||||
protected function createBackup(): void
|
protected function createBackup(): void
|
||||||
{
|
{
|
||||||
$this->info('💾 Creating database backup...');
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$backupFile = storage_path('backups/name-cleanup-backup-'.now()->format('Y-m-d-H-i-s').'.sql');
|
$backupFile = storage_path('backups/name-cleanup-backup-'.now()->format('Y-m-d-H-i-s').'.sql');
|
||||||
|
|
||||||
@@ -229,15 +201,9 @@ class CleanupNames extends Command
|
|||||||
);
|
);
|
||||||
|
|
||||||
exec($command, $output, $returnCode);
|
exec($command, $output, $returnCode);
|
||||||
|
|
||||||
if ($returnCode === 0) {
|
|
||||||
$this->info("✅ Backup created: {$backupFile}");
|
|
||||||
} else {
|
|
||||||
$this->warn('⚠️ Backup creation may have failed. Proceeding anyway...');
|
|
||||||
}
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
$this->warn('⚠️ Could not create backup: '.$e->getMessage());
|
// Log failure but continue - backup is optional safeguard
|
||||||
$this->warn('Proceeding without backup...');
|
Log::warning('Name cleanup backup failed', ['error' => $e->getMessage()]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,10 +18,6 @@ class CleanupRedis extends Command
|
|||||||
$dryRun = $this->option('dry-run');
|
$dryRun = $this->option('dry-run');
|
||||||
$skipOverlapping = $this->option('skip-overlapping');
|
$skipOverlapping = $this->option('skip-overlapping');
|
||||||
|
|
||||||
if ($dryRun) {
|
|
||||||
$this->info('DRY RUN MODE - No data will be deleted');
|
|
||||||
}
|
|
||||||
|
|
||||||
$deletedCount = 0;
|
$deletedCount = 0;
|
||||||
$totalKeys = 0;
|
$totalKeys = 0;
|
||||||
|
|
||||||
@@ -29,8 +25,6 @@ class CleanupRedis extends Command
|
|||||||
$keys = $redis->keys('*');
|
$keys = $redis->keys('*');
|
||||||
$totalKeys = count($keys);
|
$totalKeys = count($keys);
|
||||||
|
|
||||||
$this->info("Scanning {$totalKeys} keys for cleanup...");
|
|
||||||
|
|
||||||
foreach ($keys as $key) {
|
foreach ($keys as $key) {
|
||||||
$keyWithoutPrefix = str_replace($prefix, '', $key);
|
$keyWithoutPrefix = str_replace($prefix, '', $key);
|
||||||
$type = $redis->command('type', [$keyWithoutPrefix]);
|
$type = $redis->command('type', [$keyWithoutPrefix]);
|
||||||
@@ -51,14 +45,12 @@ class CleanupRedis extends Command
|
|||||||
|
|
||||||
// Clean up overlapping queues if not skipped
|
// Clean up overlapping queues if not skipped
|
||||||
if (! $skipOverlapping) {
|
if (! $skipOverlapping) {
|
||||||
$this->info('Cleaning up overlapping queues...');
|
|
||||||
$overlappingCleaned = $this->cleanupOverlappingQueues($redis, $prefix, $dryRun);
|
$overlappingCleaned = $this->cleanupOverlappingQueues($redis, $prefix, $dryRun);
|
||||||
$deletedCount += $overlappingCleaned;
|
$deletedCount += $overlappingCleaned;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up stale cache locks (WithoutOverlapping middleware)
|
// Clean up stale cache locks (WithoutOverlapping middleware)
|
||||||
if ($this->option('clear-locks')) {
|
if ($this->option('clear-locks')) {
|
||||||
$this->info('Cleaning up stale cache locks...');
|
|
||||||
$locksCleaned = $this->cleanupCacheLocks($dryRun);
|
$locksCleaned = $this->cleanupCacheLocks($dryRun);
|
||||||
$deletedCount += $locksCleaned;
|
$deletedCount += $locksCleaned;
|
||||||
}
|
}
|
||||||
@@ -66,15 +58,14 @@ class CleanupRedis extends Command
|
|||||||
// Clean up stuck jobs (restart mode = aggressive, runtime mode = conservative)
|
// Clean up stuck jobs (restart mode = aggressive, runtime mode = conservative)
|
||||||
$isRestart = $this->option('restart');
|
$isRestart = $this->option('restart');
|
||||||
if ($isRestart || $this->option('clear-locks')) {
|
if ($isRestart || $this->option('clear-locks')) {
|
||||||
$this->info($isRestart ? 'Cleaning up stuck jobs (RESTART MODE - aggressive)...' : 'Checking for stuck jobs (runtime mode - conservative)...');
|
|
||||||
$jobsCleaned = $this->cleanupStuckJobs($redis, $prefix, $dryRun, $isRestart);
|
$jobsCleaned = $this->cleanupStuckJobs($redis, $prefix, $dryRun, $isRestart);
|
||||||
$deletedCount += $jobsCleaned;
|
$deletedCount += $jobsCleaned;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($dryRun) {
|
if ($dryRun) {
|
||||||
$this->info("DRY RUN: Would delete {$deletedCount} out of {$totalKeys} keys");
|
$this->info("Redis cleanup: would delete {$deletedCount} items");
|
||||||
} else {
|
} else {
|
||||||
$this->info("Deleted {$deletedCount} out of {$totalKeys} keys");
|
$this->info("Redis cleanup: deleted {$deletedCount} items");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,11 +76,8 @@ class CleanupRedis extends Command
|
|||||||
|
|
||||||
// Delete completed and failed jobs
|
// Delete completed and failed jobs
|
||||||
if (in_array($status, ['completed', 'failed'])) {
|
if (in_array($status, ['completed', 'failed'])) {
|
||||||
if ($dryRun) {
|
if (! $dryRun) {
|
||||||
$this->line("Would delete job: {$keyWithoutPrefix} (status: {$status})");
|
|
||||||
} else {
|
|
||||||
$redis->command('del', [$keyWithoutPrefix]);
|
$redis->command('del', [$keyWithoutPrefix]);
|
||||||
$this->line("Deleted job: {$keyWithoutPrefix} (status: {$status})");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -115,11 +103,8 @@ class CleanupRedis extends Command
|
|||||||
|
|
||||||
foreach ($patterns as $pattern => $description) {
|
foreach ($patterns as $pattern => $description) {
|
||||||
if (str_contains($keyWithoutPrefix, $pattern)) {
|
if (str_contains($keyWithoutPrefix, $pattern)) {
|
||||||
if ($dryRun) {
|
if (! $dryRun) {
|
||||||
$this->line("Would delete {$description}: {$keyWithoutPrefix}");
|
|
||||||
} else {
|
|
||||||
$redis->command('del', [$keyWithoutPrefix]);
|
$redis->command('del', [$keyWithoutPrefix]);
|
||||||
$this->line("Deleted {$description}: {$keyWithoutPrefix}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -132,11 +117,8 @@ class CleanupRedis extends Command
|
|||||||
$weekAgo = now()->subDays(7)->timestamp;
|
$weekAgo = now()->subDays(7)->timestamp;
|
||||||
|
|
||||||
if ($timestamp < $weekAgo) {
|
if ($timestamp < $weekAgo) {
|
||||||
if ($dryRun) {
|
if (! $dryRun) {
|
||||||
$this->line("Would delete old timestamped data: {$keyWithoutPrefix}");
|
|
||||||
} else {
|
|
||||||
$redis->command('del', [$keyWithoutPrefix]);
|
$redis->command('del', [$keyWithoutPrefix]);
|
||||||
$this->line("Deleted old timestamped data: {$keyWithoutPrefix}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -160,8 +142,6 @@ class CleanupRedis extends Command
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->info('Found '.count($queueKeys).' queue-related keys');
|
|
||||||
|
|
||||||
// Group queues by name pattern to find duplicates
|
// Group queues by name pattern to find duplicates
|
||||||
$queueGroups = [];
|
$queueGroups = [];
|
||||||
foreach ($queueKeys as $queueKey) {
|
foreach ($queueKeys as $queueKey) {
|
||||||
@@ -193,7 +173,6 @@ class CleanupRedis extends Command
|
|||||||
private function deduplicateQueueGroup($redis, $baseName, $keys, $dryRun)
|
private function deduplicateQueueGroup($redis, $baseName, $keys, $dryRun)
|
||||||
{
|
{
|
||||||
$cleanedCount = 0;
|
$cleanedCount = 0;
|
||||||
$this->line("Processing queue group: {$baseName} (".count($keys).' keys)');
|
|
||||||
|
|
||||||
// Sort keys to keep the most recent one
|
// Sort keys to keep the most recent one
|
||||||
usort($keys, function ($a, $b) {
|
usort($keys, function ($a, $b) {
|
||||||
@@ -244,11 +223,8 @@ class CleanupRedis extends Command
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($shouldDelete) {
|
if ($shouldDelete) {
|
||||||
if ($dryRun) {
|
if (! $dryRun) {
|
||||||
$this->line(" Would delete empty queue: {$redundantKey}");
|
|
||||||
} else {
|
|
||||||
$redis->command('del', [$redundantKey]);
|
$redis->command('del', [$redundantKey]);
|
||||||
$this->line(" Deleted empty queue: {$redundantKey}");
|
|
||||||
}
|
}
|
||||||
$cleanedCount++;
|
$cleanedCount++;
|
||||||
}
|
}
|
||||||
@@ -271,15 +247,12 @@ class CleanupRedis extends Command
|
|||||||
if (count($uniqueItems) < count($items)) {
|
if (count($uniqueItems) < count($items)) {
|
||||||
$duplicates = count($items) - count($uniqueItems);
|
$duplicates = count($items) - count($uniqueItems);
|
||||||
|
|
||||||
if ($dryRun) {
|
if (! $dryRun) {
|
||||||
$this->line(" Would remove {$duplicates} duplicate jobs from queue: {$queueKey}");
|
|
||||||
} else {
|
|
||||||
// Rebuild the list with unique items
|
// Rebuild the list with unique items
|
||||||
$redis->command('del', [$queueKey]);
|
$redis->command('del', [$queueKey]);
|
||||||
foreach (array_reverse($uniqueItems) as $item) {
|
foreach (array_reverse($uniqueItems) as $item) {
|
||||||
$redis->command('lpush', [$queueKey, $item]);
|
$redis->command('lpush', [$queueKey, $item]);
|
||||||
}
|
}
|
||||||
$this->line(" Removed {$duplicates} duplicate jobs from queue: {$queueKey}");
|
|
||||||
}
|
}
|
||||||
$cleanedCount += $duplicates;
|
$cleanedCount += $duplicates;
|
||||||
}
|
}
|
||||||
@@ -307,13 +280,9 @@ class CleanupRedis extends Command
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (empty($lockKeys)) {
|
if (empty($lockKeys)) {
|
||||||
$this->info(' No cache locks found.');
|
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->info(' Found '.count($lockKeys).' cache lock(s)');
|
|
||||||
|
|
||||||
foreach ($lockKeys as $lockKey) {
|
foreach ($lockKeys as $lockKey) {
|
||||||
// Check TTL to identify stale locks
|
// Check TTL to identify stale locks
|
||||||
$ttl = $redis->ttl($lockKey);
|
$ttl = $redis->ttl($lockKey);
|
||||||
@@ -326,18 +295,11 @@ class CleanupRedis extends Command
|
|||||||
$this->warn(" Would delete STALE lock (no expiration): {$lockKey}");
|
$this->warn(" Would delete STALE lock (no expiration): {$lockKey}");
|
||||||
} else {
|
} else {
|
||||||
$redis->del($lockKey);
|
$redis->del($lockKey);
|
||||||
$this->info(" ✓ Deleted STALE lock: {$lockKey}");
|
|
||||||
}
|
}
|
||||||
$cleanedCount++;
|
$cleanedCount++;
|
||||||
} elseif ($ttl > 0) {
|
|
||||||
$this->line(" Skipping active lock (expires in {$ttl}s): {$lockKey}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($cleanedCount === 0) {
|
|
||||||
$this->info(' No stale locks found (all locks have expiration set)');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $cleanedCount;
|
return $cleanedCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -453,17 +415,11 @@ class CleanupRedis extends Command
|
|||||||
$redis->command('hset', [$keyWithoutPrefix, 'status', 'failed']);
|
$redis->command('hset', [$keyWithoutPrefix, 'status', 'failed']);
|
||||||
$redis->command('hset', [$keyWithoutPrefix, 'failed_at', $now]);
|
$redis->command('hset', [$keyWithoutPrefix, 'failed_at', $now]);
|
||||||
$redis->command('hset', [$keyWithoutPrefix, 'exception', "Job cleaned up by cleanup:redis - {$reason}"]);
|
$redis->command('hset', [$keyWithoutPrefix, 'exception', "Job cleaned up by cleanup:redis - {$reason}"]);
|
||||||
|
|
||||||
$this->info(" ✓ Marked as FAILED: {$jobClass} (processing for ".round($processingTime / 60, 1).' min) - '.$reason);
|
|
||||||
}
|
}
|
||||||
$cleanedCount++;
|
$cleanedCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($cleanedCount === 0) {
|
|
||||||
$this->info($isRestart ? ' No jobs to clean up' : ' No stuck jobs found (all jobs running normally)');
|
|
||||||
}
|
|
||||||
|
|
||||||
return $cleanedCount;
|
return $cleanedCount;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class NotifyDemo extends Command
|
|||||||
php artisan app:demo-notify {channel}
|
php artisan app:demo-notify {channel}
|
||||||
</p>
|
</p>
|
||||||
<div class="my-1">
|
<div class="my-1">
|
||||||
<div class="text-yellow-500"> Channels: </div>
|
<div class="text-warning-500"> Channels: </div>
|
||||||
<ul class="text-coolify">
|
<ul class="text-coolify">
|
||||||
<li>email</li>
|
<li>email</li>
|
||||||
<li>discord</li>
|
<li>discord</li>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class SyncBunny extends Command
|
|||||||
*
|
*
|
||||||
* @var string
|
* @var string
|
||||||
*/
|
*/
|
||||||
protected $signature = 'sync:bunny {--templates} {--release} {--github-releases} {--nightly}';
|
protected $signature = 'sync:bunny {--templates} {--release} {--github-releases} {--github-versions} {--nightly}';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The console command description.
|
* The console command description.
|
||||||
@@ -50,6 +50,7 @@ class SyncBunny extends Command
|
|||||||
|
|
||||||
// Clone the repository
|
// Clone the repository
|
||||||
$this->info('Cloning coolify-cdn repository...');
|
$this->info('Cloning coolify-cdn repository...');
|
||||||
|
$output = [];
|
||||||
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
|
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
|
||||||
if ($returnCode !== 0) {
|
if ($returnCode !== 0) {
|
||||||
$this->error('Failed to clone repository: '.implode("\n", $output));
|
$this->error('Failed to clone repository: '.implode("\n", $output));
|
||||||
@@ -59,6 +60,7 @@ class SyncBunny extends Command
|
|||||||
|
|
||||||
// Create feature branch
|
// Create feature branch
|
||||||
$this->info('Creating feature branch...');
|
$this->info('Creating feature branch...');
|
||||||
|
$output = [];
|
||||||
exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
|
exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
|
||||||
if ($returnCode !== 0) {
|
if ($returnCode !== 0) {
|
||||||
$this->error('Failed to create branch: '.implode("\n", $output));
|
$this->error('Failed to create branch: '.implode("\n", $output));
|
||||||
@@ -70,12 +72,25 @@ class SyncBunny extends Command
|
|||||||
// Write releases.json
|
// Write releases.json
|
||||||
$this->info('Writing releases.json...');
|
$this->info('Writing releases.json...');
|
||||||
$releasesPath = "$tmpDir/json/releases.json";
|
$releasesPath = "$tmpDir/json/releases.json";
|
||||||
|
$releasesDir = dirname($releasesPath);
|
||||||
|
|
||||||
|
// Ensure directory exists
|
||||||
|
if (! is_dir($releasesDir)) {
|
||||||
|
$this->info("Creating directory: $releasesDir");
|
||||||
|
if (! mkdir($releasesDir, 0755, true)) {
|
||||||
|
$this->error("Failed to create directory: $releasesDir");
|
||||||
|
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$jsonContent = json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
$jsonContent = json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||||
$bytesWritten = file_put_contents($releasesPath, $jsonContent);
|
$bytesWritten = file_put_contents($releasesPath, $jsonContent);
|
||||||
|
|
||||||
if ($bytesWritten === false) {
|
if ($bytesWritten === false) {
|
||||||
$this->error("Failed to write releases.json to: $releasesPath");
|
$this->error("Failed to write releases.json to: $releasesPath");
|
||||||
$this->error('Possible reasons: directory does not exist, permission denied, or disk full.');
|
$this->error('Possible reasons: permission denied or disk full.');
|
||||||
exec('rm -rf '.escapeshellarg($tmpDir));
|
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -83,6 +98,7 @@ class SyncBunny extends Command
|
|||||||
|
|
||||||
// Stage and commit
|
// Stage and commit
|
||||||
$this->info('Committing changes...');
|
$this->info('Committing changes...');
|
||||||
|
$output = [];
|
||||||
exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json 2>&1', $output, $returnCode);
|
exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json 2>&1', $output, $returnCode);
|
||||||
if ($returnCode !== 0) {
|
if ($returnCode !== 0) {
|
||||||
$this->error('Failed to stage changes: '.implode("\n", $output));
|
$this->error('Failed to stage changes: '.implode("\n", $output));
|
||||||
@@ -120,6 +136,7 @@ class SyncBunny extends Command
|
|||||||
|
|
||||||
// Push to remote
|
// Push to remote
|
||||||
$this->info('Pushing branch to remote...');
|
$this->info('Pushing branch to remote...');
|
||||||
|
$output = [];
|
||||||
exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
|
exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
|
||||||
if ($returnCode !== 0) {
|
if ($returnCode !== 0) {
|
||||||
$this->error('Failed to push branch: '.implode("\n", $output));
|
$this->error('Failed to push branch: '.implode("\n", $output));
|
||||||
@@ -133,6 +150,7 @@ class SyncBunny extends Command
|
|||||||
$prTitle = 'Update releases.json - '.date('Y-m-d H:i:s');
|
$prTitle = 'Update releases.json - '.date('Y-m-d H:i:s');
|
||||||
$prBody = 'Automated update of releases.json with latest '.count($releases).' releases from GitHub API';
|
$prBody = 'Automated update of releases.json with latest '.count($releases).' releases from GitHub API';
|
||||||
$prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
|
$prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
|
||||||
|
$output = [];
|
||||||
exec($prCommand, $output, $returnCode);
|
exec($prCommand, $output, $returnCode);
|
||||||
|
|
||||||
// Clean up
|
// Clean up
|
||||||
@@ -158,6 +176,343 @@ class SyncBunny extends Command
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync both releases.json and versions.json to GitHub repository in one PR
|
||||||
|
*/
|
||||||
|
private function syncReleasesAndVersionsToGitHubRepo(string $versionsLocation, bool $nightly = false): bool
|
||||||
|
{
|
||||||
|
$this->info('Syncing releases.json and versions.json to GitHub repository...');
|
||||||
|
try {
|
||||||
|
// 1. Fetch releases from GitHub API
|
||||||
|
$this->info('Fetching releases from GitHub API...');
|
||||||
|
$response = Http::timeout(30)
|
||||||
|
->get('https://api.github.com/repos/coollabsio/coolify/releases', [
|
||||||
|
'per_page' => 30,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (! $response->successful()) {
|
||||||
|
$this->error('Failed to fetch releases from GitHub: '.$response->status());
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$releases = $response->json();
|
||||||
|
|
||||||
|
// 2. Read versions.json
|
||||||
|
if (! file_exists($versionsLocation)) {
|
||||||
|
$this->error("versions.json not found at: $versionsLocation");
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = file_get_contents($versionsLocation);
|
||||||
|
$versionsJson = json_decode($file, true);
|
||||||
|
$actualVersion = data_get($versionsJson, 'coolify.v4.version');
|
||||||
|
|
||||||
|
$timestamp = time();
|
||||||
|
$tmpDir = sys_get_temp_dir().'/coolify-cdn-combined-'.$timestamp;
|
||||||
|
$branchName = 'update-releases-and-versions-'.$timestamp;
|
||||||
|
$versionsTargetPath = $nightly ? 'json/versions-nightly.json' : 'json/versions.json';
|
||||||
|
|
||||||
|
// 3. Clone the repository
|
||||||
|
$this->info('Cloning coolify-cdn repository...');
|
||||||
|
$output = [];
|
||||||
|
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
|
||||||
|
if ($returnCode !== 0) {
|
||||||
|
$this->error('Failed to clone repository: '.implode("\n", $output));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Create feature branch
|
||||||
|
$this->info('Creating feature branch...');
|
||||||
|
$output = [];
|
||||||
|
exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
|
||||||
|
if ($returnCode !== 0) {
|
||||||
|
$this->error('Failed to create branch: '.implode("\n", $output));
|
||||||
|
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Write releases.json
|
||||||
|
$this->info('Writing releases.json...');
|
||||||
|
$releasesPath = "$tmpDir/json/releases.json";
|
||||||
|
$releasesDir = dirname($releasesPath);
|
||||||
|
|
||||||
|
if (! is_dir($releasesDir)) {
|
||||||
|
if (! mkdir($releasesDir, 0755, true)) {
|
||||||
|
$this->error("Failed to create directory: $releasesDir");
|
||||||
|
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$releasesJsonContent = json_encode($releases, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||||
|
if (file_put_contents($releasesPath, $releasesJsonContent) === false) {
|
||||||
|
$this->error("Failed to write releases.json to: $releasesPath");
|
||||||
|
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Write versions.json
|
||||||
|
$this->info('Writing versions.json...');
|
||||||
|
$versionsPath = "$tmpDir/$versionsTargetPath";
|
||||||
|
$versionsDir = dirname($versionsPath);
|
||||||
|
|
||||||
|
if (! is_dir($versionsDir)) {
|
||||||
|
if (! mkdir($versionsDir, 0755, true)) {
|
||||||
|
$this->error("Failed to create directory: $versionsDir");
|
||||||
|
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$versionsJsonContent = json_encode($versionsJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||||
|
if (file_put_contents($versionsPath, $versionsJsonContent) === false) {
|
||||||
|
$this->error("Failed to write versions.json to: $versionsPath");
|
||||||
|
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Stage both files
|
||||||
|
$this->info('Staging changes...');
|
||||||
|
$output = [];
|
||||||
|
exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json '.escapeshellarg($versionsTargetPath).' 2>&1', $output, $returnCode);
|
||||||
|
if ($returnCode !== 0) {
|
||||||
|
$this->error('Failed to stage changes: '.implode("\n", $output));
|
||||||
|
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Check for changes
|
||||||
|
$this->info('Checking for changes...');
|
||||||
|
$statusOutput = [];
|
||||||
|
exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain 2>&1', $statusOutput, $returnCode);
|
||||||
|
if ($returnCode !== 0) {
|
||||||
|
$this->error('Failed to check repository status: '.implode("\n", $statusOutput));
|
||||||
|
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty(array_filter($statusOutput))) {
|
||||||
|
$this->info('Both files are already up to date. No changes to commit.');
|
||||||
|
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Commit changes
|
||||||
|
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
|
||||||
|
$commitMessage = "Update releases.json and $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
|
||||||
|
$output = [];
|
||||||
|
exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
|
||||||
|
if ($returnCode !== 0) {
|
||||||
|
$this->error('Failed to commit changes: '.implode("\n", $output));
|
||||||
|
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. Push to remote
|
||||||
|
$this->info('Pushing branch to remote...');
|
||||||
|
$output = [];
|
||||||
|
exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
|
||||||
|
if ($returnCode !== 0) {
|
||||||
|
$this->error('Failed to push branch: '.implode("\n", $output));
|
||||||
|
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11. Create pull request
|
||||||
|
$this->info('Creating pull request...');
|
||||||
|
$prTitle = "Update releases.json and $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
|
||||||
|
$prBody = "Automated update:\n- releases.json with latest ".count($releases)." releases from GitHub API\n- $envLabel versions.json to version $actualVersion";
|
||||||
|
$prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
|
||||||
|
$output = [];
|
||||||
|
exec($prCommand, $output, $returnCode);
|
||||||
|
|
||||||
|
// 12. Clean up
|
||||||
|
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||||
|
|
||||||
|
if ($returnCode !== 0) {
|
||||||
|
$this->error('Failed to create PR: '.implode("\n", $output));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('Pull request created successfully!');
|
||||||
|
if (! empty($output)) {
|
||||||
|
$this->info('PR URL: '.implode("\n", $output));
|
||||||
|
}
|
||||||
|
$this->info("Version synced: $actualVersion");
|
||||||
|
$this->info('Total releases synced: '.count($releases));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->error('Error syncing to GitHub: '.$e->getMessage());
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync versions.json to GitHub repository via PR
|
||||||
|
*/
|
||||||
|
private function syncVersionsToGitHubRepo(string $versionsLocation, bool $nightly = false): bool
|
||||||
|
{
|
||||||
|
$this->info('Syncing versions.json to GitHub repository...');
|
||||||
|
try {
|
||||||
|
if (! file_exists($versionsLocation)) {
|
||||||
|
$this->error("versions.json not found at: $versionsLocation");
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$file = file_get_contents($versionsLocation);
|
||||||
|
$json = json_decode($file, true);
|
||||||
|
$actualVersion = data_get($json, 'coolify.v4.version');
|
||||||
|
|
||||||
|
$timestamp = time();
|
||||||
|
$tmpDir = sys_get_temp_dir().'/coolify-cdn-versions-'.$timestamp;
|
||||||
|
$branchName = 'update-versions-'.$timestamp;
|
||||||
|
$targetPath = $nightly ? 'json/versions-nightly.json' : 'json/versions.json';
|
||||||
|
|
||||||
|
// Clone the repository
|
||||||
|
$this->info('Cloning coolify-cdn repository...');
|
||||||
|
exec('gh repo clone coollabsio/coolify-cdn '.escapeshellarg($tmpDir).' 2>&1', $output, $returnCode);
|
||||||
|
if ($returnCode !== 0) {
|
||||||
|
$this->error('Failed to clone repository: '.implode("\n", $output));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create feature branch
|
||||||
|
$this->info('Creating feature branch...');
|
||||||
|
$output = [];
|
||||||
|
exec('cd '.escapeshellarg($tmpDir).' && git checkout -b '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
|
||||||
|
if ($returnCode !== 0) {
|
||||||
|
$this->error('Failed to create branch: '.implode("\n", $output));
|
||||||
|
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write versions.json
|
||||||
|
$this->info('Writing versions.json...');
|
||||||
|
$versionsPath = "$tmpDir/$targetPath";
|
||||||
|
$versionsDir = dirname($versionsPath);
|
||||||
|
|
||||||
|
// Ensure directory exists
|
||||||
|
if (! is_dir($versionsDir)) {
|
||||||
|
$this->info("Creating directory: $versionsDir");
|
||||||
|
if (! mkdir($versionsDir, 0755, true)) {
|
||||||
|
$this->error("Failed to create directory: $versionsDir");
|
||||||
|
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$jsonContent = json_encode($json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||||
|
$bytesWritten = file_put_contents($versionsPath, $jsonContent);
|
||||||
|
|
||||||
|
if ($bytesWritten === false) {
|
||||||
|
$this->error("Failed to write versions.json to: $versionsPath");
|
||||||
|
$this->error('Possible reasons: permission denied or disk full.');
|
||||||
|
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage and commit
|
||||||
|
$this->info('Committing changes...');
|
||||||
|
$output = [];
|
||||||
|
exec('cd '.escapeshellarg($tmpDir).' && git add '.escapeshellarg($targetPath).' 2>&1', $output, $returnCode);
|
||||||
|
if ($returnCode !== 0) {
|
||||||
|
$this->error('Failed to stage changes: '.implode("\n", $output));
|
||||||
|
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('Checking for changes...');
|
||||||
|
$statusOutput = [];
|
||||||
|
exec('cd '.escapeshellarg($tmpDir).' && git status --porcelain '.escapeshellarg($targetPath).' 2>&1', $statusOutput, $returnCode);
|
||||||
|
if ($returnCode !== 0) {
|
||||||
|
$this->error('Failed to check repository status: '.implode("\n", $statusOutput));
|
||||||
|
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty(array_filter($statusOutput))) {
|
||||||
|
$this->info('versions.json is already up to date. No changes to commit.');
|
||||||
|
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
|
||||||
|
$commitMessage = "Update $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
|
||||||
|
$output = [];
|
||||||
|
exec('cd '.escapeshellarg($tmpDir).' && git commit -m '.escapeshellarg($commitMessage).' 2>&1', $output, $returnCode);
|
||||||
|
if ($returnCode !== 0) {
|
||||||
|
$this->error('Failed to commit changes: '.implode("\n", $output));
|
||||||
|
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push to remote
|
||||||
|
$this->info('Pushing branch to remote...');
|
||||||
|
$output = [];
|
||||||
|
exec('cd '.escapeshellarg($tmpDir).' && git push origin '.escapeshellarg($branchName).' 2>&1', $output, $returnCode);
|
||||||
|
if ($returnCode !== 0) {
|
||||||
|
$this->error('Failed to push branch: '.implode("\n", $output));
|
||||||
|
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create pull request
|
||||||
|
$this->info('Creating pull request...');
|
||||||
|
$prTitle = "Update $envLabel versions.json to $actualVersion - ".date('Y-m-d H:i:s');
|
||||||
|
$prBody = "Automated update of $envLabel versions.json to version $actualVersion";
|
||||||
|
$output = [];
|
||||||
|
$prCommand = 'gh pr create --repo coollabsio/coolify-cdn --title '.escapeshellarg($prTitle).' --body '.escapeshellarg($prBody).' --base main --head '.escapeshellarg($branchName).' 2>&1';
|
||||||
|
exec($prCommand, $output, $returnCode);
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
exec('rm -rf '.escapeshellarg($tmpDir));
|
||||||
|
|
||||||
|
if ($returnCode !== 0) {
|
||||||
|
$this->error('Failed to create PR: '.implode("\n", $output));
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('Pull request created successfully!');
|
||||||
|
if (! empty($output)) {
|
||||||
|
$this->info('PR URL: '.implode("\n", $output));
|
||||||
|
}
|
||||||
|
$this->info("Version synced: $actualVersion");
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->error('Error syncing versions.json: '.$e->getMessage());
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Execute the console command.
|
* Execute the console command.
|
||||||
*/
|
*/
|
||||||
@@ -167,6 +522,7 @@ class SyncBunny extends Command
|
|||||||
$only_template = $this->option('templates');
|
$only_template = $this->option('templates');
|
||||||
$only_version = $this->option('release');
|
$only_version = $this->option('release');
|
||||||
$only_github_releases = $this->option('github-releases');
|
$only_github_releases = $this->option('github-releases');
|
||||||
|
$only_github_versions = $this->option('github-versions');
|
||||||
$nightly = $this->option('nightly');
|
$nightly = $this->option('nightly');
|
||||||
$bunny_cdn = 'https://cdn.coollabs.io';
|
$bunny_cdn = 'https://cdn.coollabs.io';
|
||||||
$bunny_cdn_path = 'coolify';
|
$bunny_cdn_path = 'coolify';
|
||||||
@@ -224,7 +580,7 @@ class SyncBunny extends Command
|
|||||||
$install_script_location = "$parent_dir/other/nightly/$install_script";
|
$install_script_location = "$parent_dir/other/nightly/$install_script";
|
||||||
$versions_location = "$parent_dir/other/nightly/$versions";
|
$versions_location = "$parent_dir/other/nightly/$versions";
|
||||||
}
|
}
|
||||||
if (! $only_template && ! $only_version && ! $only_github_releases) {
|
if (! $only_template && ! $only_version && ! $only_github_releases && ! $only_github_versions) {
|
||||||
if ($nightly) {
|
if ($nightly) {
|
||||||
$this->info('About to sync files NIGHTLY (docker-compose.prod.yaml, upgrade.sh, install.sh, etc) to BunnyCDN.');
|
$this->info('About to sync files NIGHTLY (docker-compose.prod.yaml, upgrade.sh, install.sh, etc) to BunnyCDN.');
|
||||||
} else {
|
} else {
|
||||||
@@ -250,25 +606,47 @@ class SyncBunny extends Command
|
|||||||
return;
|
return;
|
||||||
} elseif ($only_version) {
|
} elseif ($only_version) {
|
||||||
if ($nightly) {
|
if ($nightly) {
|
||||||
$this->info('About to sync NIGHLTY versions.json to BunnyCDN.');
|
$this->info('About to sync NIGHTLY versions.json to BunnyCDN and create GitHub PR.');
|
||||||
} else {
|
} else {
|
||||||
$this->info('About to sync PRODUCTION versions.json to BunnyCDN.');
|
$this->info('About to sync PRODUCTION versions.json to BunnyCDN and create GitHub PR.');
|
||||||
}
|
}
|
||||||
$file = file_get_contents($versions_location);
|
$file = file_get_contents($versions_location);
|
||||||
$json = json_decode($file, true);
|
$json = json_decode($file, true);
|
||||||
$actual_version = data_get($json, 'coolify.v4.version');
|
$actual_version = data_get($json, 'coolify.v4.version');
|
||||||
|
|
||||||
$confirmed = confirm("Are you sure you want to sync to {$actual_version}?");
|
$this->info("Version: {$actual_version}");
|
||||||
|
$this->info('This will:');
|
||||||
|
$this->info(' 1. Sync versions.json to BunnyCDN (deprecated but still supported)');
|
||||||
|
$this->info(' 2. Create ONE GitHub PR with both releases.json and versions.json');
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
$confirmed = confirm('Are you sure you want to proceed?');
|
||||||
if (! $confirmed) {
|
if (! $confirmed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync versions.json to BunnyCDN
|
// 1. Sync versions.json to BunnyCDN (deprecated but still needed)
|
||||||
|
$this->info('Step 1/2: Syncing versions.json to BunnyCDN...');
|
||||||
Http::pool(fn (Pool $pool) => [
|
Http::pool(fn (Pool $pool) => [
|
||||||
$pool->storage(fileName: $versions_location)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$versions"),
|
$pool->storage(fileName: $versions_location)->put("/$bunny_cdn_storage_name/$bunny_cdn_path/$versions"),
|
||||||
$pool->purge("$bunny_cdn/$bunny_cdn_path/$versions"),
|
$pool->purge("$bunny_cdn/$bunny_cdn_path/$versions"),
|
||||||
]);
|
]);
|
||||||
$this->info('versions.json uploaded & purged...');
|
$this->info('✓ versions.json uploaded & purged to BunnyCDN');
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
// 2. Create GitHub PR with both releases.json and versions.json
|
||||||
|
$this->info('Step 2/2: Creating GitHub PR with releases.json and versions.json...');
|
||||||
|
$githubSuccess = $this->syncReleasesAndVersionsToGitHubRepo($versions_location, $nightly);
|
||||||
|
if ($githubSuccess) {
|
||||||
|
$this->info('✓ GitHub PR created successfully with both files');
|
||||||
|
} else {
|
||||||
|
$this->error('✗ Failed to create GitHub PR');
|
||||||
|
}
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
$this->info('=== Summary ===');
|
||||||
|
$this->info('BunnyCDN sync: ✓ Complete');
|
||||||
|
$this->info('GitHub PR: '.($githubSuccess ? '✓ Created (releases.json + versions.json)' : '✗ Failed'));
|
||||||
|
|
||||||
return;
|
return;
|
||||||
} elseif ($only_github_releases) {
|
} elseif ($only_github_releases) {
|
||||||
@@ -281,6 +659,22 @@ class SyncBunny extends Command
|
|||||||
// Sync releases to GitHub repository
|
// Sync releases to GitHub repository
|
||||||
$this->syncReleasesToGitHubRepo();
|
$this->syncReleasesToGitHubRepo();
|
||||||
|
|
||||||
|
return;
|
||||||
|
} elseif ($only_github_versions) {
|
||||||
|
$envLabel = $nightly ? 'NIGHTLY' : 'PRODUCTION';
|
||||||
|
$file = file_get_contents($versions_location);
|
||||||
|
$json = json_decode($file, true);
|
||||||
|
$actual_version = data_get($json, 'coolify.v4.version');
|
||||||
|
|
||||||
|
$this->info("About to sync $envLabel versions.json ($actual_version) to GitHub repository.");
|
||||||
|
$confirmed = confirm('Are you sure you want to sync versions.json via GitHub PR?');
|
||||||
|
if (! $confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync versions.json to GitHub repository
|
||||||
|
$this->syncVersionsToGitHubRepo($versions_location, $nightly);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
namespace App\Console;
|
namespace App\Console;
|
||||||
|
|
||||||
use App\Jobs\CheckAndStartSentinelJob;
|
|
||||||
use App\Jobs\CheckForUpdatesJob;
|
use App\Jobs\CheckForUpdatesJob;
|
||||||
use App\Jobs\CheckHelperImageJob;
|
use App\Jobs\CheckHelperImageJob;
|
||||||
use App\Jobs\CheckTraefikVersionJob;
|
use App\Jobs\CheckTraefikVersionJob;
|
||||||
use App\Jobs\CleanupInstanceStuffsJob;
|
use App\Jobs\CleanupInstanceStuffsJob;
|
||||||
|
use App\Jobs\CleanupOrphanedPreviewContainersJob;
|
||||||
use App\Jobs\PullChangelog;
|
use App\Jobs\PullChangelog;
|
||||||
use App\Jobs\PullTemplatesFromCDN;
|
use App\Jobs\PullTemplatesFromCDN;
|
||||||
use App\Jobs\RegenerateSslCertJob;
|
use App\Jobs\RegenerateSslCertJob;
|
||||||
@@ -14,16 +14,11 @@ use App\Jobs\ScheduledJobManager;
|
|||||||
use App\Jobs\ServerManagerJob;
|
use App\Jobs\ServerManagerJob;
|
||||||
use App\Jobs\UpdateCoolifyJob;
|
use App\Jobs\UpdateCoolifyJob;
|
||||||
use App\Models\InstanceSettings;
|
use App\Models\InstanceSettings;
|
||||||
use App\Models\Server;
|
|
||||||
use App\Models\Team;
|
|
||||||
use Illuminate\Console\Scheduling\Schedule;
|
use Illuminate\Console\Scheduling\Schedule;
|
||||||
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
|
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
|
|
||||||
class Kernel extends ConsoleKernel
|
class Kernel extends ConsoleKernel
|
||||||
{
|
{
|
||||||
private $allServers;
|
|
||||||
|
|
||||||
private Schedule $scheduleInstance;
|
private Schedule $scheduleInstance;
|
||||||
|
|
||||||
private InstanceSettings $settings;
|
private InstanceSettings $settings;
|
||||||
@@ -35,8 +30,6 @@ class Kernel extends ConsoleKernel
|
|||||||
protected function schedule(Schedule $schedule): void
|
protected function schedule(Schedule $schedule): void
|
||||||
{
|
{
|
||||||
$this->scheduleInstance = $schedule;
|
$this->scheduleInstance = $schedule;
|
||||||
$this->allServers = Server::where('ip', '!=', '1.2.3.4');
|
|
||||||
|
|
||||||
$this->settings = instanceSettings();
|
$this->settings = instanceSettings();
|
||||||
$this->updateCheckFrequency = $this->settings->update_check_frequency ?: '0 * * * *';
|
$this->updateCheckFrequency = $this->settings->update_check_frequency ?: '0 * * * *';
|
||||||
|
|
||||||
@@ -88,29 +81,14 @@ class Kernel extends ConsoleKernel
|
|||||||
|
|
||||||
$this->scheduleInstance->command('cleanup:database --yes')->daily();
|
$this->scheduleInstance->command('cleanup:database --yes')->daily();
|
||||||
$this->scheduleInstance->command('uploads:clear')->everyTwoMinutes();
|
$this->scheduleInstance->command('uploads:clear')->everyTwoMinutes();
|
||||||
|
|
||||||
|
// Cleanup orphaned PR preview containers daily
|
||||||
|
$this->scheduleInstance->job(new CleanupOrphanedPreviewContainersJob)->daily()->onOneServer();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function pullImages(): void
|
private function pullImages(): void
|
||||||
{
|
{
|
||||||
if (isCloud()) {
|
|
||||||
$servers = $this->allServers->whereRelation('team.subscription', 'stripe_invoice_paid', true)->whereRelation('settings', 'is_usable', true)->whereRelation('settings', 'is_reachable', true)->get();
|
|
||||||
$own = Team::find(0)->servers;
|
|
||||||
$servers = $servers->merge($own);
|
|
||||||
} else {
|
|
||||||
$servers = $this->allServers->whereRelation('settings', 'is_usable', true)->whereRelation('settings', 'is_reachable', true)->get();
|
|
||||||
}
|
|
||||||
foreach ($servers as $server) {
|
|
||||||
try {
|
|
||||||
if ($server->isSentinelEnabled()) {
|
|
||||||
$this->scheduleInstance->job(function () use ($server) {
|
|
||||||
CheckAndStartSentinelJob::dispatch($server);
|
|
||||||
})->cron($this->updateCheckFrequency)->timezone($this->instanceTimezone)->onOneServer();
|
|
||||||
}
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
Log::error('Error pulling images: '.$e->getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$this->scheduleInstance->job(new CheckHelperImageJob)
|
$this->scheduleInstance->job(new CheckHelperImageJob)
|
||||||
->cron($this->updateCheckFrequency)
|
->cron($this->updateCheckFrequency)
|
||||||
->timezone($this->instanceTimezone)
|
->timezone($this->instanceTimezone)
|
||||||
|
|||||||
@@ -14,12 +14,15 @@ class ProxyStatusChangedUI implements ShouldBroadcast
|
|||||||
|
|
||||||
public ?int $teamId = null;
|
public ?int $teamId = null;
|
||||||
|
|
||||||
public function __construct(?int $teamId = null)
|
public ?int $activityId = null;
|
||||||
|
|
||||||
|
public function __construct(?int $teamId = null, ?int $activityId = null)
|
||||||
{
|
{
|
||||||
if (is_null($teamId) && auth()->check() && auth()->user()->currentTeam()) {
|
if (is_null($teamId) && auth()->check() && auth()->user()->currentTeam()) {
|
||||||
$teamId = auth()->user()->currentTeam()->id;
|
$teamId = auth()->user()->currentTeam()->id;
|
||||||
}
|
}
|
||||||
$this->teamId = $teamId;
|
$this->teamId = $teamId;
|
||||||
|
$this->activityId = $activityId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function broadcastOn(): array
|
public function broadcastOn(): array
|
||||||
|
|||||||
@@ -17,17 +17,23 @@ class RestoreJobFinished
|
|||||||
$tmpPath = data_get($data, 'tmpPath');
|
$tmpPath = data_get($data, 'tmpPath');
|
||||||
$container = data_get($data, 'container');
|
$container = data_get($data, 'container');
|
||||||
$serverId = data_get($data, 'serverId');
|
$serverId = data_get($data, 'serverId');
|
||||||
if (filled($scriptPath) && filled($tmpPath) && filled($container) && filled($serverId)) {
|
|
||||||
if (str($tmpPath)->startsWith('/tmp/')
|
if (filled($container) && filled($serverId)) {
|
||||||
&& str($scriptPath)->startsWith('/tmp/')
|
$commands = [];
|
||||||
&& ! str($tmpPath)->contains('..')
|
|
||||||
&& ! str($scriptPath)->contains('..')
|
if (isSafeTmpPath($scriptPath)) {
|
||||||
&& strlen($tmpPath) > 5 // longer than just "/tmp/"
|
$commands[] = 'docker exec '.escapeshellarg($container)." sh -c 'rm ".escapeshellarg($scriptPath)." 2>/dev/null || true'";
|
||||||
&& strlen($scriptPath) > 5
|
}
|
||||||
) {
|
|
||||||
$commands[] = "docker exec {$container} sh -c 'rm {$scriptPath}'";
|
if (isSafeTmpPath($tmpPath)) {
|
||||||
$commands[] = "docker exec {$container} sh -c 'rm {$tmpPath}'";
|
$commands[] = 'docker exec '.escapeshellarg($container)." sh -c 'rm ".escapeshellarg($tmpPath)." 2>/dev/null || true'";
|
||||||
instant_remote_process($commands, Server::find($serverId), throwError: true);
|
}
|
||||||
|
|
||||||
|
if (! empty($commands)) {
|
||||||
|
$server = Server::find($serverId);
|
||||||
|
if ($server) {
|
||||||
|
instant_remote_process($commands, $server, throwError: false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
56
app/Events/S3RestoreJobFinished.php
Normal file
56
app/Events/S3RestoreJobFinished.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Events;
|
||||||
|
|
||||||
|
use App\Models\Server;
|
||||||
|
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class S3RestoreJobFinished
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct($data)
|
||||||
|
{
|
||||||
|
$containerName = data_get($data, 'containerName');
|
||||||
|
$serverTmpPath = data_get($data, 'serverTmpPath');
|
||||||
|
$scriptPath = data_get($data, 'scriptPath');
|
||||||
|
$containerTmpPath = data_get($data, 'containerTmpPath');
|
||||||
|
$container = data_get($data, 'container');
|
||||||
|
$serverId = data_get($data, 'serverId');
|
||||||
|
|
||||||
|
// Most cleanup now happens inline during restore process
|
||||||
|
// This acts as a safety net for edge cases (errors, interruptions)
|
||||||
|
if (filled($serverId)) {
|
||||||
|
$commands = [];
|
||||||
|
|
||||||
|
// Ensure helper container is removed (may already be gone from inline cleanup)
|
||||||
|
if (filled($containerName)) {
|
||||||
|
$commands[] = 'docker rm -f '.escapeshellarg($containerName).' 2>/dev/null || true';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up server temp file if still exists (should already be cleaned)
|
||||||
|
if (isSafeTmpPath($serverTmpPath)) {
|
||||||
|
$commands[] = 'rm -f '.escapeshellarg($serverTmpPath).' 2>/dev/null || true';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up any remaining files in database container (may already be cleaned)
|
||||||
|
if (filled($container)) {
|
||||||
|
if (isSafeTmpPath($containerTmpPath)) {
|
||||||
|
$commands[] = 'docker exec '.escapeshellarg($container).' rm -f '.escapeshellarg($containerTmpPath).' 2>/dev/null || true';
|
||||||
|
}
|
||||||
|
if (isSafeTmpPath($scriptPath)) {
|
||||||
|
$commands[] = 'docker exec '.escapeshellarg($container).' rm -f '.escapeshellarg($scriptPath).' 2>/dev/null || true';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($commands)) {
|
||||||
|
$server = Server::find($serverId);
|
||||||
|
if ($server) {
|
||||||
|
instant_remote_process($commands, $server, throwError: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
app/Exceptions/RateLimitException.php
Normal file
15
app/Exceptions/RateLimitException.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Exceptions;
|
||||||
|
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
class RateLimitException extends Exception
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
string $message = 'Rate limit exceeded.',
|
||||||
|
public readonly ?int $retryAfter = null
|
||||||
|
) {
|
||||||
|
parent::__construct($message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -149,7 +149,7 @@ class SshMultiplexingHelper
|
|||||||
return $scp_command;
|
return $scp_command;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function generateSshCommand(Server $server, string $command)
|
public static function generateSshCommand(Server $server, string $command, bool $disableMultiplexing = false)
|
||||||
{
|
{
|
||||||
if ($server->settings->force_disabled) {
|
if ($server->settings->force_disabled) {
|
||||||
throw new \RuntimeException('Server is disabled.');
|
throw new \RuntimeException('Server is disabled.');
|
||||||
@@ -168,7 +168,7 @@ class SshMultiplexingHelper
|
|||||||
$ssh_command = "timeout $timeout ssh ";
|
$ssh_command = "timeout $timeout ssh ";
|
||||||
|
|
||||||
$multiplexingSuccessful = false;
|
$multiplexingSuccessful = false;
|
||||||
if (self::isMultiplexingEnabled()) {
|
if (! $disableMultiplexing && self::isMultiplexingEnabled()) {
|
||||||
try {
|
try {
|
||||||
$multiplexingSuccessful = self::ensureMultiplexedConnection($server);
|
$multiplexingSuccessful = self::ensureMultiplexedConnection($server);
|
||||||
if ($multiplexingSuccessful) {
|
if ($multiplexingSuccessful) {
|
||||||
|
|||||||
@@ -192,6 +192,7 @@ class ApplicationsController extends Controller
|
|||||||
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
|
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
|
||||||
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
|
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
|
||||||
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
||||||
|
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
@@ -342,6 +343,7 @@ class ApplicationsController extends Controller
|
|||||||
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
|
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
|
||||||
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
|
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
|
||||||
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
||||||
|
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
@@ -492,6 +494,7 @@ class ApplicationsController extends Controller
|
|||||||
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
|
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
|
||||||
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
|
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
|
||||||
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
||||||
|
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
@@ -626,6 +629,7 @@ class ApplicationsController extends Controller
|
|||||||
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
|
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
|
||||||
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
|
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
|
||||||
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
||||||
|
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
@@ -757,6 +761,7 @@ class ApplicationsController extends Controller
|
|||||||
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
|
'http_basic_auth_password' => ['type' => 'string', 'nullable' => true, 'description' => 'Password for HTTP Basic Authentication'],
|
||||||
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
|
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
|
||||||
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
'force_domain_override' => ['type' => 'boolean', 'description' => 'Force domain usage even if conflicts are detected. Default is false.'],
|
||||||
|
'autogenerate_domain' => ['type' => 'boolean', 'default' => true, 'description' => 'If true and domains is empty, auto-generate a domain using the server\'s wildcard domain or sslip.io fallback. Default: true.'],
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
@@ -927,7 +932,7 @@ class ApplicationsController extends Controller
|
|||||||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||||
return $return;
|
return $return;
|
||||||
}
|
}
|
||||||
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override'];
|
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'is_static', 'domains', 'git_repository', 'git_branch', 'git_commit_sha', 'private_key_uuid', 'docker_registry_image_name', 'docker_registry_image_tag', 'build_pack', 'install_command', 'build_command', 'start_command', 'ports_exposes', 'ports_mappings', 'base_directory', 'publish_directory', 'health_check_enabled', 'health_check_path', 'health_check_port', 'health_check_host', 'health_check_method', 'health_check_return_code', 'health_check_scheme', 'health_check_response_text', 'health_check_interval', 'health_check_timeout', 'health_check_retries', 'health_check_start_period', 'limits_memory', 'limits_memory_swap', 'limits_memory_swappiness', 'limits_memory_reservation', 'limits_cpus', 'limits_cpuset', 'limits_cpu_shares', 'custom_labels', 'custom_docker_run_options', 'post_deployment_command', 'post_deployment_command_container', 'pre_deployment_command', 'pre_deployment_command_container', 'manual_webhook_secret_github', 'manual_webhook_secret_gitlab', 'manual_webhook_secret_bitbucket', 'manual_webhook_secret_gitea', 'redirect', 'github_app_uuid', 'instant_deploy', 'dockerfile', 'docker_compose_location', 'docker_compose_raw', 'docker_compose_custom_start_command', 'docker_compose_custom_build_command', 'docker_compose_domains', 'watch_paths', 'use_build_server', 'static_image', 'custom_nginx_configuration', 'is_http_basic_auth_enabled', 'http_basic_auth_username', 'http_basic_auth_password', 'connect_to_docker_network', 'force_domain_override', 'autogenerate_domain'];
|
||||||
|
|
||||||
$validator = customApiValidator($request->all(), [
|
$validator = customApiValidator($request->all(), [
|
||||||
'name' => 'string|max:255',
|
'name' => 'string|max:255',
|
||||||
@@ -940,6 +945,7 @@ class ApplicationsController extends Controller
|
|||||||
'is_http_basic_auth_enabled' => 'boolean',
|
'is_http_basic_auth_enabled' => 'boolean',
|
||||||
'http_basic_auth_username' => 'string|nullable',
|
'http_basic_auth_username' => 'string|nullable',
|
||||||
'http_basic_auth_password' => 'string|nullable',
|
'http_basic_auth_password' => 'string|nullable',
|
||||||
|
'autogenerate_domain' => 'boolean',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||||
@@ -964,6 +970,7 @@ class ApplicationsController extends Controller
|
|||||||
}
|
}
|
||||||
$serverUuid = $request->server_uuid;
|
$serverUuid = $request->server_uuid;
|
||||||
$fqdn = $request->domains;
|
$fqdn = $request->domains;
|
||||||
|
$autogenerateDomain = $request->boolean('autogenerate_domain', true);
|
||||||
$instantDeploy = $request->instant_deploy;
|
$instantDeploy = $request->instant_deploy;
|
||||||
$githubAppUuid = $request->github_app_uuid;
|
$githubAppUuid = $request->github_app_uuid;
|
||||||
$useBuildServer = $request->use_build_server;
|
$useBuildServer = $request->use_build_server;
|
||||||
@@ -1087,6 +1094,11 @@ class ApplicationsController extends Controller
|
|||||||
$application->settings->save();
|
$application->settings->save();
|
||||||
}
|
}
|
||||||
$application->refresh();
|
$application->refresh();
|
||||||
|
// Auto-generate domain if requested and no custom domain provided
|
||||||
|
if ($autogenerateDomain && blank($fqdn)) {
|
||||||
|
$application->fqdn = generateUrl(server: $server, random: $application->uuid);
|
||||||
|
$application->save();
|
||||||
|
}
|
||||||
if ($application->settings->is_container_label_readonly_enabled) {
|
if ($application->settings->is_container_label_readonly_enabled) {
|
||||||
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
|
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
|
||||||
$application->save();
|
$application->save();
|
||||||
@@ -1115,7 +1127,7 @@ class ApplicationsController extends Controller
|
|||||||
|
|
||||||
return response()->json(serializeApiResponse([
|
return response()->json(serializeApiResponse([
|
||||||
'uuid' => data_get($application, 'uuid'),
|
'uuid' => data_get($application, 'uuid'),
|
||||||
'domains' => data_get($application, 'domains'),
|
'domains' => data_get($application, 'fqdn'),
|
||||||
]))->setStatusCode(201);
|
]))->setStatusCode(201);
|
||||||
} elseif ($type === 'private-gh-app') {
|
} elseif ($type === 'private-gh-app') {
|
||||||
$validationRules = [
|
$validationRules = [
|
||||||
@@ -1238,6 +1250,11 @@ class ApplicationsController extends Controller
|
|||||||
|
|
||||||
$application->save();
|
$application->save();
|
||||||
$application->refresh();
|
$application->refresh();
|
||||||
|
// Auto-generate domain if requested and no custom domain provided
|
||||||
|
if ($autogenerateDomain && blank($fqdn)) {
|
||||||
|
$application->fqdn = generateUrl(server: $server, random: $application->uuid);
|
||||||
|
$application->save();
|
||||||
|
}
|
||||||
if (isset($useBuildServer)) {
|
if (isset($useBuildServer)) {
|
||||||
$application->settings->is_build_server_enabled = $useBuildServer;
|
$application->settings->is_build_server_enabled = $useBuildServer;
|
||||||
$application->settings->save();
|
$application->settings->save();
|
||||||
@@ -1270,7 +1287,7 @@ class ApplicationsController extends Controller
|
|||||||
|
|
||||||
return response()->json(serializeApiResponse([
|
return response()->json(serializeApiResponse([
|
||||||
'uuid' => data_get($application, 'uuid'),
|
'uuid' => data_get($application, 'uuid'),
|
||||||
'domains' => data_get($application, 'domains'),
|
'domains' => data_get($application, 'fqdn'),
|
||||||
]))->setStatusCode(201);
|
]))->setStatusCode(201);
|
||||||
} elseif ($type === 'private-deploy-key') {
|
} elseif ($type === 'private-deploy-key') {
|
||||||
|
|
||||||
@@ -1367,6 +1384,11 @@ class ApplicationsController extends Controller
|
|||||||
$application->environment_id = $environment->id;
|
$application->environment_id = $environment->id;
|
||||||
$application->save();
|
$application->save();
|
||||||
$application->refresh();
|
$application->refresh();
|
||||||
|
// Auto-generate domain if requested and no custom domain provided
|
||||||
|
if ($autogenerateDomain && blank($fqdn)) {
|
||||||
|
$application->fqdn = generateUrl(server: $server, random: $application->uuid);
|
||||||
|
$application->save();
|
||||||
|
}
|
||||||
if (isset($useBuildServer)) {
|
if (isset($useBuildServer)) {
|
||||||
$application->settings->is_build_server_enabled = $useBuildServer;
|
$application->settings->is_build_server_enabled = $useBuildServer;
|
||||||
$application->settings->save();
|
$application->settings->save();
|
||||||
@@ -1399,7 +1421,7 @@ class ApplicationsController extends Controller
|
|||||||
|
|
||||||
return response()->json(serializeApiResponse([
|
return response()->json(serializeApiResponse([
|
||||||
'uuid' => data_get($application, 'uuid'),
|
'uuid' => data_get($application, 'uuid'),
|
||||||
'domains' => data_get($application, 'domains'),
|
'domains' => data_get($application, 'fqdn'),
|
||||||
]))->setStatusCode(201);
|
]))->setStatusCode(201);
|
||||||
} elseif ($type === 'dockerfile') {
|
} elseif ($type === 'dockerfile') {
|
||||||
$validationRules = [
|
$validationRules = [
|
||||||
@@ -1461,6 +1483,11 @@ class ApplicationsController extends Controller
|
|||||||
$application->git_branch = 'main';
|
$application->git_branch = 'main';
|
||||||
$application->save();
|
$application->save();
|
||||||
$application->refresh();
|
$application->refresh();
|
||||||
|
// Auto-generate domain if requested and no custom domain provided
|
||||||
|
if ($autogenerateDomain && blank($fqdn)) {
|
||||||
|
$application->fqdn = generateUrl(server: $server, random: $application->uuid);
|
||||||
|
$application->save();
|
||||||
|
}
|
||||||
if (isset($useBuildServer)) {
|
if (isset($useBuildServer)) {
|
||||||
$application->settings->is_build_server_enabled = $useBuildServer;
|
$application->settings->is_build_server_enabled = $useBuildServer;
|
||||||
$application->settings->save();
|
$application->settings->save();
|
||||||
@@ -1489,7 +1516,7 @@ class ApplicationsController extends Controller
|
|||||||
|
|
||||||
return response()->json(serializeApiResponse([
|
return response()->json(serializeApiResponse([
|
||||||
'uuid' => data_get($application, 'uuid'),
|
'uuid' => data_get($application, 'uuid'),
|
||||||
'domains' => data_get($application, 'domains'),
|
'domains' => data_get($application, 'fqdn'),
|
||||||
]))->setStatusCode(201);
|
]))->setStatusCode(201);
|
||||||
} elseif ($type === 'dockerimage') {
|
} elseif ($type === 'dockerimage') {
|
||||||
$validationRules = [
|
$validationRules = [
|
||||||
@@ -1554,6 +1581,11 @@ class ApplicationsController extends Controller
|
|||||||
$application->git_branch = 'main';
|
$application->git_branch = 'main';
|
||||||
$application->save();
|
$application->save();
|
||||||
$application->refresh();
|
$application->refresh();
|
||||||
|
// Auto-generate domain if requested and no custom domain provided
|
||||||
|
if ($autogenerateDomain && blank($fqdn)) {
|
||||||
|
$application->fqdn = generateUrl(server: $server, random: $application->uuid);
|
||||||
|
$application->save();
|
||||||
|
}
|
||||||
if (isset($useBuildServer)) {
|
if (isset($useBuildServer)) {
|
||||||
$application->settings->is_build_server_enabled = $useBuildServer;
|
$application->settings->is_build_server_enabled = $useBuildServer;
|
||||||
$application->settings->save();
|
$application->settings->save();
|
||||||
@@ -1582,7 +1614,7 @@ class ApplicationsController extends Controller
|
|||||||
|
|
||||||
return response()->json(serializeApiResponse([
|
return response()->json(serializeApiResponse([
|
||||||
'uuid' => data_get($application, 'uuid'),
|
'uuid' => data_get($application, 'uuid'),
|
||||||
'domains' => data_get($application, 'domains'),
|
'domains' => data_get($application, 'fqdn'),
|
||||||
]))->setStatusCode(201);
|
]))->setStatusCode(201);
|
||||||
} elseif ($type === 'dockercompose') {
|
} elseif ($type === 'dockercompose') {
|
||||||
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'instant_deploy', 'docker_compose_raw', 'force_domain_override'];
|
$allowedFields = ['project_uuid', 'environment_name', 'environment_uuid', 'server_uuid', 'destination_uuid', 'type', 'name', 'description', 'instant_deploy', 'docker_compose_raw', 'force_domain_override'];
|
||||||
@@ -1652,6 +1684,10 @@ class ApplicationsController extends Controller
|
|||||||
$service->save();
|
$service->save();
|
||||||
|
|
||||||
$service->parse(isNew: true);
|
$service->parse(isNew: true);
|
||||||
|
|
||||||
|
// Apply service-specific application prerequisites
|
||||||
|
applyServiceApplicationPrerequisites($service);
|
||||||
|
|
||||||
if ($instantDeploy) {
|
if ($instantDeploy) {
|
||||||
StartService::dispatch($service);
|
StartService::dispatch($service);
|
||||||
}
|
}
|
||||||
|
|||||||
531
app/Http/Controllers/Api/CloudProviderTokensController.php
Normal file
531
app/Http/Controllers/Api/CloudProviderTokensController.php
Normal file
@@ -0,0 +1,531 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\CloudProviderToken;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use OpenApi\Attributes as OA;
|
||||||
|
|
||||||
|
class CloudProviderTokensController extends Controller
|
||||||
|
{
|
||||||
|
private function removeSensitiveData($token)
|
||||||
|
{
|
||||||
|
$token->makeHidden([
|
||||||
|
'id',
|
||||||
|
'token',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return serializeApiResponse($token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a provider token against the provider's API.
|
||||||
|
*
|
||||||
|
* @return array{valid: bool, error: string|null}
|
||||||
|
*/
|
||||||
|
private function validateProviderToken(string $provider, string $token): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$response = match ($provider) {
|
||||||
|
'hetzner' => Http::withHeaders([
|
||||||
|
'Authorization' => 'Bearer '.$token,
|
||||||
|
])->timeout(10)->get('https://api.hetzner.cloud/v1/servers'),
|
||||||
|
'digitalocean' => Http::withHeaders([
|
||||||
|
'Authorization' => 'Bearer '.$token,
|
||||||
|
])->timeout(10)->get('https://api.digitalocean.com/v2/account'),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($response === null) {
|
||||||
|
return ['valid' => false, 'error' => 'Unsupported provider.'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
return ['valid' => true, 'error' => null];
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['valid' => false, 'error' => "Invalid {$provider} token. Please check your API token."];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('Failed to validate cloud provider token', [
|
||||||
|
'provider' => $provider,
|
||||||
|
'exception' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return ['valid' => false, 'error' => 'Failed to validate token with provider API.'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[OA\Get(
|
||||||
|
summary: 'List Cloud Provider Tokens',
|
||||||
|
description: 'List all cloud provider tokens for the authenticated team.',
|
||||||
|
path: '/cloud-tokens',
|
||||||
|
operationId: 'list-cloud-tokens',
|
||||||
|
security: [
|
||||||
|
['bearerAuth' => []],
|
||||||
|
],
|
||||||
|
tags: ['Cloud Tokens'],
|
||||||
|
responses: [
|
||||||
|
new OA\Response(
|
||||||
|
response: 200,
|
||||||
|
description: 'Get all cloud provider tokens.',
|
||||||
|
content: [
|
||||||
|
new OA\MediaType(
|
||||||
|
mediaType: 'application/json',
|
||||||
|
schema: new OA\Schema(
|
||||||
|
type: 'array',
|
||||||
|
items: new OA\Items(
|
||||||
|
type: 'object',
|
||||||
|
properties: [
|
||||||
|
'uuid' => ['type' => 'string'],
|
||||||
|
'name' => ['type' => 'string'],
|
||||||
|
'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean']],
|
||||||
|
'team_id' => ['type' => 'integer'],
|
||||||
|
'servers_count' => ['type' => 'integer'],
|
||||||
|
'created_at' => ['type' => 'string'],
|
||||||
|
'updated_at' => ['type' => 'string'],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
new OA\Response(
|
||||||
|
response: 401,
|
||||||
|
ref: '#/components/responses/401',
|
||||||
|
),
|
||||||
|
new OA\Response(
|
||||||
|
response: 400,
|
||||||
|
ref: '#/components/responses/400',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
public function index(Request $request)
|
||||||
|
{
|
||||||
|
$teamId = getTeamIdFromToken();
|
||||||
|
if (is_null($teamId)) {
|
||||||
|
return invalidTokenResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
$tokens = CloudProviderToken::whereTeamId($teamId)
|
||||||
|
->withCount('servers')
|
||||||
|
->get()
|
||||||
|
->map(function ($token) {
|
||||||
|
return $this->removeSensitiveData($token);
|
||||||
|
});
|
||||||
|
|
||||||
|
return response()->json($tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[OA\Get(
|
||||||
|
summary: 'Get Cloud Provider Token',
|
||||||
|
description: 'Get cloud provider token by UUID.',
|
||||||
|
path: '/cloud-tokens/{uuid}',
|
||||||
|
operationId: 'get-cloud-token-by-uuid',
|
||||||
|
security: [
|
||||||
|
['bearerAuth' => []],
|
||||||
|
],
|
||||||
|
tags: ['Cloud Tokens'],
|
||||||
|
parameters: [
|
||||||
|
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Token UUID', schema: new OA\Schema(type: 'string')),
|
||||||
|
],
|
||||||
|
responses: [
|
||||||
|
new OA\Response(
|
||||||
|
response: 200,
|
||||||
|
description: 'Get cloud provider token by UUID',
|
||||||
|
content: [
|
||||||
|
new OA\MediaType(
|
||||||
|
mediaType: 'application/json',
|
||||||
|
schema: new OA\Schema(
|
||||||
|
type: 'object',
|
||||||
|
properties: [
|
||||||
|
'uuid' => ['type' => 'string'],
|
||||||
|
'name' => ['type' => 'string'],
|
||||||
|
'provider' => ['type' => 'string'],
|
||||||
|
'team_id' => ['type' => 'integer'],
|
||||||
|
'servers_count' => ['type' => 'integer'],
|
||||||
|
'created_at' => ['type' => 'string'],
|
||||||
|
'updated_at' => ['type' => 'string'],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
new OA\Response(
|
||||||
|
response: 401,
|
||||||
|
ref: '#/components/responses/401',
|
||||||
|
),
|
||||||
|
new OA\Response(
|
||||||
|
response: 404,
|
||||||
|
ref: '#/components/responses/404',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
public function show(Request $request)
|
||||||
|
{
|
||||||
|
$teamId = getTeamIdFromToken();
|
||||||
|
if (is_null($teamId)) {
|
||||||
|
return invalidTokenResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = CloudProviderToken::whereTeamId($teamId)
|
||||||
|
->whereUuid($request->uuid)
|
||||||
|
->withCount('servers')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (is_null($token)) {
|
||||||
|
return response()->json(['message' => 'Cloud provider token not found.'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($this->removeSensitiveData($token));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[OA\Post(
|
||||||
|
summary: 'Create Cloud Provider Token',
|
||||||
|
description: 'Create a new cloud provider token. The token will be validated before being stored.',
|
||||||
|
path: '/cloud-tokens',
|
||||||
|
operationId: 'create-cloud-token',
|
||||||
|
security: [
|
||||||
|
['bearerAuth' => []],
|
||||||
|
],
|
||||||
|
tags: ['Cloud Tokens'],
|
||||||
|
requestBody: new OA\RequestBody(
|
||||||
|
required: true,
|
||||||
|
description: 'Cloud provider token details',
|
||||||
|
content: new OA\MediaType(
|
||||||
|
mediaType: 'application/json',
|
||||||
|
schema: new OA\Schema(
|
||||||
|
type: 'object',
|
||||||
|
required: ['provider', 'token', 'name'],
|
||||||
|
properties: [
|
||||||
|
'provider' => ['type' => 'string', 'enum' => ['hetzner', 'digitalocean'], 'example' => 'hetzner', 'description' => 'The cloud provider.'],
|
||||||
|
'token' => ['type' => 'string', 'example' => 'your-api-token-here', 'description' => 'The API token for the cloud provider.'],
|
||||||
|
'name' => ['type' => 'string', 'example' => 'My Hetzner Token', 'description' => 'A friendly name for the token.'],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
responses: [
|
||||||
|
new OA\Response(
|
||||||
|
response: 201,
|
||||||
|
description: 'Cloud provider token created.',
|
||||||
|
content: [
|
||||||
|
new OA\MediaType(
|
||||||
|
mediaType: 'application/json',
|
||||||
|
schema: new OA\Schema(
|
||||||
|
type: 'object',
|
||||||
|
properties: [
|
||||||
|
'uuid' => ['type' => 'string', 'example' => 'og888os', 'description' => 'The UUID of the token.'],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
new OA\Response(
|
||||||
|
response: 401,
|
||||||
|
ref: '#/components/responses/401',
|
||||||
|
),
|
||||||
|
new OA\Response(
|
||||||
|
response: 400,
|
||||||
|
ref: '#/components/responses/400',
|
||||||
|
),
|
||||||
|
new OA\Response(
|
||||||
|
response: 422,
|
||||||
|
ref: '#/components/responses/422',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
$allowedFields = ['provider', 'token', 'name'];
|
||||||
|
|
||||||
|
$teamId = getTeamIdFromToken();
|
||||||
|
if (is_null($teamId)) {
|
||||||
|
return invalidTokenResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
$return = validateIncomingRequest($request);
|
||||||
|
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||||
|
return $return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use request body only (excludes any route parameters)
|
||||||
|
$body = $request->json()->all();
|
||||||
|
|
||||||
|
$validator = customApiValidator($body, [
|
||||||
|
'provider' => 'required|string|in:hetzner,digitalocean',
|
||||||
|
'token' => 'required|string',
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$extraFields = array_diff(array_keys($body), $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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate token with the provider's API
|
||||||
|
$validation = $this->validateProviderToken($body['provider'], $body['token']);
|
||||||
|
|
||||||
|
if (! $validation['valid']) {
|
||||||
|
return response()->json(['message' => $validation['error']], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$cloudProviderToken = CloudProviderToken::create([
|
||||||
|
'team_id' => $teamId,
|
||||||
|
'provider' => $body['provider'],
|
||||||
|
'token' => $body['token'],
|
||||||
|
'name' => $body['name'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'uuid' => $cloudProviderToken->uuid,
|
||||||
|
])->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[OA\Patch(
|
||||||
|
summary: 'Update Cloud Provider Token',
|
||||||
|
description: 'Update cloud provider token name.',
|
||||||
|
path: '/cloud-tokens/{uuid}',
|
||||||
|
operationId: 'update-cloud-token-by-uuid',
|
||||||
|
security: [
|
||||||
|
['bearerAuth' => []],
|
||||||
|
],
|
||||||
|
tags: ['Cloud Tokens'],
|
||||||
|
parameters: [
|
||||||
|
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Token UUID', schema: new OA\Schema(type: 'string')),
|
||||||
|
],
|
||||||
|
requestBody: new OA\RequestBody(
|
||||||
|
required: true,
|
||||||
|
description: 'Cloud provider token updated.',
|
||||||
|
content: new OA\MediaType(
|
||||||
|
mediaType: 'application/json',
|
||||||
|
schema: new OA\Schema(
|
||||||
|
type: 'object',
|
||||||
|
properties: [
|
||||||
|
'name' => ['type' => 'string', 'description' => 'The friendly name for the token.'],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
responses: [
|
||||||
|
new OA\Response(
|
||||||
|
response: 200,
|
||||||
|
description: 'Cloud provider token updated.',
|
||||||
|
content: [
|
||||||
|
new OA\MediaType(
|
||||||
|
mediaType: 'application/json',
|
||||||
|
schema: new OA\Schema(
|
||||||
|
type: 'object',
|
||||||
|
properties: [
|
||||||
|
'uuid' => ['type' => 'string'],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
new OA\Response(
|
||||||
|
response: 401,
|
||||||
|
ref: '#/components/responses/401',
|
||||||
|
),
|
||||||
|
new OA\Response(
|
||||||
|
response: 404,
|
||||||
|
ref: '#/components/responses/404',
|
||||||
|
),
|
||||||
|
new OA\Response(
|
||||||
|
response: 422,
|
||||||
|
ref: '#/components/responses/422',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
public function update(Request $request)
|
||||||
|
{
|
||||||
|
$allowedFields = ['name'];
|
||||||
|
|
||||||
|
$teamId = getTeamIdFromToken();
|
||||||
|
if (is_null($teamId)) {
|
||||||
|
return invalidTokenResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
$return = validateIncomingRequest($request);
|
||||||
|
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||||
|
return $return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use request body only (excludes route parameters like uuid)
|
||||||
|
$body = $request->json()->all();
|
||||||
|
|
||||||
|
$validator = customApiValidator($body, [
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$extraFields = array_diff(array_keys($body), $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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use route parameter for UUID lookup
|
||||||
|
$token = CloudProviderToken::whereTeamId($teamId)->whereUuid($request->route('uuid'))->first();
|
||||||
|
if (! $token) {
|
||||||
|
return response()->json(['message' => 'Cloud provider token not found.'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$token->update(array_intersect_key($body, array_flip($allowedFields)));
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'uuid' => $token->uuid,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[OA\Delete(
|
||||||
|
summary: 'Delete Cloud Provider Token',
|
||||||
|
description: 'Delete cloud provider token by UUID. Cannot delete if token is used by any servers.',
|
||||||
|
path: '/cloud-tokens/{uuid}',
|
||||||
|
operationId: 'delete-cloud-token-by-uuid',
|
||||||
|
security: [
|
||||||
|
['bearerAuth' => []],
|
||||||
|
],
|
||||||
|
tags: ['Cloud Tokens'],
|
||||||
|
parameters: [
|
||||||
|
new OA\Parameter(
|
||||||
|
name: 'uuid',
|
||||||
|
in: 'path',
|
||||||
|
description: 'UUID of the cloud provider token.',
|
||||||
|
required: true,
|
||||||
|
schema: new OA\Schema(
|
||||||
|
type: 'string',
|
||||||
|
format: 'uuid',
|
||||||
|
)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
responses: [
|
||||||
|
new OA\Response(
|
||||||
|
response: 200,
|
||||||
|
description: 'Cloud provider token deleted.',
|
||||||
|
content: [
|
||||||
|
new OA\MediaType(
|
||||||
|
mediaType: 'application/json',
|
||||||
|
schema: new OA\Schema(
|
||||||
|
type: 'object',
|
||||||
|
properties: [
|
||||||
|
'message' => ['type' => 'string', 'example' => 'Cloud provider token deleted.'],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
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 destroy(Request $request)
|
||||||
|
{
|
||||||
|
$teamId = getTeamIdFromToken();
|
||||||
|
if (is_null($teamId)) {
|
||||||
|
return invalidTokenResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $request->uuid) {
|
||||||
|
return response()->json(['message' => 'UUID is required.'], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = CloudProviderToken::whereTeamId($teamId)->whereUuid($request->uuid)->first();
|
||||||
|
|
||||||
|
if (! $token) {
|
||||||
|
return response()->json(['message' => 'Cloud provider token not found.'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($token->hasServers()) {
|
||||||
|
return response()->json(['message' => 'Cannot delete token that is used by servers.'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$token->delete();
|
||||||
|
|
||||||
|
return response()->json(['message' => 'Cloud provider token deleted.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[OA\Post(
|
||||||
|
summary: 'Validate Cloud Provider Token',
|
||||||
|
description: 'Validate a cloud provider token against the provider API.',
|
||||||
|
path: '/cloud-tokens/{uuid}/validate',
|
||||||
|
operationId: 'validate-cloud-token-by-uuid',
|
||||||
|
security: [
|
||||||
|
['bearerAuth' => []],
|
||||||
|
],
|
||||||
|
tags: ['Cloud Tokens'],
|
||||||
|
parameters: [
|
||||||
|
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Token UUID', schema: new OA\Schema(type: 'string')),
|
||||||
|
],
|
||||||
|
responses: [
|
||||||
|
new OA\Response(
|
||||||
|
response: 200,
|
||||||
|
description: 'Token validation result.',
|
||||||
|
content: [
|
||||||
|
new OA\MediaType(
|
||||||
|
mediaType: 'application/json',
|
||||||
|
schema: new OA\Schema(
|
||||||
|
type: 'object',
|
||||||
|
properties: [
|
||||||
|
'valid' => ['type' => 'boolean', 'example' => true],
|
||||||
|
'message' => ['type' => 'string', 'example' => 'Token is valid.'],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
new OA\Response(
|
||||||
|
response: 401,
|
||||||
|
ref: '#/components/responses/401',
|
||||||
|
),
|
||||||
|
new OA\Response(
|
||||||
|
response: 404,
|
||||||
|
ref: '#/components/responses/404',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
public function validateToken(Request $request)
|
||||||
|
{
|
||||||
|
$teamId = getTeamIdFromToken();
|
||||||
|
if (is_null($teamId)) {
|
||||||
|
return invalidTokenResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
$cloudToken = CloudProviderToken::whereTeamId($teamId)->whereUuid($request->uuid)->first();
|
||||||
|
|
||||||
|
if (! $cloudToken) {
|
||||||
|
return response()->json(['message' => 'Cloud provider token not found.'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validation = $this->validateProviderToken($cloudToken->provider, $cloudToken->token);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'valid' => $validation['valid'],
|
||||||
|
'message' => $validation['valid'] ? 'Token is valid.' : $validation['error'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -388,7 +388,11 @@ class DeployController extends Controller
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $this->deploy_resource($resource, $force, $pr);
|
$result = $this->deploy_resource($resource, $force, $pr);
|
||||||
|
if (isset($result['status']) && $result['status'] === 429) {
|
||||||
|
return response()->json(['message' => $result['message']], 429)->header('Retry-After', 60);
|
||||||
|
}
|
||||||
|
['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $result;
|
||||||
if ($deployment_uuid) {
|
if ($deployment_uuid) {
|
||||||
$deployments->push(['message' => $return_message, 'resource_uuid' => $uuid, 'deployment_uuid' => $deployment_uuid->toString()]);
|
$deployments->push(['message' => $return_message, 'resource_uuid' => $uuid, 'deployment_uuid' => $deployment_uuid->toString()]);
|
||||||
} else {
|
} else {
|
||||||
@@ -430,7 +434,11 @@ class DeployController extends Controller
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
foreach ($applications as $resource) {
|
foreach ($applications as $resource) {
|
||||||
['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $this->deploy_resource($resource, $force);
|
$result = $this->deploy_resource($resource, $force);
|
||||||
|
if (isset($result['status']) && $result['status'] === 429) {
|
||||||
|
return response()->json(['message' => $result['message']], 429)->header('Retry-After', 60);
|
||||||
|
}
|
||||||
|
['message' => $return_message, 'deployment_uuid' => $deployment_uuid] = $result;
|
||||||
if ($deployment_uuid) {
|
if ($deployment_uuid) {
|
||||||
$deployments->push(['resource_uuid' => $resource->uuid, 'deployment_uuid' => $deployment_uuid->toString()]);
|
$deployments->push(['resource_uuid' => $resource->uuid, 'deployment_uuid' => $deployment_uuid->toString()]);
|
||||||
}
|
}
|
||||||
@@ -474,8 +482,11 @@ class DeployController extends Controller
|
|||||||
deployment_uuid: $deployment_uuid,
|
deployment_uuid: $deployment_uuid,
|
||||||
force_rebuild: $force,
|
force_rebuild: $force,
|
||||||
pull_request_id: $pr,
|
pull_request_id: $pr,
|
||||||
|
is_api: true,
|
||||||
);
|
);
|
||||||
if ($result['status'] === 'skipped') {
|
if ($result['status'] === 'queue_full') {
|
||||||
|
return ['message' => $result['message'], 'deployment_uuid' => null, 'status' => 429];
|
||||||
|
} elseif ($result['status'] === 'skipped') {
|
||||||
$message = $result['message'];
|
$message = $result['message'];
|
||||||
} else {
|
} else {
|
||||||
$message = "Application {$resource->name} deployment queued.";
|
$message = "Application {$resource->name} deployment queued.";
|
||||||
|
|||||||
738
app/Http/Controllers/Api/HetznerController.php
Normal file
738
app/Http/Controllers/Api/HetznerController.php
Normal file
@@ -0,0 +1,738 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Enums\ProxyTypes;
|
||||||
|
use App\Exceptions\RateLimitException;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\CloudProviderToken;
|
||||||
|
use App\Models\PrivateKey;
|
||||||
|
use App\Models\Server;
|
||||||
|
use App\Models\Team;
|
||||||
|
use App\Rules\ValidCloudInitYaml;
|
||||||
|
use App\Rules\ValidHostname;
|
||||||
|
use App\Services\HetznerService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use OpenApi\Attributes as OA;
|
||||||
|
|
||||||
|
class HetznerController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get cloud provider token UUID from request.
|
||||||
|
* Prefers cloud_provider_token_uuid over deprecated cloud_provider_token_id.
|
||||||
|
*/
|
||||||
|
private function getCloudProviderTokenUuid(Request $request): ?string
|
||||||
|
{
|
||||||
|
return $request->cloud_provider_token_uuid ?? $request->cloud_provider_token_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[OA\Get(
|
||||||
|
summary: 'Get Hetzner Locations',
|
||||||
|
description: 'Get all available Hetzner datacenter locations.',
|
||||||
|
path: '/hetzner/locations',
|
||||||
|
operationId: 'get-hetzner-locations',
|
||||||
|
security: [
|
||||||
|
['bearerAuth' => []],
|
||||||
|
],
|
||||||
|
tags: ['Hetzner'],
|
||||||
|
parameters: [
|
||||||
|
new OA\Parameter(
|
||||||
|
name: 'cloud_provider_token_uuid',
|
||||||
|
in: 'query',
|
||||||
|
required: false,
|
||||||
|
description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.',
|
||||||
|
schema: new OA\Schema(type: 'string')
|
||||||
|
),
|
||||||
|
new OA\Parameter(
|
||||||
|
name: 'cloud_provider_token_id',
|
||||||
|
in: 'query',
|
||||||
|
required: false,
|
||||||
|
deprecated: true,
|
||||||
|
description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.',
|
||||||
|
schema: new OA\Schema(type: 'string')
|
||||||
|
),
|
||||||
|
],
|
||||||
|
responses: [
|
||||||
|
new OA\Response(
|
||||||
|
response: 200,
|
||||||
|
description: 'List of Hetzner locations.',
|
||||||
|
content: [
|
||||||
|
new OA\MediaType(
|
||||||
|
mediaType: 'application/json',
|
||||||
|
schema: new OA\Schema(
|
||||||
|
type: 'array',
|
||||||
|
items: new OA\Items(
|
||||||
|
type: 'object',
|
||||||
|
properties: [
|
||||||
|
'id' => ['type' => 'integer'],
|
||||||
|
'name' => ['type' => 'string'],
|
||||||
|
'description' => ['type' => 'string'],
|
||||||
|
'country' => ['type' => 'string'],
|
||||||
|
'city' => ['type' => 'string'],
|
||||||
|
'latitude' => ['type' => 'number'],
|
||||||
|
'longitude' => ['type' => 'number'],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
new OA\Response(
|
||||||
|
response: 401,
|
||||||
|
ref: '#/components/responses/401',
|
||||||
|
),
|
||||||
|
new OA\Response(
|
||||||
|
response: 404,
|
||||||
|
ref: '#/components/responses/404',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
public function locations(Request $request)
|
||||||
|
{
|
||||||
|
$teamId = getTeamIdFromToken();
|
||||||
|
if (is_null($teamId)) {
|
||||||
|
return invalidTokenResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
$validator = customApiValidator($request->all(), [
|
||||||
|
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
|
||||||
|
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Validation failed.',
|
||||||
|
'errors' => $validator->errors(),
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tokenUuid = $this->getCloudProviderTokenUuid($request);
|
||||||
|
$token = CloudProviderToken::whereTeamId($teamId)
|
||||||
|
->whereUuid($tokenUuid)
|
||||||
|
->where('provider', 'hetzner')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $token) {
|
||||||
|
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$hetznerService = new HetznerService($token->token);
|
||||||
|
$locations = $hetznerService->getLocations();
|
||||||
|
|
||||||
|
return response()->json($locations);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return response()->json(['message' => 'Failed to fetch locations: '.$e->getMessage()], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[OA\Get(
|
||||||
|
summary: 'Get Hetzner Server Types',
|
||||||
|
description: 'Get all available Hetzner server types (instance sizes).',
|
||||||
|
path: '/hetzner/server-types',
|
||||||
|
operationId: 'get-hetzner-server-types',
|
||||||
|
security: [
|
||||||
|
['bearerAuth' => []],
|
||||||
|
],
|
||||||
|
tags: ['Hetzner'],
|
||||||
|
parameters: [
|
||||||
|
new OA\Parameter(
|
||||||
|
name: 'cloud_provider_token_uuid',
|
||||||
|
in: 'query',
|
||||||
|
required: false,
|
||||||
|
description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.',
|
||||||
|
schema: new OA\Schema(type: 'string')
|
||||||
|
),
|
||||||
|
new OA\Parameter(
|
||||||
|
name: 'cloud_provider_token_id',
|
||||||
|
in: 'query',
|
||||||
|
required: false,
|
||||||
|
deprecated: true,
|
||||||
|
description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.',
|
||||||
|
schema: new OA\Schema(type: 'string')
|
||||||
|
),
|
||||||
|
],
|
||||||
|
responses: [
|
||||||
|
new OA\Response(
|
||||||
|
response: 200,
|
||||||
|
description: 'List of Hetzner server types.',
|
||||||
|
content: [
|
||||||
|
new OA\MediaType(
|
||||||
|
mediaType: 'application/json',
|
||||||
|
schema: new OA\Schema(
|
||||||
|
type: 'array',
|
||||||
|
items: new OA\Items(
|
||||||
|
type: 'object',
|
||||||
|
properties: [
|
||||||
|
'id' => ['type' => 'integer'],
|
||||||
|
'name' => ['type' => 'string'],
|
||||||
|
'description' => ['type' => 'string'],
|
||||||
|
'cores' => ['type' => 'integer'],
|
||||||
|
'memory' => ['type' => 'number'],
|
||||||
|
'disk' => ['type' => 'integer'],
|
||||||
|
'prices' => [
|
||||||
|
'type' => 'array',
|
||||||
|
'items' => [
|
||||||
|
'type' => 'object',
|
||||||
|
'properties' => [
|
||||||
|
'location' => ['type' => 'string', 'description' => 'Datacenter location name'],
|
||||||
|
'price_hourly' => [
|
||||||
|
'type' => 'object',
|
||||||
|
'properties' => [
|
||||||
|
'net' => ['type' => 'string'],
|
||||||
|
'gross' => ['type' => 'string'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'price_monthly' => [
|
||||||
|
'type' => 'object',
|
||||||
|
'properties' => [
|
||||||
|
'net' => ['type' => 'string'],
|
||||||
|
'gross' => ['type' => 'string'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
new OA\Response(
|
||||||
|
response: 401,
|
||||||
|
ref: '#/components/responses/401',
|
||||||
|
),
|
||||||
|
new OA\Response(
|
||||||
|
response: 404,
|
||||||
|
ref: '#/components/responses/404',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
public function serverTypes(Request $request)
|
||||||
|
{
|
||||||
|
$teamId = getTeamIdFromToken();
|
||||||
|
if (is_null($teamId)) {
|
||||||
|
return invalidTokenResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
$validator = customApiValidator($request->all(), [
|
||||||
|
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
|
||||||
|
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Validation failed.',
|
||||||
|
'errors' => $validator->errors(),
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tokenUuid = $this->getCloudProviderTokenUuid($request);
|
||||||
|
$token = CloudProviderToken::whereTeamId($teamId)
|
||||||
|
->whereUuid($tokenUuid)
|
||||||
|
->where('provider', 'hetzner')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $token) {
|
||||||
|
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$hetznerService = new HetznerService($token->token);
|
||||||
|
$serverTypes = $hetznerService->getServerTypes();
|
||||||
|
|
||||||
|
return response()->json($serverTypes);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return response()->json(['message' => 'Failed to fetch server types: '.$e->getMessage()], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[OA\Get(
|
||||||
|
summary: 'Get Hetzner Images',
|
||||||
|
description: 'Get all available Hetzner system images (operating systems).',
|
||||||
|
path: '/hetzner/images',
|
||||||
|
operationId: 'get-hetzner-images',
|
||||||
|
security: [
|
||||||
|
['bearerAuth' => []],
|
||||||
|
],
|
||||||
|
tags: ['Hetzner'],
|
||||||
|
parameters: [
|
||||||
|
new OA\Parameter(
|
||||||
|
name: 'cloud_provider_token_uuid',
|
||||||
|
in: 'query',
|
||||||
|
required: false,
|
||||||
|
description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.',
|
||||||
|
schema: new OA\Schema(type: 'string')
|
||||||
|
),
|
||||||
|
new OA\Parameter(
|
||||||
|
name: 'cloud_provider_token_id',
|
||||||
|
in: 'query',
|
||||||
|
required: false,
|
||||||
|
deprecated: true,
|
||||||
|
description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.',
|
||||||
|
schema: new OA\Schema(type: 'string')
|
||||||
|
),
|
||||||
|
],
|
||||||
|
responses: [
|
||||||
|
new OA\Response(
|
||||||
|
response: 200,
|
||||||
|
description: 'List of Hetzner images.',
|
||||||
|
content: [
|
||||||
|
new OA\MediaType(
|
||||||
|
mediaType: 'application/json',
|
||||||
|
schema: new OA\Schema(
|
||||||
|
type: 'array',
|
||||||
|
items: new OA\Items(
|
||||||
|
type: 'object',
|
||||||
|
properties: [
|
||||||
|
'id' => ['type' => 'integer'],
|
||||||
|
'name' => ['type' => 'string'],
|
||||||
|
'description' => ['type' => 'string'],
|
||||||
|
'type' => ['type' => 'string'],
|
||||||
|
'os_flavor' => ['type' => 'string'],
|
||||||
|
'os_version' => ['type' => 'string'],
|
||||||
|
'architecture' => ['type' => 'string'],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
new OA\Response(
|
||||||
|
response: 401,
|
||||||
|
ref: '#/components/responses/401',
|
||||||
|
),
|
||||||
|
new OA\Response(
|
||||||
|
response: 404,
|
||||||
|
ref: '#/components/responses/404',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
public function images(Request $request)
|
||||||
|
{
|
||||||
|
$teamId = getTeamIdFromToken();
|
||||||
|
if (is_null($teamId)) {
|
||||||
|
return invalidTokenResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
$validator = customApiValidator($request->all(), [
|
||||||
|
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
|
||||||
|
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Validation failed.',
|
||||||
|
'errors' => $validator->errors(),
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tokenUuid = $this->getCloudProviderTokenUuid($request);
|
||||||
|
$token = CloudProviderToken::whereTeamId($teamId)
|
||||||
|
->whereUuid($tokenUuid)
|
||||||
|
->where('provider', 'hetzner')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $token) {
|
||||||
|
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$hetznerService = new HetznerService($token->token);
|
||||||
|
$images = $hetznerService->getImages();
|
||||||
|
|
||||||
|
// Filter out deprecated images (same as UI)
|
||||||
|
$filtered = array_filter($images, function ($image) {
|
||||||
|
if (isset($image['type']) && $image['type'] !== 'system') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($image['deprecated']) && $image['deprecated'] === true) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return response()->json(array_values($filtered));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return response()->json(['message' => 'Failed to fetch images: '.$e->getMessage()], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[OA\Get(
|
||||||
|
summary: 'Get Hetzner SSH Keys',
|
||||||
|
description: 'Get all SSH keys stored in the Hetzner account.',
|
||||||
|
path: '/hetzner/ssh-keys',
|
||||||
|
operationId: 'get-hetzner-ssh-keys',
|
||||||
|
security: [
|
||||||
|
['bearerAuth' => []],
|
||||||
|
],
|
||||||
|
tags: ['Hetzner'],
|
||||||
|
parameters: [
|
||||||
|
new OA\Parameter(
|
||||||
|
name: 'cloud_provider_token_uuid',
|
||||||
|
in: 'query',
|
||||||
|
required: false,
|
||||||
|
description: 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.',
|
||||||
|
schema: new OA\Schema(type: 'string')
|
||||||
|
),
|
||||||
|
new OA\Parameter(
|
||||||
|
name: 'cloud_provider_token_id',
|
||||||
|
in: 'query',
|
||||||
|
required: false,
|
||||||
|
deprecated: true,
|
||||||
|
description: 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.',
|
||||||
|
schema: new OA\Schema(type: 'string')
|
||||||
|
),
|
||||||
|
],
|
||||||
|
responses: [
|
||||||
|
new OA\Response(
|
||||||
|
response: 200,
|
||||||
|
description: 'List of Hetzner SSH keys.',
|
||||||
|
content: [
|
||||||
|
new OA\MediaType(
|
||||||
|
mediaType: 'application/json',
|
||||||
|
schema: new OA\Schema(
|
||||||
|
type: 'array',
|
||||||
|
items: new OA\Items(
|
||||||
|
type: 'object',
|
||||||
|
properties: [
|
||||||
|
'id' => ['type' => 'integer'],
|
||||||
|
'name' => ['type' => 'string'],
|
||||||
|
'fingerprint' => ['type' => 'string'],
|
||||||
|
'public_key' => ['type' => 'string'],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
new OA\Response(
|
||||||
|
response: 401,
|
||||||
|
ref: '#/components/responses/401',
|
||||||
|
),
|
||||||
|
new OA\Response(
|
||||||
|
response: 404,
|
||||||
|
ref: '#/components/responses/404',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
public function sshKeys(Request $request)
|
||||||
|
{
|
||||||
|
$teamId = getTeamIdFromToken();
|
||||||
|
if (is_null($teamId)) {
|
||||||
|
return invalidTokenResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
$validator = customApiValidator($request->all(), [
|
||||||
|
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
|
||||||
|
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Validation failed.',
|
||||||
|
'errors' => $validator->errors(),
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tokenUuid = $this->getCloudProviderTokenUuid($request);
|
||||||
|
$token = CloudProviderToken::whereTeamId($teamId)
|
||||||
|
->whereUuid($tokenUuid)
|
||||||
|
->where('provider', 'hetzner')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $token) {
|
||||||
|
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$hetznerService = new HetznerService($token->token);
|
||||||
|
$sshKeys = $hetznerService->getSshKeys();
|
||||||
|
|
||||||
|
return response()->json($sshKeys);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return response()->json(['message' => 'Failed to fetch SSH keys: '.$e->getMessage()], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[OA\Post(
|
||||||
|
summary: 'Create Hetzner Server',
|
||||||
|
description: 'Create a new server on Hetzner and register it in Coolify.',
|
||||||
|
path: '/servers/hetzner',
|
||||||
|
operationId: 'create-hetzner-server',
|
||||||
|
security: [
|
||||||
|
['bearerAuth' => []],
|
||||||
|
],
|
||||||
|
tags: ['Hetzner'],
|
||||||
|
requestBody: new OA\RequestBody(
|
||||||
|
required: true,
|
||||||
|
description: 'Hetzner server creation parameters',
|
||||||
|
content: new OA\MediaType(
|
||||||
|
mediaType: 'application/json',
|
||||||
|
schema: new OA\Schema(
|
||||||
|
type: 'object',
|
||||||
|
required: ['location', 'server_type', 'image', 'private_key_uuid'],
|
||||||
|
properties: [
|
||||||
|
'cloud_provider_token_uuid' => ['type' => 'string', 'example' => 'abc123', 'description' => 'Cloud provider token UUID. Required if cloud_provider_token_id is not provided.'],
|
||||||
|
'cloud_provider_token_id' => ['type' => 'string', 'example' => 'abc123', 'description' => 'Deprecated: Use cloud_provider_token_uuid instead. Cloud provider token UUID.', 'deprecated' => true],
|
||||||
|
'location' => ['type' => 'string', 'example' => 'nbg1', 'description' => 'Hetzner location name'],
|
||||||
|
'server_type' => ['type' => 'string', 'example' => 'cx11', 'description' => 'Hetzner server type name'],
|
||||||
|
'image' => ['type' => 'integer', 'example' => 15512617, 'description' => 'Hetzner image ID'],
|
||||||
|
'name' => ['type' => 'string', 'example' => 'my-server', 'description' => 'Server name (auto-generated if not provided)'],
|
||||||
|
'private_key_uuid' => ['type' => 'string', 'example' => 'xyz789', 'description' => 'Private key UUID'],
|
||||||
|
'enable_ipv4' => ['type' => 'boolean', 'example' => true, 'description' => 'Enable IPv4 (default: true)'],
|
||||||
|
'enable_ipv6' => ['type' => 'boolean', 'example' => true, 'description' => 'Enable IPv6 (default: true)'],
|
||||||
|
'hetzner_ssh_key_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Additional Hetzner SSH key IDs'],
|
||||||
|
'cloud_init_script' => ['type' => 'string', 'description' => 'Cloud-init YAML script (optional)'],
|
||||||
|
'instant_validate' => ['type' => 'boolean', 'example' => false, 'description' => 'Validate server immediately after creation'],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
responses: [
|
||||||
|
new OA\Response(
|
||||||
|
response: 201,
|
||||||
|
description: 'Hetzner server created.',
|
||||||
|
content: [
|
||||||
|
new OA\MediaType(
|
||||||
|
mediaType: 'application/json',
|
||||||
|
schema: new OA\Schema(
|
||||||
|
type: 'object',
|
||||||
|
properties: [
|
||||||
|
'uuid' => ['type' => 'string', 'example' => 'og888os', 'description' => 'The UUID of the server.'],
|
||||||
|
'hetzner_server_id' => ['type' => 'integer', 'description' => 'The Hetzner server ID.'],
|
||||||
|
'ip' => ['type' => 'string', 'description' => 'The server IP address.'],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
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',
|
||||||
|
),
|
||||||
|
new OA\Response(
|
||||||
|
response: 422,
|
||||||
|
ref: '#/components/responses/422',
|
||||||
|
),
|
||||||
|
new OA\Response(
|
||||||
|
response: 429,
|
||||||
|
ref: '#/components/responses/429',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)]
|
||||||
|
public function createServer(Request $request)
|
||||||
|
{
|
||||||
|
$allowedFields = [
|
||||||
|
'cloud_provider_token_uuid',
|
||||||
|
'cloud_provider_token_id',
|
||||||
|
'location',
|
||||||
|
'server_type',
|
||||||
|
'image',
|
||||||
|
'name',
|
||||||
|
'private_key_uuid',
|
||||||
|
'enable_ipv4',
|
||||||
|
'enable_ipv6',
|
||||||
|
'hetzner_ssh_key_ids',
|
||||||
|
'cloud_init_script',
|
||||||
|
'instant_validate',
|
||||||
|
];
|
||||||
|
|
||||||
|
$teamId = getTeamIdFromToken();
|
||||||
|
if (is_null($teamId)) {
|
||||||
|
return invalidTokenResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
$return = validateIncomingRequest($request);
|
||||||
|
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||||
|
return $return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$validator = customApiValidator($request->all(), [
|
||||||
|
'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string',
|
||||||
|
'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string',
|
||||||
|
'location' => 'required|string',
|
||||||
|
'server_type' => 'required|string',
|
||||||
|
'image' => 'required|integer',
|
||||||
|
'name' => ['nullable', 'string', 'max:253', new ValidHostname],
|
||||||
|
'private_key_uuid' => 'required|string',
|
||||||
|
'enable_ipv4' => 'nullable|boolean',
|
||||||
|
'enable_ipv6' => 'nullable|boolean',
|
||||||
|
'hetzner_ssh_key_ids' => 'nullable|array',
|
||||||
|
'hetzner_ssh_key_ids.*' => 'integer',
|
||||||
|
'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml],
|
||||||
|
'instant_validate' => 'nullable|boolean',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check server limit
|
||||||
|
if (Team::serverLimitReached()) {
|
||||||
|
return response()->json(['message' => 'Server limit reached for your subscription.'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set defaults
|
||||||
|
if (! $request->name) {
|
||||||
|
$request->offsetSet('name', generate_random_name());
|
||||||
|
}
|
||||||
|
if (is_null($request->enable_ipv4)) {
|
||||||
|
$request->offsetSet('enable_ipv4', true);
|
||||||
|
}
|
||||||
|
if (is_null($request->enable_ipv6)) {
|
||||||
|
$request->offsetSet('enable_ipv6', true);
|
||||||
|
}
|
||||||
|
if (is_null($request->hetzner_ssh_key_ids)) {
|
||||||
|
$request->offsetSet('hetzner_ssh_key_ids', []);
|
||||||
|
}
|
||||||
|
if (is_null($request->instant_validate)) {
|
||||||
|
$request->offsetSet('instant_validate', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate cloud provider token
|
||||||
|
$tokenUuid = $this->getCloudProviderTokenUuid($request);
|
||||||
|
$token = CloudProviderToken::whereTeamId($teamId)
|
||||||
|
->whereUuid($tokenUuid)
|
||||||
|
->where('provider', 'hetzner')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $token) {
|
||||||
|
return response()->json(['message' => 'Hetzner cloud provider token not found.'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate private key
|
||||||
|
$privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first();
|
||||||
|
if (! $privateKey) {
|
||||||
|
return response()->json(['message' => 'Private key not found.'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$hetznerService = new HetznerService($token->token);
|
||||||
|
|
||||||
|
// Get public key and MD5 fingerprint
|
||||||
|
$publicKey = $privateKey->getPublicKey();
|
||||||
|
$md5Fingerprint = PrivateKey::generateMd5Fingerprint($privateKey->private_key);
|
||||||
|
|
||||||
|
// Check if SSH key already exists on Hetzner
|
||||||
|
$existingSshKeys = $hetznerService->getSshKeys();
|
||||||
|
$existingKey = null;
|
||||||
|
|
||||||
|
foreach ($existingSshKeys as $key) {
|
||||||
|
if ($key['fingerprint'] === $md5Fingerprint) {
|
||||||
|
$existingKey = $key;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload SSH key if it doesn't exist
|
||||||
|
if ($existingKey) {
|
||||||
|
$sshKeyId = $existingKey['id'];
|
||||||
|
} else {
|
||||||
|
$sshKeyName = $privateKey->name;
|
||||||
|
$uploadedKey = $hetznerService->uploadSshKey($sshKeyName, $publicKey);
|
||||||
|
$sshKeyId = $uploadedKey['id'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize server name to lowercase for RFC 1123 compliance
|
||||||
|
$normalizedServerName = strtolower(trim($request->name));
|
||||||
|
|
||||||
|
// Prepare SSH keys array: Coolify key + user-selected Hetzner keys
|
||||||
|
$sshKeys = array_merge(
|
||||||
|
[$sshKeyId],
|
||||||
|
$request->hetzner_ssh_key_ids
|
||||||
|
);
|
||||||
|
|
||||||
|
// Remove duplicates
|
||||||
|
$sshKeys = array_unique($sshKeys);
|
||||||
|
$sshKeys = array_values($sshKeys);
|
||||||
|
|
||||||
|
// Prepare server creation parameters
|
||||||
|
$params = [
|
||||||
|
'name' => $normalizedServerName,
|
||||||
|
'server_type' => $request->server_type,
|
||||||
|
'image' => $request->image,
|
||||||
|
'location' => $request->location,
|
||||||
|
'start_after_create' => true,
|
||||||
|
'ssh_keys' => $sshKeys,
|
||||||
|
'public_net' => [
|
||||||
|
'enable_ipv4' => $request->enable_ipv4,
|
||||||
|
'enable_ipv6' => $request->enable_ipv6,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add cloud-init script if provided
|
||||||
|
if (! empty($request->cloud_init_script)) {
|
||||||
|
$params['user_data'] = $request->cloud_init_script;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create server on Hetzner
|
||||||
|
$hetznerServer = $hetznerService->createServer($params);
|
||||||
|
|
||||||
|
// Determine IP address to use (prefer IPv4, fallback to IPv6)
|
||||||
|
$ipAddress = null;
|
||||||
|
if ($request->enable_ipv4 && isset($hetznerServer['public_net']['ipv4']['ip'])) {
|
||||||
|
$ipAddress = $hetznerServer['public_net']['ipv4']['ip'];
|
||||||
|
} elseif ($request->enable_ipv6 && isset($hetznerServer['public_net']['ipv6']['ip'])) {
|
||||||
|
$ipAddress = $hetznerServer['public_net']['ipv6']['ip'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $ipAddress) {
|
||||||
|
throw new \Exception('No public IP address available. Enable at least one of IPv4 or IPv6.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create server in Coolify database
|
||||||
|
$server = Server::create([
|
||||||
|
'name' => $normalizedServerName,
|
||||||
|
'ip' => $ipAddress,
|
||||||
|
'user' => 'root',
|
||||||
|
'port' => 22,
|
||||||
|
'team_id' => $teamId,
|
||||||
|
'private_key_id' => $privateKey->id,
|
||||||
|
'cloud_provider_token_id' => $token->id,
|
||||||
|
'hetzner_server_id' => $hetznerServer['id'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$server->proxy->set('status', 'exited');
|
||||||
|
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
|
||||||
|
$server->save();
|
||||||
|
|
||||||
|
// Validate server if requested
|
||||||
|
if ($request->instant_validate) {
|
||||||
|
\App\Actions\Server\ValidateServer::dispatch($server);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'uuid' => $server->uuid,
|
||||||
|
'hetzner_server_id' => $hetznerServer['id'],
|
||||||
|
'ip' => $ipAddress,
|
||||||
|
])->setStatusCode(201);
|
||||||
|
} catch (RateLimitException $e) {
|
||||||
|
$response = response()->json(['message' => $e->getMessage()], 429);
|
||||||
|
if ($e->retryAfter !== null) {
|
||||||
|
$response->header('Retry-After', $e->retryAfter);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return response()->json(['message' => 'Failed to create server: '.$e->getMessage()], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,6 +61,22 @@ use OpenApi\Attributes as OA;
|
|||||||
),
|
),
|
||||||
]
|
]
|
||||||
)),
|
)),
|
||||||
|
new OA\Response(
|
||||||
|
response: 429,
|
||||||
|
description: 'Rate limit exceeded.',
|
||||||
|
headers: [
|
||||||
|
new OA\Header(
|
||||||
|
header: 'Retry-After',
|
||||||
|
description: 'Number of seconds to wait before retrying.',
|
||||||
|
schema: new OA\Schema(type: 'integer', example: 60)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
content: new OA\JsonContent(
|
||||||
|
type: 'object',
|
||||||
|
properties: [
|
||||||
|
new OA\Property(property: 'message', type: 'string', example: 'Rate limit exceeded. Please try again later.'),
|
||||||
|
]
|
||||||
|
)),
|
||||||
],
|
],
|
||||||
)]
|
)]
|
||||||
class OpenApi
|
class OpenApi
|
||||||
|
|||||||
@@ -351,7 +351,7 @@ class ServicesController extends Controller
|
|||||||
'destination_id' => $destination->id,
|
'destination_id' => $destination->id,
|
||||||
'destination_type' => $destination->getMorphClass(),
|
'destination_type' => $destination->getMorphClass(),
|
||||||
];
|
];
|
||||||
if ($oneClickServiceName === 'cloudflared') {
|
if (in_array($oneClickServiceName, NEEDS_TO_CONNECT_TO_PREDEFINED_NETWORK)) {
|
||||||
data_set($servicePayload, 'connect_to_docker_network', true);
|
data_set($servicePayload, 'connect_to_docker_network', true);
|
||||||
}
|
}
|
||||||
$service = Service::create($servicePayload);
|
$service = Service::create($servicePayload);
|
||||||
@@ -376,6 +376,10 @@ class ServicesController extends Controller
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
$service->parse(isNew: true);
|
$service->parse(isNew: true);
|
||||||
|
|
||||||
|
// Apply service-specific application prerequisites
|
||||||
|
applyServiceApplicationPrerequisites($service);
|
||||||
|
|
||||||
if ($instantDeploy) {
|
if ($instantDeploy) {
|
||||||
StartService::dispatch($service);
|
StartService::dispatch($service);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
|
||||||
|
|
||||||
use App\Models\Environment;
|
|
||||||
use App\Models\Project;
|
|
||||||
use App\Models\Server;
|
|
||||||
use App\Models\Team;
|
|
||||||
|
|
||||||
class MagicController extends Controller
|
|
||||||
{
|
|
||||||
public function servers()
|
|
||||||
{
|
|
||||||
return response()->json([
|
|
||||||
'servers' => Server::isUsable()->get(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function destinations()
|
|
||||||
{
|
|
||||||
return response()->json([
|
|
||||||
'destinations' => Server::destinationsByServer(request()->query('server_id'))->sortBy('name'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function projects()
|
|
||||||
{
|
|
||||||
return response()->json([
|
|
||||||
'projects' => Project::ownedByCurrentTeam()->get(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function environments()
|
|
||||||
{
|
|
||||||
$project = Project::ownedByCurrentTeam()->whereUuid(request()->query('project_uuid'))->first();
|
|
||||||
if (! $project) {
|
|
||||||
return response()->json([
|
|
||||||
'environments' => [],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'environments' => $project->environments,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function newProject()
|
|
||||||
{
|
|
||||||
$project = Project::firstOrCreate(
|
|
||||||
['name' => request()->query('name') ?? generate_random_name()],
|
|
||||||
['team_id' => currentTeam()->id]
|
|
||||||
);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'project_uuid' => $project->uuid,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function newEnvironment()
|
|
||||||
{
|
|
||||||
$environment = Environment::firstOrCreate(
|
|
||||||
['name' => request()->query('name') ?? generate_random_name()],
|
|
||||||
['project_id' => Project::ownedByCurrentTeam()->whereUuid(request()->query('project_uuid'))->firstOrFail()->id]
|
|
||||||
);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'environment_name' => $environment->name,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function newTeam()
|
|
||||||
{
|
|
||||||
$team = Team::create(
|
|
||||||
[
|
|
||||||
'name' => request()->query('name') ?? generate_random_name(),
|
|
||||||
'personal_team' => false,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
auth()->user()->teams()->attach($team, ['role' => 'admin']);
|
|
||||||
refreshSession();
|
|
||||||
|
|
||||||
return redirect(request()->header('Referer'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Webhook;
|
namespace App\Http\Controllers\Webhook;
|
||||||
|
|
||||||
|
use App\Actions\Application\CleanupPreviewDeployment;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Livewire\Project\Service\Storage;
|
|
||||||
use App\Models\Application;
|
use App\Models\Application;
|
||||||
use App\Models\ApplicationPreview;
|
use App\Models\ApplicationPreview;
|
||||||
use Exception;
|
use Exception;
|
||||||
@@ -15,23 +15,6 @@ class Bitbucket extends Controller
|
|||||||
public function manual(Request $request)
|
public function manual(Request $request)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
if (app()->isDownForMaintenance()) {
|
|
||||||
$epoch = now()->valueOf();
|
|
||||||
$data = [
|
|
||||||
'attributes' => $request->attributes->all(),
|
|
||||||
'request' => $request->request->all(),
|
|
||||||
'query' => $request->query->all(),
|
|
||||||
'server' => $request->server->all(),
|
|
||||||
'files' => $request->files->all(),
|
|
||||||
'cookies' => $request->cookies->all(),
|
|
||||||
'headers' => $request->headers->all(),
|
|
||||||
'content' => $request->getContent(),
|
|
||||||
];
|
|
||||||
$json = json_encode($data);
|
|
||||||
Storage::disk('webhooks-during-maintenance')->put("{$epoch}_Bitbicket::manual_bitbucket", $json);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$return_payloads = collect([]);
|
$return_payloads = collect([]);
|
||||||
$payload = $request->collect();
|
$payload = $request->collect();
|
||||||
$headers = $request->headers->all();
|
$headers = $request->headers->all();
|
||||||
@@ -107,7 +90,9 @@ class Bitbucket extends Controller
|
|||||||
force_rebuild: false,
|
force_rebuild: false,
|
||||||
is_webhook: true
|
is_webhook: true
|
||||||
);
|
);
|
||||||
if ($result['status'] === 'skipped') {
|
if ($result['status'] === 'queue_full') {
|
||||||
|
return response($result['message'], 429)->header('Retry-After', 60);
|
||||||
|
} elseif ($result['status'] === 'skipped') {
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
'application' => $application->name,
|
'application' => $application->name,
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
@@ -161,7 +146,9 @@ class Bitbucket extends Controller
|
|||||||
is_webhook: true,
|
is_webhook: true,
|
||||||
git_type: 'bitbucket'
|
git_type: 'bitbucket'
|
||||||
);
|
);
|
||||||
if ($result['status'] === 'skipped') {
|
if ($result['status'] === 'queue_full') {
|
||||||
|
return response($result['message'], 429)->header('Retry-After', 60);
|
||||||
|
} elseif ($result['status'] === 'skipped') {
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
'application' => $application->name,
|
'application' => $application->name,
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
@@ -185,9 +172,10 @@ class Bitbucket extends Controller
|
|||||||
if ($x_bitbucket_event === 'pullrequest:rejected' || $x_bitbucket_event === 'pullrequest:fulfilled') {
|
if ($x_bitbucket_event === 'pullrequest:rejected' || $x_bitbucket_event === 'pullrequest:fulfilled') {
|
||||||
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||||
if ($found) {
|
if ($found) {
|
||||||
$found->delete();
|
// Use comprehensive cleanup that cancels active deployments,
|
||||||
$container_name = generateApplicationContainerName($application, $pull_request_id);
|
// kills helper containers, and removes all PR containers
|
||||||
instant_remote_process(["docker rm -f $container_name"], $application->destination->server);
|
CleanupPreviewDeployment::run($application, $pull_request_id, $found);
|
||||||
|
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
'application' => $application->name,
|
'application' => $application->name,
|
||||||
'status' => 'success',
|
'status' => 'success',
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Webhook;
|
namespace App\Http\Controllers\Webhook;
|
||||||
|
|
||||||
|
use App\Actions\Application\CleanupPreviewDeployment;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Application;
|
use App\Models\Application;
|
||||||
use App\Models\ApplicationPreview;
|
use App\Models\ApplicationPreview;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Visus\Cuid2\Cuid2;
|
use Visus\Cuid2\Cuid2;
|
||||||
|
|
||||||
@@ -18,30 +18,6 @@ class Gitea extends Controller
|
|||||||
try {
|
try {
|
||||||
$return_payloads = collect([]);
|
$return_payloads = collect([]);
|
||||||
$x_gitea_delivery = request()->header('X-Gitea-Delivery');
|
$x_gitea_delivery = request()->header('X-Gitea-Delivery');
|
||||||
if (app()->isDownForMaintenance()) {
|
|
||||||
$epoch = now()->valueOf();
|
|
||||||
$files = Storage::disk('webhooks-during-maintenance')->files();
|
|
||||||
$gitea_delivery_found = collect($files)->filter(function ($file) use ($x_gitea_delivery) {
|
|
||||||
return Str::contains($file, $x_gitea_delivery);
|
|
||||||
})->first();
|
|
||||||
if ($gitea_delivery_found) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$data = [
|
|
||||||
'attributes' => $request->attributes->all(),
|
|
||||||
'request' => $request->request->all(),
|
|
||||||
'query' => $request->query->all(),
|
|
||||||
'server' => $request->server->all(),
|
|
||||||
'files' => $request->files->all(),
|
|
||||||
'cookies' => $request->cookies->all(),
|
|
||||||
'headers' => $request->headers->all(),
|
|
||||||
'content' => $request->getContent(),
|
|
||||||
];
|
|
||||||
$json = json_encode($data);
|
|
||||||
Storage::disk('webhooks-during-maintenance')->put("{$epoch}_Gitea::manual_{$x_gitea_delivery}", $json);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$x_gitea_event = Str::lower($request->header('X-Gitea-Event'));
|
$x_gitea_event = Str::lower($request->header('X-Gitea-Event'));
|
||||||
$x_hub_signature_256 = Str::after($request->header('X-Hub-Signature-256'), 'sha256=');
|
$x_hub_signature_256 = Str::after($request->header('X-Hub-Signature-256'), 'sha256=');
|
||||||
$content_type = $request->header('Content-Type');
|
$content_type = $request->header('Content-Type');
|
||||||
@@ -123,7 +99,9 @@ class Gitea extends Controller
|
|||||||
commit: data_get($payload, 'after', 'HEAD'),
|
commit: data_get($payload, 'after', 'HEAD'),
|
||||||
is_webhook: true,
|
is_webhook: true,
|
||||||
);
|
);
|
||||||
if ($result['status'] === 'skipped') {
|
if ($result['status'] === 'queue_full') {
|
||||||
|
return response($result['message'], 429)->header('Retry-After', 60);
|
||||||
|
} elseif ($result['status'] === 'skipped') {
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
'application' => $application->name,
|
'application' => $application->name,
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
@@ -193,7 +171,9 @@ class Gitea extends Controller
|
|||||||
is_webhook: true,
|
is_webhook: true,
|
||||||
git_type: 'gitea'
|
git_type: 'gitea'
|
||||||
);
|
);
|
||||||
if ($result['status'] === 'skipped') {
|
if ($result['status'] === 'queue_full') {
|
||||||
|
return response($result['message'], 429)->header('Retry-After', 60);
|
||||||
|
} elseif ($result['status'] === 'skipped') {
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
'application' => $application->name,
|
'application' => $application->name,
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
@@ -217,9 +197,10 @@ class Gitea extends Controller
|
|||||||
if ($action === 'closed') {
|
if ($action === 'closed') {
|
||||||
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||||
if ($found) {
|
if ($found) {
|
||||||
$found->delete();
|
// Use comprehensive cleanup that cancels active deployments,
|
||||||
$container_name = generateApplicationContainerName($application, $pull_request_id);
|
// kills helper containers, and removes all PR containers
|
||||||
instant_remote_process(["docker rm -f $container_name"], $application->destination->server);
|
CleanupPreviewDeployment::run($application, $pull_request_id, $found);
|
||||||
|
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
'application' => $application->name,
|
'application' => $application->name,
|
||||||
'status' => 'success',
|
'status' => 'success',
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Webhook;
|
namespace App\Http\Controllers\Webhook;
|
||||||
|
|
||||||
|
use App\Actions\Application\CleanupPreviewDeployment;
|
||||||
use App\Enums\ProcessStatus;
|
use App\Enums\ProcessStatus;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Jobs\ApplicationPullRequestUpdateJob;
|
use App\Jobs\ApplicationPullRequestUpdateJob;
|
||||||
use App\Jobs\DeleteResourceJob;
|
|
||||||
use App\Jobs\GithubAppPermissionJob;
|
use App\Jobs\GithubAppPermissionJob;
|
||||||
use App\Models\Application;
|
use App\Models\Application;
|
||||||
use App\Models\ApplicationPreview;
|
use App\Models\ApplicationPreview;
|
||||||
@@ -14,7 +14,6 @@ use App\Models\PrivateKey;
|
|||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Visus\Cuid2\Cuid2;
|
use Visus\Cuid2\Cuid2;
|
||||||
|
|
||||||
@@ -25,30 +24,6 @@ class Github extends Controller
|
|||||||
try {
|
try {
|
||||||
$return_payloads = collect([]);
|
$return_payloads = collect([]);
|
||||||
$x_github_delivery = request()->header('X-GitHub-Delivery');
|
$x_github_delivery = request()->header('X-GitHub-Delivery');
|
||||||
if (app()->isDownForMaintenance()) {
|
|
||||||
$epoch = now()->valueOf();
|
|
||||||
$files = Storage::disk('webhooks-during-maintenance')->files();
|
|
||||||
$github_delivery_found = collect($files)->filter(function ($file) use ($x_github_delivery) {
|
|
||||||
return Str::contains($file, $x_github_delivery);
|
|
||||||
})->first();
|
|
||||||
if ($github_delivery_found) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$data = [
|
|
||||||
'attributes' => $request->attributes->all(),
|
|
||||||
'request' => $request->request->all(),
|
|
||||||
'query' => $request->query->all(),
|
|
||||||
'server' => $request->server->all(),
|
|
||||||
'files' => $request->files->all(),
|
|
||||||
'cookies' => $request->cookies->all(),
|
|
||||||
'headers' => $request->headers->all(),
|
|
||||||
'content' => $request->getContent(),
|
|
||||||
];
|
|
||||||
$json = json_encode($data);
|
|
||||||
Storage::disk('webhooks-during-maintenance')->put("{$epoch}_Github::manual_{$x_github_delivery}", $json);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$x_github_event = Str::lower($request->header('X-GitHub-Event'));
|
$x_github_event = Str::lower($request->header('X-GitHub-Event'));
|
||||||
$x_hub_signature_256 = Str::after($request->header('X-Hub-Signature-256'), 'sha256=');
|
$x_hub_signature_256 = Str::after($request->header('X-Hub-Signature-256'), 'sha256=');
|
||||||
$content_type = $request->header('Content-Type');
|
$content_type = $request->header('Content-Type');
|
||||||
@@ -136,7 +111,9 @@ class Github extends Controller
|
|||||||
commit: data_get($payload, 'after', 'HEAD'),
|
commit: data_get($payload, 'after', 'HEAD'),
|
||||||
is_webhook: true,
|
is_webhook: true,
|
||||||
);
|
);
|
||||||
if ($result['status'] === 'skipped') {
|
if ($result['status'] === 'queue_full') {
|
||||||
|
return response($result['message'], 429)->header('Retry-After', 60);
|
||||||
|
} elseif ($result['status'] === 'skipped') {
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
'application' => $application->name,
|
'application' => $application->name,
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
@@ -222,7 +199,9 @@ class Github extends Controller
|
|||||||
is_webhook: true,
|
is_webhook: true,
|
||||||
git_type: 'github'
|
git_type: 'github'
|
||||||
);
|
);
|
||||||
if ($result['status'] === 'skipped') {
|
if ($result['status'] === 'queue_full') {
|
||||||
|
return response($result['message'], 429)->header('Retry-After', 60);
|
||||||
|
} elseif ($result['status'] === 'skipped') {
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
'application' => $application->name,
|
'application' => $application->name,
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
@@ -246,41 +225,10 @@ class Github extends Controller
|
|||||||
if ($action === 'closed') {
|
if ($action === 'closed') {
|
||||||
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||||
if ($found) {
|
if ($found) {
|
||||||
// Cancel any active deployments for this PR immediately
|
// Use comprehensive cleanup that cancels active deployments,
|
||||||
$activeDeployment = \App\Models\ApplicationDeploymentQueue::where('application_id', $application->id)
|
// kills helper containers, and removes all PR containers
|
||||||
->where('pull_request_id', $pull_request_id)
|
CleanupPreviewDeployment::run($application, $pull_request_id, $found);
|
||||||
->whereIn('status', [
|
|
||||||
\App\Enums\ApplicationDeploymentStatus::QUEUED->value,
|
|
||||||
\App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value,
|
|
||||||
])
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($activeDeployment) {
|
|
||||||
try {
|
|
||||||
// Mark deployment as cancelled
|
|
||||||
$activeDeployment->update([
|
|
||||||
'status' => \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Add cancellation log entry
|
|
||||||
$activeDeployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr');
|
|
||||||
|
|
||||||
// Check if helper container exists and kill it
|
|
||||||
$deployment_uuid = $activeDeployment->deployment_uuid;
|
|
||||||
$server = $application->destination->server;
|
|
||||||
$checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'";
|
|
||||||
$containerExists = instant_remote_process([$checkCommand], $server);
|
|
||||||
|
|
||||||
if ($containerExists && str($containerExists)->trim()->isNotEmpty()) {
|
|
||||||
instant_remote_process(["docker rm -f {$deployment_uuid}"], $server);
|
|
||||||
$activeDeployment->addLogEntry('Deployment container stopped.');
|
|
||||||
}
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
// Silently handle errors during deployment cancellation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DeleteResourceJob::dispatch($found);
|
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
'application' => $application->name,
|
'application' => $application->name,
|
||||||
'status' => 'success',
|
'status' => 'success',
|
||||||
@@ -310,30 +258,6 @@ class Github extends Controller
|
|||||||
$return_payloads = collect([]);
|
$return_payloads = collect([]);
|
||||||
$id = null;
|
$id = null;
|
||||||
$x_github_delivery = $request->header('X-GitHub-Delivery');
|
$x_github_delivery = $request->header('X-GitHub-Delivery');
|
||||||
if (app()->isDownForMaintenance()) {
|
|
||||||
$epoch = now()->valueOf();
|
|
||||||
$files = Storage::disk('webhooks-during-maintenance')->files();
|
|
||||||
$github_delivery_found = collect($files)->filter(function ($file) use ($x_github_delivery) {
|
|
||||||
return Str::contains($file, $x_github_delivery);
|
|
||||||
})->first();
|
|
||||||
if ($github_delivery_found) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$data = [
|
|
||||||
'attributes' => $request->attributes->all(),
|
|
||||||
'request' => $request->request->all(),
|
|
||||||
'query' => $request->query->all(),
|
|
||||||
'server' => $request->server->all(),
|
|
||||||
'files' => $request->files->all(),
|
|
||||||
'cookies' => $request->cookies->all(),
|
|
||||||
'headers' => $request->headers->all(),
|
|
||||||
'content' => $request->getContent(),
|
|
||||||
];
|
|
||||||
$json = json_encode($data);
|
|
||||||
Storage::disk('webhooks-during-maintenance')->put("{$epoch}_Github::normal_{$x_github_delivery}", $json);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$x_github_event = Str::lower($request->header('X-GitHub-Event'));
|
$x_github_event = Str::lower($request->header('X-GitHub-Event'));
|
||||||
$x_github_hook_installation_target_id = $request->header('X-GitHub-Hook-Installation-Target-Id');
|
$x_github_hook_installation_target_id = $request->header('X-GitHub-Hook-Installation-Target-Id');
|
||||||
$x_hub_signature_256 = Str::after($request->header('X-Hub-Signature-256'), 'sha256=');
|
$x_hub_signature_256 = Str::after($request->header('X-Hub-Signature-256'), 'sha256=');
|
||||||
@@ -385,7 +309,9 @@ class Github extends Controller
|
|||||||
if (! $id || ! $branch) {
|
if (! $id || ! $branch) {
|
||||||
return response('Nothing to do. No id or branch found.');
|
return response('Nothing to do. No id or branch found.');
|
||||||
}
|
}
|
||||||
$applications = Application::where('repository_project_id', $id)->whereRelation('source', 'is_public', false);
|
$applications = Application::where('repository_project_id', $id)
|
||||||
|
->where('source_id', $github_app->id)
|
||||||
|
->whereRelation('source', 'is_public', false);
|
||||||
if ($x_github_event === 'push') {
|
if ($x_github_event === 'push') {
|
||||||
$applications = $applications->where('git_branch', $branch)->get();
|
$applications = $applications->where('git_branch', $branch)->get();
|
||||||
if ($applications->isEmpty()) {
|
if ($applications->isEmpty()) {
|
||||||
@@ -427,12 +353,15 @@ class Github extends Controller
|
|||||||
force_rebuild: false,
|
force_rebuild: false,
|
||||||
is_webhook: true,
|
is_webhook: true,
|
||||||
);
|
);
|
||||||
|
if ($result['status'] === 'queue_full') {
|
||||||
|
return response($result['message'], 429)->header('Retry-After', 60);
|
||||||
|
}
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
'status' => $result['status'],
|
'status' => $result['status'],
|
||||||
'message' => $result['message'],
|
'message' => $result['message'],
|
||||||
'application_uuid' => $application->uuid,
|
'application_uuid' => $application->uuid,
|
||||||
'application_name' => $application->name,
|
'application_name' => $application->name,
|
||||||
'deployment_uuid' => $result['deployment_uuid'],
|
'deployment_uuid' => $result['deployment_uuid'] ?? null,
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
$paths = str($application->watch_paths)->explode("\n");
|
$paths = str($application->watch_paths)->explode("\n");
|
||||||
@@ -491,7 +420,9 @@ class Github extends Controller
|
|||||||
is_webhook: true,
|
is_webhook: true,
|
||||||
git_type: 'github'
|
git_type: 'github'
|
||||||
);
|
);
|
||||||
if ($result['status'] === 'skipped') {
|
if ($result['status'] === 'queue_full') {
|
||||||
|
return response($result['message'], 429)->header('Retry-After', 60);
|
||||||
|
} elseif ($result['status'] === 'skipped') {
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
'application' => $application->name,
|
'application' => $application->name,
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
@@ -515,53 +446,12 @@ class Github extends Controller
|
|||||||
if ($action === 'closed' || $action === 'close') {
|
if ($action === 'closed' || $action === 'close') {
|
||||||
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||||
if ($found) {
|
if ($found) {
|
||||||
// Cancel any active deployments for this PR immediately
|
// Delete the PR comment on GitHub (GitHub-specific feature)
|
||||||
$activeDeployment = \App\Models\ApplicationDeploymentQueue::where('application_id', $application->id)
|
|
||||||
->where('pull_request_id', $pull_request_id)
|
|
||||||
->whereIn('status', [
|
|
||||||
\App\Enums\ApplicationDeploymentStatus::QUEUED->value,
|
|
||||||
\App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value,
|
|
||||||
])
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($activeDeployment) {
|
|
||||||
try {
|
|
||||||
// Mark deployment as cancelled
|
|
||||||
$activeDeployment->update([
|
|
||||||
'status' => \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Add cancellation log entry
|
|
||||||
$activeDeployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr');
|
|
||||||
|
|
||||||
// Check if helper container exists and kill it
|
|
||||||
$deployment_uuid = $activeDeployment->deployment_uuid;
|
|
||||||
$server = $application->destination->server;
|
|
||||||
$checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'";
|
|
||||||
$containerExists = instant_remote_process([$checkCommand], $server);
|
|
||||||
|
|
||||||
if ($containerExists && str($containerExists)->trim()->isNotEmpty()) {
|
|
||||||
instant_remote_process(["docker rm -f {$deployment_uuid}"], $server);
|
|
||||||
$activeDeployment->addLogEntry('Deployment container stopped.');
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (\Throwable $e) {
|
|
||||||
// Silently handle errors during deployment cancellation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up any deployed containers
|
|
||||||
$containers = getCurrentApplicationContainerStatus($application->destination->server, $application->id, $pull_request_id);
|
|
||||||
if ($containers->isNotEmpty()) {
|
|
||||||
$containers->each(function ($container) use ($application) {
|
|
||||||
$container_name = data_get($container, 'Names');
|
|
||||||
instant_remote_process(["docker rm -f $container_name"], $application->destination->server);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
ApplicationPullRequestUpdateJob::dispatchSync(application: $application, preview: $found, status: ProcessStatus::CLOSED);
|
ApplicationPullRequestUpdateJob::dispatchSync(application: $application, preview: $found, status: ProcessStatus::CLOSED);
|
||||||
|
|
||||||
DeleteResourceJob::dispatch($found);
|
// Use comprehensive cleanup that cancels active deployments,
|
||||||
|
// kills helper containers, and removes all PR containers
|
||||||
|
CleanupPreviewDeployment::run($application, $pull_request_id, $found);
|
||||||
|
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
'application' => $application->name,
|
'application' => $application->name,
|
||||||
@@ -624,23 +514,6 @@ class Github extends Controller
|
|||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$installation_id = $request->get('installation_id');
|
$installation_id = $request->get('installation_id');
|
||||||
if (app()->isDownForMaintenance()) {
|
|
||||||
$epoch = now()->valueOf();
|
|
||||||
$data = [
|
|
||||||
'attributes' => $request->attributes->all(),
|
|
||||||
'request' => $request->request->all(),
|
|
||||||
'query' => $request->query->all(),
|
|
||||||
'server' => $request->server->all(),
|
|
||||||
'files' => $request->files->all(),
|
|
||||||
'cookies' => $request->cookies->all(),
|
|
||||||
'headers' => $request->headers->all(),
|
|
||||||
'content' => $request->getContent(),
|
|
||||||
];
|
|
||||||
$json = json_encode($data);
|
|
||||||
Storage::disk('webhooks-during-maintenance')->put("{$epoch}_Github::install_{$installation_id}", $json);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
$source = $request->get('source');
|
$source = $request->get('source');
|
||||||
$setup_action = $request->get('setup_action');
|
$setup_action = $request->get('setup_action');
|
||||||
$github_app = GithubApp::where('uuid', $source)->firstOrFail();
|
$github_app = GithubApp::where('uuid', $source)->firstOrFail();
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Webhook;
|
namespace App\Http\Controllers\Webhook;
|
||||||
|
|
||||||
|
use App\Actions\Application\CleanupPreviewDeployment;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\Application;
|
use App\Models\Application;
|
||||||
use App\Models\ApplicationPreview;
|
use App\Models\ApplicationPreview;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Visus\Cuid2\Cuid2;
|
use Visus\Cuid2\Cuid2;
|
||||||
|
|
||||||
@@ -16,24 +16,6 @@ class Gitlab extends Controller
|
|||||||
public function manual(Request $request)
|
public function manual(Request $request)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
if (app()->isDownForMaintenance()) {
|
|
||||||
$epoch = now()->valueOf();
|
|
||||||
$data = [
|
|
||||||
'attributes' => $request->attributes->all(),
|
|
||||||
'request' => $request->request->all(),
|
|
||||||
'query' => $request->query->all(),
|
|
||||||
'server' => $request->server->all(),
|
|
||||||
'files' => $request->files->all(),
|
|
||||||
'cookies' => $request->cookies->all(),
|
|
||||||
'headers' => $request->headers->all(),
|
|
||||||
'content' => $request->getContent(),
|
|
||||||
];
|
|
||||||
$json = json_encode($data);
|
|
||||||
Storage::disk('webhooks-during-maintenance')->put("{$epoch}_Gitlab::manual_gitlab", $json);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$return_payloads = collect([]);
|
$return_payloads = collect([]);
|
||||||
$payload = $request->collect();
|
$payload = $request->collect();
|
||||||
$headers = $request->headers->all();
|
$headers = $request->headers->all();
|
||||||
@@ -149,7 +131,9 @@ class Gitlab extends Controller
|
|||||||
force_rebuild: false,
|
force_rebuild: false,
|
||||||
is_webhook: true,
|
is_webhook: true,
|
||||||
);
|
);
|
||||||
if ($result['status'] === 'skipped') {
|
if ($result['status'] === 'queue_full') {
|
||||||
|
return response($result['message'], 429)->header('Retry-After', 60);
|
||||||
|
} elseif ($result['status'] === 'skipped') {
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
'status' => $result['status'],
|
'status' => $result['status'],
|
||||||
'message' => $result['message'],
|
'message' => $result['message'],
|
||||||
@@ -220,7 +204,9 @@ class Gitlab extends Controller
|
|||||||
is_webhook: true,
|
is_webhook: true,
|
||||||
git_type: 'gitlab'
|
git_type: 'gitlab'
|
||||||
);
|
);
|
||||||
if ($result['status'] === 'skipped') {
|
if ($result['status'] === 'queue_full') {
|
||||||
|
return response($result['message'], 429)->header('Retry-After', 60);
|
||||||
|
} elseif ($result['status'] === 'skipped') {
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
'application' => $application->name,
|
'application' => $application->name,
|
||||||
'status' => 'skipped',
|
'status' => 'skipped',
|
||||||
@@ -243,22 +229,22 @@ class Gitlab extends Controller
|
|||||||
} elseif ($action === 'closed' || $action === 'close' || $action === 'merge') {
|
} elseif ($action === 'closed' || $action === 'close' || $action === 'merge') {
|
||||||
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||||
if ($found) {
|
if ($found) {
|
||||||
$found->delete();
|
// Use comprehensive cleanup that cancels active deployments,
|
||||||
$container_name = generateApplicationContainerName($application, $pull_request_id);
|
// kills helper containers, and removes all PR containers
|
||||||
instant_remote_process(["docker rm -f $container_name"], $application->destination->server);
|
CleanupPreviewDeployment::run($application, $pull_request_id, $found);
|
||||||
|
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
'application' => $application->name,
|
'application' => $application->name,
|
||||||
'status' => 'success',
|
'status' => 'success',
|
||||||
'message' => 'Preview Deployment closed',
|
'message' => 'Preview deployment closed.',
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
$return_payloads->push([
|
||||||
|
'application' => $application->name,
|
||||||
|
'status' => 'failed',
|
||||||
|
'message' => 'No preview deployment found.',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return response($return_payloads);
|
|
||||||
}
|
}
|
||||||
$return_payloads->push([
|
|
||||||
'application' => $application->name,
|
|
||||||
'status' => 'failed',
|
|
||||||
'message' => 'No Preview Deployment found',
|
|
||||||
]);
|
|
||||||
} else {
|
} else {
|
||||||
$return_payloads->push([
|
$return_payloads->push([
|
||||||
'application' => $application->name,
|
'application' => $application->name,
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ use App\Http\Controllers\Controller;
|
|||||||
use App\Jobs\StripeProcessJob;
|
use App\Jobs\StripeProcessJob;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
|
|
||||||
class Stripe extends Controller
|
class Stripe extends Controller
|
||||||
{
|
{
|
||||||
@@ -20,23 +19,6 @@ class Stripe extends Controller
|
|||||||
$signature,
|
$signature,
|
||||||
$webhookSecret
|
$webhookSecret
|
||||||
);
|
);
|
||||||
if (app()->isDownForMaintenance()) {
|
|
||||||
$epoch = now()->valueOf();
|
|
||||||
$data = [
|
|
||||||
'attributes' => $request->attributes->all(),
|
|
||||||
'request' => $request->request->all(),
|
|
||||||
'query' => $request->query->all(),
|
|
||||||
'server' => $request->server->all(),
|
|
||||||
'files' => $request->files->all(),
|
|
||||||
'cookies' => $request->cookies->all(),
|
|
||||||
'headers' => $request->headers->all(),
|
|
||||||
'content' => $request->getContent(),
|
|
||||||
];
|
|
||||||
$json = json_encode($data);
|
|
||||||
Storage::disk('webhooks-during-maintenance')->put("{$epoch}_Stripe::events_stripe", $json);
|
|
||||||
|
|
||||||
return response('Webhook received. Cool cool cool cool cool.', 200);
|
|
||||||
}
|
|
||||||
StripeProcessJob::dispatch($event);
|
StripeProcessJob::dispatch($event);
|
||||||
|
|
||||||
return response('Webhook received. Cool cool cool cool cool.', 200);
|
return response('Webhook received. Cool cool cool cool cool.', 200);
|
||||||
|
|||||||
@@ -486,15 +486,38 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
|
|
||||||
private function post_deployment()
|
private function post_deployment()
|
||||||
{
|
{
|
||||||
GetContainersStatus::dispatch($this->server);
|
// Mark deployment as complete FIRST, before any other operations
|
||||||
|
// This ensures the deployment status is FINISHED even if subsequent operations fail
|
||||||
$this->completeDeployment();
|
$this->completeDeployment();
|
||||||
|
|
||||||
|
// Then handle side effects - these should not fail the deployment
|
||||||
|
try {
|
||||||
|
GetContainersStatus::dispatch($this->server);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
\Log::warning('Failed to dispatch GetContainersStatus for deployment '.$this->deployment_uuid.': '.$e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->pull_request_id !== 0) {
|
if ($this->pull_request_id !== 0) {
|
||||||
if ($this->application->is_github_based()) {
|
if ($this->application->is_github_based()) {
|
||||||
ApplicationPullRequestUpdateJob::dispatch(application: $this->application, preview: $this->preview, deployment_uuid: $this->deployment_uuid, status: ProcessStatus::FINISHED);
|
try {
|
||||||
|
ApplicationPullRequestUpdateJob::dispatch(application: $this->application, preview: $this->preview, deployment_uuid: $this->deployment_uuid, status: ProcessStatus::FINISHED);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
\Log::warning('Failed to dispatch PR update for deployment '.$this->deployment_uuid.': '.$e->getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$this->run_post_deployment_command();
|
|
||||||
$this->application->isConfigurationChanged(true);
|
try {
|
||||||
|
$this->run_post_deployment_command();
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
\Log::warning('Post deployment command failed for '.$this->deployment_uuid.': '.$e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->application->isConfigurationChanged(true);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
\Log::warning('Failed to mark configuration as changed for deployment '.$this->deployment_uuid.': '.$e->getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function deploy_simple_dockerfile()
|
private function deploy_simple_dockerfile()
|
||||||
@@ -620,7 +643,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
$this->application_deployment_queue->addLogEntry('Build secrets are configured. Ensure your docker-compose file includes build.secrets configuration for services that need them.');
|
$this->application_deployment_queue->addLogEntry('Build secrets are configured. Ensure your docker-compose file includes build.secrets configuration for services that need them.');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$composeFile = $this->application->parse(pull_request_id: $this->pull_request_id, preview_id: data_get($this->preview, 'id'));
|
$composeFile = $this->application->parse(pull_request_id: $this->pull_request_id, preview_id: data_get($this->preview, 'id'), commit: $this->commit);
|
||||||
// Always add .env file to services
|
// Always add .env file to services
|
||||||
$services = collect(data_get($composeFile, 'services', []));
|
$services = collect(data_get($composeFile, 'services', []));
|
||||||
$services = $services->map(function ($service, $name) {
|
$services = $services->map(function ($service, $name) {
|
||||||
@@ -670,13 +693,20 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
$build_command = "DOCKER_BUILDKIT=1 {$build_command}";
|
$build_command = "DOCKER_BUILDKIT=1 {$build_command}";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Append build arguments if not using build secrets (matching default behavior)
|
// Inject build arguments after build subcommand if not using build secrets
|
||||||
if (! $this->application->settings->use_build_secrets && $this->build_args instanceof \Illuminate\Support\Collection && $this->build_args->isNotEmpty()) {
|
if (! $this->application->settings->use_build_secrets && $this->build_args instanceof \Illuminate\Support\Collection && $this->build_args->isNotEmpty()) {
|
||||||
$build_args_string = $this->build_args->implode(' ');
|
$build_args_string = $this->build_args->implode(' ');
|
||||||
// Escape single quotes for bash -c context used by executeInDocker
|
// Escape single quotes for bash -c context used by executeInDocker
|
||||||
$build_args_string = str_replace("'", "'\\''", $build_args_string);
|
$build_args_string = str_replace("'", "'\\''", $build_args_string);
|
||||||
$build_command .= " {$build_args_string}";
|
|
||||||
$this->application_deployment_queue->addLogEntry('Adding build arguments to custom Docker Compose build command.');
|
// Inject build args right after 'build' subcommand (not at the end)
|
||||||
|
$original_command = $build_command;
|
||||||
|
$build_command = injectDockerComposeBuildArgs($build_command, $build_args_string);
|
||||||
|
|
||||||
|
// Only log if build args were actually injected (command was modified)
|
||||||
|
if ($build_command !== $original_command) {
|
||||||
|
$this->application_deployment_queue->addLogEntry('Adding build arguments to custom Docker Compose build command.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->execute_remote_command(
|
$this->execute_remote_command(
|
||||||
@@ -1363,7 +1393,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
$envs_base64 = base64_encode($environment_variables->implode("\n"));
|
$envs_base64 = base64_encode($environment_variables->implode("\n"));
|
||||||
|
|
||||||
// Write .env file to workdir (for container runtime)
|
// Write .env file to workdir (for container runtime)
|
||||||
$this->application_deployment_queue->addLogEntry('Creating .env file with runtime variables for build phase.', hidden: true);
|
$this->application_deployment_queue->addLogEntry('Creating .env file with runtime variables for container.', hidden: true);
|
||||||
$this->execute_remote_command(
|
$this->execute_remote_command(
|
||||||
[
|
[
|
||||||
executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/.env > /dev/null"),
|
executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/.env > /dev/null"),
|
||||||
@@ -1401,15 +1431,44 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
|
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
|
||||||
}
|
}
|
||||||
|
|
||||||
$envs = collect([]);
|
// Use associative array for automatic deduplication
|
||||||
$coolify_envs = $this->generate_coolify_env_variables();
|
$envs_dict = [];
|
||||||
|
|
||||||
// Add COOLIFY variables
|
// 1. Add nixpacks plan variables FIRST (lowest priority - can be overridden)
|
||||||
$coolify_envs->each(function ($item, $key) use ($envs) {
|
if ($this->build_pack === 'nixpacks' &&
|
||||||
$envs->push($key.'='.escapeBashEnvValue($item));
|
isset($this->nixpacks_plan_json) &&
|
||||||
});
|
$this->nixpacks_plan_json->isNotEmpty()) {
|
||||||
|
|
||||||
// Add SERVICE_NAME variables for Docker Compose builds
|
$planVariables = data_get($this->nixpacks_plan_json, 'variables', []);
|
||||||
|
|
||||||
|
if (! empty($planVariables)) {
|
||||||
|
if (isDev()) {
|
||||||
|
$this->application_deployment_queue->addLogEntry('[DEBUG] Adding '.count($planVariables).' nixpacks plan variables to buildtime.env');
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($planVariables as $key => $value) {
|
||||||
|
// Skip COOLIFY_* and SERVICE_* - they'll be added later with higher priority
|
||||||
|
if (str_starts_with($key, 'COOLIFY_') || str_starts_with($key, 'SERVICE_')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$escapedValue = escapeBashEnvValue($value);
|
||||||
|
$envs_dict[$key] = $escapedValue;
|
||||||
|
|
||||||
|
if (isDev()) {
|
||||||
|
$this->application_deployment_queue->addLogEntry("[DEBUG] Nixpacks var: {$key}={$escapedValue}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Add COOLIFY variables (can override nixpacks, but shouldn't happen in practice)
|
||||||
|
$coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true);
|
||||||
|
foreach ($coolify_envs as $key => $item) {
|
||||||
|
$envs_dict[$key] = escapeBashEnvValue($item);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Add SERVICE_NAME, SERVICE_FQDN, SERVICE_URL variables for Docker Compose builds
|
||||||
if ($this->build_pack === 'dockercompose') {
|
if ($this->build_pack === 'dockercompose') {
|
||||||
if ($this->pull_request_id === 0) {
|
if ($this->pull_request_id === 0) {
|
||||||
// Generate SERVICE_NAME for dockercompose services from processed compose
|
// Generate SERVICE_NAME for dockercompose services from processed compose
|
||||||
@@ -1420,7 +1479,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
}
|
}
|
||||||
$services = data_get($dockerCompose, 'services', []);
|
$services = data_get($dockerCompose, 'services', []);
|
||||||
foreach ($services as $serviceName => $_) {
|
foreach ($services as $serviceName => $_) {
|
||||||
$envs->push('SERVICE_NAME_'.str($serviceName)->upper().'='.escapeBashEnvValue($serviceName));
|
$envs_dict['SERVICE_NAME_'.str($serviceName)->upper()] = escapeBashEnvValue($serviceName);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate SERVICE_FQDN & SERVICE_URL for non-PR deployments
|
// Generate SERVICE_FQDN & SERVICE_URL for non-PR deployments
|
||||||
@@ -1433,8 +1492,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
$coolifyScheme = $coolifyUrl->getScheme();
|
$coolifyScheme = $coolifyUrl->getScheme();
|
||||||
$coolifyFqdn = $coolifyUrl->getHost();
|
$coolifyFqdn = $coolifyUrl->getHost();
|
||||||
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
|
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
|
||||||
$envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyUrl->__toString()));
|
$envs_dict['SERVICE_URL_'.str($forServiceName)->upper()] = escapeBashEnvValue($coolifyUrl->__toString());
|
||||||
$envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyFqdn));
|
$envs_dict['SERVICE_FQDN_'.str($forServiceName)->upper()] = escapeBashEnvValue($coolifyFqdn);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -1442,7 +1501,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
$rawDockerCompose = Yaml::parse($this->application->docker_compose_raw);
|
$rawDockerCompose = Yaml::parse($this->application->docker_compose_raw);
|
||||||
$rawServices = data_get($rawDockerCompose, 'services', []);
|
$rawServices = data_get($rawDockerCompose, 'services', []);
|
||||||
foreach ($rawServices as $rawServiceName => $_) {
|
foreach ($rawServices as $rawServiceName => $_) {
|
||||||
$envs->push('SERVICE_NAME_'.str($rawServiceName)->upper().'='.escapeBashEnvValue(addPreviewDeploymentSuffix($rawServiceName, $this->pull_request_id)));
|
$envs_dict['SERVICE_NAME_'.str($rawServiceName)->upper()] = escapeBashEnvValue(addPreviewDeploymentSuffix($rawServiceName, $this->pull_request_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate SERVICE_FQDN & SERVICE_URL for preview deployments with PR-specific domains
|
// Generate SERVICE_FQDN & SERVICE_URL for preview deployments with PR-specific domains
|
||||||
@@ -1455,17 +1514,16 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
$coolifyScheme = $coolifyUrl->getScheme();
|
$coolifyScheme = $coolifyUrl->getScheme();
|
||||||
$coolifyFqdn = $coolifyUrl->getHost();
|
$coolifyFqdn = $coolifyUrl->getHost();
|
||||||
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
|
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
|
||||||
$envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyUrl->__toString()));
|
$envs_dict['SERVICE_URL_'.str($forServiceName)->upper()] = escapeBashEnvValue($coolifyUrl->__toString());
|
||||||
$envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyFqdn));
|
$envs_dict['SERVICE_FQDN_'.str($forServiceName)->upper()] = escapeBashEnvValue($coolifyFqdn);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add build-time user variables only
|
// 4. Add user-defined build-time variables LAST (highest priority - can override everything)
|
||||||
if ($this->pull_request_id === 0) {
|
if ($this->pull_request_id === 0) {
|
||||||
$sorted_environment_variables = $this->application->environment_variables()
|
$sorted_environment_variables = $this->application->environment_variables()
|
||||||
->where('key', 'not like', 'NIXPACKS_%')
|
|
||||||
->where('is_buildtime', true) // ONLY build-time variables
|
->where('is_buildtime', true) // ONLY build-time variables
|
||||||
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
|
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
|
||||||
->get();
|
->get();
|
||||||
@@ -1483,7 +1541,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
// Strip outer quotes from real_value and apply proper bash escaping
|
// Strip outer quotes from real_value and apply proper bash escaping
|
||||||
$value = trim($env->real_value, "'");
|
$value = trim($env->real_value, "'");
|
||||||
$escapedValue = escapeBashEnvValue($value);
|
$escapedValue = escapeBashEnvValue($value);
|
||||||
$envs->push($env->key.'='.$escapedValue);
|
|
||||||
|
if (isDev() && isset($envs_dict[$env->key])) {
|
||||||
|
$this->application_deployment_queue->addLogEntry("[DEBUG] User override: {$env->key} (was: {$envs_dict[$env->key]}, now: {$escapedValue})");
|
||||||
|
}
|
||||||
|
|
||||||
|
$envs_dict[$env->key] = $escapedValue;
|
||||||
|
|
||||||
if (isDev()) {
|
if (isDev()) {
|
||||||
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
|
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
|
||||||
@@ -1495,7 +1558,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
} else {
|
} else {
|
||||||
// For normal vars, use double quotes to allow $VAR expansion
|
// For normal vars, use double quotes to allow $VAR expansion
|
||||||
$escapedValue = escapeBashDoubleQuoted($env->real_value);
|
$escapedValue = escapeBashDoubleQuoted($env->real_value);
|
||||||
$envs->push($env->key.'='.$escapedValue);
|
|
||||||
|
if (isDev() && isset($envs_dict[$env->key])) {
|
||||||
|
$this->application_deployment_queue->addLogEntry("[DEBUG] User override: {$env->key} (was: {$envs_dict[$env->key]}, now: {$escapedValue})");
|
||||||
|
}
|
||||||
|
|
||||||
|
$envs_dict[$env->key] = $escapedValue;
|
||||||
|
|
||||||
if (isDev()) {
|
if (isDev()) {
|
||||||
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
|
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
|
||||||
@@ -1507,7 +1575,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$sorted_environment_variables = $this->application->environment_variables_preview()
|
$sorted_environment_variables = $this->application->environment_variables_preview()
|
||||||
->where('key', 'not like', 'NIXPACKS_%')
|
|
||||||
->where('is_buildtime', true) // ONLY build-time variables
|
->where('is_buildtime', true) // ONLY build-time variables
|
||||||
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
|
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
|
||||||
->get();
|
->get();
|
||||||
@@ -1525,7 +1592,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
// Strip outer quotes from real_value and apply proper bash escaping
|
// Strip outer quotes from real_value and apply proper bash escaping
|
||||||
$value = trim($env->real_value, "'");
|
$value = trim($env->real_value, "'");
|
||||||
$escapedValue = escapeBashEnvValue($value);
|
$escapedValue = escapeBashEnvValue($value);
|
||||||
$envs->push($env->key.'='.$escapedValue);
|
|
||||||
|
if (isDev() && isset($envs_dict[$env->key])) {
|
||||||
|
$this->application_deployment_queue->addLogEntry("[DEBUG] User override: {$env->key} (was: {$envs_dict[$env->key]}, now: {$escapedValue})");
|
||||||
|
}
|
||||||
|
|
||||||
|
$envs_dict[$env->key] = $escapedValue;
|
||||||
|
|
||||||
if (isDev()) {
|
if (isDev()) {
|
||||||
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
|
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
|
||||||
@@ -1537,7 +1609,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
} else {
|
} else {
|
||||||
// For normal vars, use double quotes to allow $VAR expansion
|
// For normal vars, use double quotes to allow $VAR expansion
|
||||||
$escapedValue = escapeBashDoubleQuoted($env->real_value);
|
$escapedValue = escapeBashDoubleQuoted($env->real_value);
|
||||||
$envs->push($env->key.'='.$escapedValue);
|
|
||||||
|
if (isDev() && isset($envs_dict[$env->key])) {
|
||||||
|
$this->application_deployment_queue->addLogEntry("[DEBUG] User override: {$env->key} (was: {$envs_dict[$env->key]}, now: {$escapedValue})");
|
||||||
|
}
|
||||||
|
|
||||||
|
$envs_dict[$env->key] = $escapedValue;
|
||||||
|
|
||||||
if (isDev()) {
|
if (isDev()) {
|
||||||
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
|
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
|
||||||
@@ -1549,6 +1626,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert dictionary back to collection in KEY=VALUE format
|
||||||
|
$envs = collect([]);
|
||||||
|
foreach ($envs_dict as $key => $value) {
|
||||||
|
$envs->push($key.'='.$value);
|
||||||
|
}
|
||||||
|
|
||||||
// Return the generated environment variables
|
// Return the generated environment variables
|
||||||
if (isDev()) {
|
if (isDev()) {
|
||||||
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
|
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
|
||||||
@@ -1753,9 +1836,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
$this->application->update(['status' => 'running']);
|
$this->application->update(['status' => 'running']);
|
||||||
$this->application_deployment_queue->addLogEntry('New container is healthy.');
|
$this->application_deployment_queue->addLogEntry('New container is healthy.');
|
||||||
break;
|
break;
|
||||||
}
|
} elseif (str($this->saved_outputs->get('health_check'))->replace('"', '')->value() === 'unhealthy') {
|
||||||
if (str($this->saved_outputs->get('health_check'))->replace('"', '')->value() === 'unhealthy') {
|
|
||||||
$this->newVersionIsHealthy = false;
|
$this->newVersionIsHealthy = false;
|
||||||
|
$this->application_deployment_queue->addLogEntry('New container is unhealthy.', type: 'error');
|
||||||
$this->query_logs();
|
$this->query_logs();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -1957,7 +2040,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
|
|
||||||
private function set_coolify_variables()
|
private function set_coolify_variables()
|
||||||
{
|
{
|
||||||
$this->coolify_variables = "SOURCE_COMMIT={$this->commit} ";
|
$this->coolify_variables = '';
|
||||||
|
|
||||||
|
// Only include SOURCE_COMMIT in build context if enabled in settings
|
||||||
|
if ($this->application->settings->include_source_commit_in_build) {
|
||||||
|
$this->coolify_variables .= "SOURCE_COMMIT={$this->commit} ";
|
||||||
|
}
|
||||||
if ($this->pull_request_id === 0) {
|
if ($this->pull_request_id === 0) {
|
||||||
$fqdn = $this->application->fqdn;
|
$fqdn = $this->application->fqdn;
|
||||||
} else {
|
} else {
|
||||||
@@ -1979,7 +2067,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
$this->coolify_variables .= "COOLIFY_BRANCH={$this->application->git_branch} ";
|
$this->coolify_variables .= "COOLIFY_BRANCH={$this->application->git_branch} ";
|
||||||
}
|
}
|
||||||
$this->coolify_variables .= "COOLIFY_RESOURCE_UUID={$this->application->uuid} ";
|
$this->coolify_variables .= "COOLIFY_RESOURCE_UUID={$this->application->uuid} ";
|
||||||
$this->coolify_variables .= "COOLIFY_CONTAINER_NAME={$this->container_name} ";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function check_git_if_build_needed()
|
private function check_git_if_build_needed()
|
||||||
@@ -2217,38 +2304,44 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
$this->env_nixpacks_args = collect([]);
|
$this->env_nixpacks_args = collect([]);
|
||||||
if ($this->pull_request_id === 0) {
|
if ($this->pull_request_id === 0) {
|
||||||
foreach ($this->application->nixpacks_environment_variables as $env) {
|
foreach ($this->application->nixpacks_environment_variables as $env) {
|
||||||
if (! is_null($env->real_value)) {
|
if (! is_null($env->real_value) && $env->real_value !== '') {
|
||||||
$this->env_nixpacks_args->push("--env {$env->key}={$env->real_value}");
|
$this->env_nixpacks_args->push("--env {$env->key}={$env->real_value}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
foreach ($this->application->nixpacks_environment_variables_preview as $env) {
|
foreach ($this->application->nixpacks_environment_variables_preview as $env) {
|
||||||
if (! is_null($env->real_value)) {
|
if (! is_null($env->real_value) && $env->real_value !== '') {
|
||||||
$this->env_nixpacks_args->push("--env {$env->key}={$env->real_value}");
|
$this->env_nixpacks_args->push("--env {$env->key}={$env->real_value}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add COOLIFY_* environment variables to Nixpacks build context
|
// Add COOLIFY_* environment variables to Nixpacks build context
|
||||||
$coolify_envs = $this->generate_coolify_env_variables();
|
$coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true);
|
||||||
$coolify_envs->each(function ($value, $key) {
|
$coolify_envs->each(function ($value, $key) {
|
||||||
$this->env_nixpacks_args->push("--env {$key}={$value}");
|
// Only add environment variables with non-null and non-empty values
|
||||||
|
if (! is_null($value) && $value !== '') {
|
||||||
|
$this->env_nixpacks_args->push("--env {$key}={$value}");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$this->env_nixpacks_args = $this->env_nixpacks_args->implode(' ');
|
$this->env_nixpacks_args = $this->env_nixpacks_args->implode(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function generate_coolify_env_variables(): Collection
|
private function generate_coolify_env_variables(bool $forBuildTime = false): Collection
|
||||||
{
|
{
|
||||||
$coolify_envs = collect([]);
|
$coolify_envs = collect([]);
|
||||||
$local_branch = $this->branch;
|
$local_branch = $this->branch;
|
||||||
if ($this->pull_request_id !== 0) {
|
if ($this->pull_request_id !== 0) {
|
||||||
// Add SOURCE_COMMIT if not exists
|
// Only add SOURCE_COMMIT for runtime OR when explicitly enabled for build-time
|
||||||
if ($this->application->environment_variables_preview->where('key', 'SOURCE_COMMIT')->isEmpty()) {
|
// SOURCE_COMMIT changes with each commit and breaks Docker cache if included in build
|
||||||
if (! is_null($this->commit)) {
|
if (! $forBuildTime || $this->application->settings->include_source_commit_in_build) {
|
||||||
$coolify_envs->put('SOURCE_COMMIT', $this->commit);
|
if ($this->application->environment_variables_preview->where('key', 'SOURCE_COMMIT')->isEmpty()) {
|
||||||
} else {
|
if (! is_null($this->commit)) {
|
||||||
$coolify_envs->put('SOURCE_COMMIT', 'unknown');
|
$coolify_envs->put('SOURCE_COMMIT', $this->commit);
|
||||||
|
} else {
|
||||||
|
$coolify_envs->put('SOURCE_COMMIT', 'unknown');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($this->application->environment_variables_preview->where('key', 'COOLIFY_FQDN')->isEmpty()) {
|
if ($this->application->environment_variables_preview->where('key', 'COOLIFY_FQDN')->isEmpty()) {
|
||||||
@@ -2273,20 +2366,26 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
if ($this->application->environment_variables_preview->where('key', 'COOLIFY_RESOURCE_UUID')->isEmpty()) {
|
if ($this->application->environment_variables_preview->where('key', 'COOLIFY_RESOURCE_UUID')->isEmpty()) {
|
||||||
$coolify_envs->put('COOLIFY_RESOURCE_UUID', $this->application->uuid);
|
$coolify_envs->put('COOLIFY_RESOURCE_UUID', $this->application->uuid);
|
||||||
}
|
}
|
||||||
if ($this->application->environment_variables_preview->where('key', 'COOLIFY_CONTAINER_NAME')->isEmpty()) {
|
// Only add COOLIFY_CONTAINER_NAME for runtime (not build-time) - it changes every deployment and breaks Docker cache
|
||||||
$coolify_envs->put('COOLIFY_CONTAINER_NAME', $this->container_name);
|
if (! $forBuildTime) {
|
||||||
|
if ($this->application->environment_variables_preview->where('key', 'COOLIFY_CONTAINER_NAME')->isEmpty()) {
|
||||||
|
$coolify_envs->put('COOLIFY_CONTAINER_NAME', $this->container_name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
add_coolify_default_environment_variables($this->application, $coolify_envs, $this->application->environment_variables_preview);
|
add_coolify_default_environment_variables($this->application, $coolify_envs, $this->application->environment_variables_preview);
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// Add SOURCE_COMMIT if not exists
|
// Only add SOURCE_COMMIT for runtime OR when explicitly enabled for build-time
|
||||||
if ($this->application->environment_variables->where('key', 'SOURCE_COMMIT')->isEmpty()) {
|
// SOURCE_COMMIT changes with each commit and breaks Docker cache if included in build
|
||||||
if (! is_null($this->commit)) {
|
if (! $forBuildTime || $this->application->settings->include_source_commit_in_build) {
|
||||||
$coolify_envs->put('SOURCE_COMMIT', $this->commit);
|
if ($this->application->environment_variables->where('key', 'SOURCE_COMMIT')->isEmpty()) {
|
||||||
} else {
|
if (! is_null($this->commit)) {
|
||||||
$coolify_envs->put('SOURCE_COMMIT', 'unknown');
|
$coolify_envs->put('SOURCE_COMMIT', $this->commit);
|
||||||
|
} else {
|
||||||
|
$coolify_envs->put('SOURCE_COMMIT', 'unknown');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($this->application->environment_variables->where('key', 'COOLIFY_FQDN')->isEmpty()) {
|
if ($this->application->environment_variables->where('key', 'COOLIFY_FQDN')->isEmpty()) {
|
||||||
@@ -2311,8 +2410,11 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
if ($this->application->environment_variables->where('key', 'COOLIFY_RESOURCE_UUID')->isEmpty()) {
|
if ($this->application->environment_variables->where('key', 'COOLIFY_RESOURCE_UUID')->isEmpty()) {
|
||||||
$coolify_envs->put('COOLIFY_RESOURCE_UUID', $this->application->uuid);
|
$coolify_envs->put('COOLIFY_RESOURCE_UUID', $this->application->uuid);
|
||||||
}
|
}
|
||||||
if ($this->application->environment_variables->where('key', 'COOLIFY_CONTAINER_NAME')->isEmpty()) {
|
// Only add COOLIFY_CONTAINER_NAME for runtime (not build-time) - it changes every deployment and breaks Docker cache
|
||||||
$coolify_envs->put('COOLIFY_CONTAINER_NAME', $this->container_name);
|
if (! $forBuildTime) {
|
||||||
|
if ($this->application->environment_variables->where('key', 'COOLIFY_CONTAINER_NAME')->isEmpty()) {
|
||||||
|
$coolify_envs->put('COOLIFY_CONTAINER_NAME', $this->container_name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2326,9 +2428,13 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
private function generate_env_variables()
|
private function generate_env_variables()
|
||||||
{
|
{
|
||||||
$this->env_args = collect([]);
|
$this->env_args = collect([]);
|
||||||
$this->env_args->put('SOURCE_COMMIT', $this->commit);
|
|
||||||
|
|
||||||
$coolify_envs = $this->generate_coolify_env_variables();
|
// Only include SOURCE_COMMIT in build args if enabled in settings
|
||||||
|
if ($this->application->settings->include_source_commit_in_build) {
|
||||||
|
$this->env_args->put('SOURCE_COMMIT', $this->commit);
|
||||||
|
}
|
||||||
|
|
||||||
|
$coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true);
|
||||||
$coolify_envs->each(function ($value, $key) {
|
$coolify_envs->each(function ($value, $key) {
|
||||||
$this->env_args->put($key, $value);
|
$this->env_args->put($key, $value);
|
||||||
});
|
});
|
||||||
@@ -2748,7 +2854,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
} else {
|
} else {
|
||||||
// Traditional build args approach - generate COOLIFY_ variables locally
|
// Traditional build args approach - generate COOLIFY_ variables locally
|
||||||
// Generate COOLIFY_ variables locally for build args
|
// Generate COOLIFY_ variables locally for build args
|
||||||
$coolify_envs = $this->generate_coolify_env_variables();
|
$coolify_envs = $this->generate_coolify_env_variables(forBuildTime: true);
|
||||||
$coolify_envs->each(function ($value, $key) {
|
$coolify_envs->each(function ($value, $key) {
|
||||||
$this->build_args->push("--build-arg '{$key}'");
|
$this->build_args->push("--build-arg '{$key}'");
|
||||||
});
|
});
|
||||||
@@ -3070,7 +3176,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
|||||||
try {
|
try {
|
||||||
$timeout = isDev() ? 1 : 30;
|
$timeout = isDev() ? 1 : 30;
|
||||||
$this->execute_remote_command(
|
$this->execute_remote_command(
|
||||||
["docker stop --time=$timeout $containerName", 'hidden' => true, 'ignore_errors' => true],
|
["docker stop -t $timeout $containerName", 'hidden' => true, 'ignore_errors' => true],
|
||||||
["docker rm -f $containerName", 'hidden' => true, 'ignore_errors' => true]
|
["docker rm -f $containerName", 'hidden' => true, 'ignore_errors' => true]
|
||||||
);
|
);
|
||||||
} catch (Exception $error) {
|
} catch (Exception $error) {
|
||||||
@@ -3107,6 +3213,18 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
|||||||
$this->graceful_shutdown_container($this->container_name);
|
$this->graceful_shutdown_container($this->container_name);
|
||||||
}
|
}
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
|
// If new version is healthy, this is just cleanup - don't fail the deployment
|
||||||
|
if ($this->newVersionIsHealthy || $force) {
|
||||||
|
$this->application_deployment_queue->addLogEntry(
|
||||||
|
"Warning: Could not remove old container: {$e->getMessage()}",
|
||||||
|
'stderr',
|
||||||
|
hidden: true
|
||||||
|
);
|
||||||
|
|
||||||
|
return; // Don't re-throw - cleanup failures shouldn't fail successful deployments
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only re-throw if deployment hasn't succeeded yet
|
||||||
throw new DeploymentException("Failed to stop running container: {$e->getMessage()}", $e->getCode(), $e);
|
throw new DeploymentException("Failed to stop running container: {$e->getMessage()}", $e->getCode(), $e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3294,7 +3412,9 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
|||||||
private function generate_secrets_hash($variables)
|
private function generate_secrets_hash($variables)
|
||||||
{
|
{
|
||||||
if (! $this->secrets_hash_key) {
|
if (! $this->secrets_hash_key) {
|
||||||
$this->secrets_hash_key = bin2hex(random_bytes(32));
|
// Use APP_KEY as deterministic hash key to preserve Docker build cache
|
||||||
|
// Random keys would change every deployment, breaking cache even when secrets haven't changed
|
||||||
|
$this->secrets_hash_key = config('app.key');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($variables instanceof Collection) {
|
if ($variables instanceof Collection) {
|
||||||
@@ -3337,100 +3457,121 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
|||||||
{
|
{
|
||||||
if ($this->dockerBuildkitSupported) {
|
if ($this->dockerBuildkitSupported) {
|
||||||
// We dont need to add build secrets to dockerfile for buildkit, as we already added them with --secret flag in function generate_docker_env_flags_for_secrets
|
// We dont need to add build secrets to dockerfile for buildkit, as we already added them with --secret flag in function generate_docker_env_flags_for_secrets
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip ARG injection if disabled by user - preserves Docker build cache
|
||||||
|
if ($this->application->settings->inject_build_args_to_dockerfile === false) {
|
||||||
|
$this->application_deployment_queue->addLogEntry('Skipping Dockerfile ARG injection (disabled in settings).', hidden: true);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->execute_remote_command([
|
||||||
|
executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"),
|
||||||
|
'hidden' => true,
|
||||||
|
'save' => 'dockerfile',
|
||||||
|
'ignore_errors' => true,
|
||||||
|
]);
|
||||||
|
$dockerfile = collect(str($this->saved_outputs->get('dockerfile'))->trim()->explode("\n"));
|
||||||
|
|
||||||
|
// Find all FROM instruction positions
|
||||||
|
$fromLines = $this->findFromInstructionLines($dockerfile);
|
||||||
|
|
||||||
|
// If no FROM instructions found, skip ARG insertion
|
||||||
|
if (empty($fromLines)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all ARG statements to insert
|
||||||
|
$argsToInsert = collect();
|
||||||
|
|
||||||
|
if ($this->pull_request_id === 0) {
|
||||||
|
// Only add environment variables that are available during build
|
||||||
|
$envs = $this->application->environment_variables()
|
||||||
|
->where('key', 'not like', 'NIXPACKS_%')
|
||||||
|
->where('is_buildtime', true)
|
||||||
|
->get();
|
||||||
|
foreach ($envs as $env) {
|
||||||
|
if (data_get($env, 'is_multiline') === true) {
|
||||||
|
$argsToInsert->push("ARG {$env->key}");
|
||||||
|
} else {
|
||||||
|
$argsToInsert->push("ARG {$env->key}={$env->real_value}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Add Coolify variables as ARGs
|
||||||
|
if ($this->coolify_variables) {
|
||||||
|
$coolify_vars = collect(explode(' ', trim($this->coolify_variables)))
|
||||||
|
->filter()
|
||||||
|
->map(function ($var) {
|
||||||
|
return "ARG {$var}";
|
||||||
|
});
|
||||||
|
$argsToInsert = $argsToInsert->merge($coolify_vars);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
$this->execute_remote_command([
|
// Only add preview environment variables that are available during build
|
||||||
|
$envs = $this->application->environment_variables_preview()
|
||||||
|
->where('key', 'not like', 'NIXPACKS_%')
|
||||||
|
->where('is_buildtime', true)
|
||||||
|
->get();
|
||||||
|
foreach ($envs as $env) {
|
||||||
|
if (data_get($env, 'is_multiline') === true) {
|
||||||
|
$argsToInsert->push("ARG {$env->key}");
|
||||||
|
} else {
|
||||||
|
$argsToInsert->push("ARG {$env->key}={$env->real_value}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Add Coolify variables as ARGs
|
||||||
|
if ($this->coolify_variables) {
|
||||||
|
$coolify_vars = collect(explode(' ', trim($this->coolify_variables)))
|
||||||
|
->filter()
|
||||||
|
->map(function ($var) {
|
||||||
|
return "ARG {$var}";
|
||||||
|
});
|
||||||
|
$argsToInsert = $argsToInsert->merge($coolify_vars);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Development logging to show what ARGs are being injected
|
||||||
|
if (isDev()) {
|
||||||
|
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
|
||||||
|
$this->application_deployment_queue->addLogEntry('[DEBUG] Dockerfile ARG Injection');
|
||||||
|
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
|
||||||
|
$this->application_deployment_queue->addLogEntry('[DEBUG] ARGs to inject: '.$argsToInsert->count());
|
||||||
|
foreach ($argsToInsert as $arg) {
|
||||||
|
// Only show ARG key, not the value (for security)
|
||||||
|
$argKey = str($arg)->after('ARG ')->before('=')->toString();
|
||||||
|
$this->application_deployment_queue->addLogEntry("[DEBUG] - {$argKey}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert ARGs after each FROM instruction (in reverse order to maintain correct line numbers)
|
||||||
|
if ($argsToInsert->isNotEmpty()) {
|
||||||
|
foreach (array_reverse($fromLines) as $fromLineIndex) {
|
||||||
|
// Insert all ARGs after this FROM instruction
|
||||||
|
foreach ($argsToInsert->reverse() as $arg) {
|
||||||
|
$dockerfile->splice($fromLineIndex + 1, 0, [$arg]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$envs_mapped = $envs->mapWithKeys(function ($env) {
|
||||||
|
return [$env->key => $env->real_value];
|
||||||
|
});
|
||||||
|
$secrets_hash = $this->generate_secrets_hash($envs_mapped);
|
||||||
|
$argsToInsert->push("ARG COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$dockerfile_base64 = base64_encode($dockerfile->implode("\n"));
|
||||||
|
$this->application_deployment_queue->addLogEntry('Final Dockerfile:', type: 'info', hidden: true);
|
||||||
|
$this->execute_remote_command(
|
||||||
|
[
|
||||||
|
executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"),
|
||||||
|
'hidden' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"),
|
executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"),
|
||||||
'hidden' => true,
|
'hidden' => true,
|
||||||
'save' => 'dockerfile',
|
|
||||||
'ignore_errors' => true,
|
'ignore_errors' => true,
|
||||||
]);
|
]);
|
||||||
$dockerfile = collect(str($this->saved_outputs->get('dockerfile'))->trim()->explode("\n"));
|
|
||||||
|
|
||||||
// Find all FROM instruction positions
|
|
||||||
$fromLines = $this->findFromInstructionLines($dockerfile);
|
|
||||||
|
|
||||||
// If no FROM instructions found, skip ARG insertion
|
|
||||||
if (empty($fromLines)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect all ARG statements to insert
|
|
||||||
$argsToInsert = collect();
|
|
||||||
|
|
||||||
if ($this->pull_request_id === 0) {
|
|
||||||
// Only add environment variables that are available during build
|
|
||||||
$envs = $this->application->environment_variables()
|
|
||||||
->where('key', 'not like', 'NIXPACKS_%')
|
|
||||||
->where('is_buildtime', true)
|
|
||||||
->get();
|
|
||||||
foreach ($envs as $env) {
|
|
||||||
if (data_get($env, 'is_multiline') === true) {
|
|
||||||
$argsToInsert->push("ARG {$env->key}");
|
|
||||||
} else {
|
|
||||||
$argsToInsert->push("ARG {$env->key}={$env->real_value}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Add Coolify variables as ARGs
|
|
||||||
if ($this->coolify_variables) {
|
|
||||||
$coolify_vars = collect(explode(' ', trim($this->coolify_variables)))
|
|
||||||
->filter()
|
|
||||||
->map(function ($var) {
|
|
||||||
return "ARG {$var}";
|
|
||||||
});
|
|
||||||
$argsToInsert = $argsToInsert->merge($coolify_vars);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Only add preview environment variables that are available during build
|
|
||||||
$envs = $this->application->environment_variables_preview()
|
|
||||||
->where('key', 'not like', 'NIXPACKS_%')
|
|
||||||
->where('is_buildtime', true)
|
|
||||||
->get();
|
|
||||||
foreach ($envs as $env) {
|
|
||||||
if (data_get($env, 'is_multiline') === true) {
|
|
||||||
$argsToInsert->push("ARG {$env->key}");
|
|
||||||
} else {
|
|
||||||
$argsToInsert->push("ARG {$env->key}={$env->real_value}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Add Coolify variables as ARGs
|
|
||||||
if ($this->coolify_variables) {
|
|
||||||
$coolify_vars = collect(explode(' ', trim($this->coolify_variables)))
|
|
||||||
->filter()
|
|
||||||
->map(function ($var) {
|
|
||||||
return "ARG {$var}";
|
|
||||||
});
|
|
||||||
$argsToInsert = $argsToInsert->merge($coolify_vars);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert ARGs after each FROM instruction (in reverse order to maintain correct line numbers)
|
|
||||||
if ($argsToInsert->isNotEmpty()) {
|
|
||||||
foreach (array_reverse($fromLines) as $fromLineIndex) {
|
|
||||||
// Insert all ARGs after this FROM instruction
|
|
||||||
foreach ($argsToInsert->reverse() as $arg) {
|
|
||||||
$dockerfile->splice($fromLineIndex + 1, 0, [$arg]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$envs_mapped = $envs->mapWithKeys(function ($env) {
|
|
||||||
return [$env->key => $env->real_value];
|
|
||||||
});
|
|
||||||
$secrets_hash = $this->generate_secrets_hash($envs_mapped);
|
|
||||||
$argsToInsert->push("ARG COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}");
|
|
||||||
}
|
|
||||||
|
|
||||||
$dockerfile_base64 = base64_encode($dockerfile->implode("\n"));
|
|
||||||
$this->application_deployment_queue->addLogEntry('Final Dockerfile:', type: 'info', hidden: true);
|
|
||||||
$this->execute_remote_command(
|
|
||||||
[
|
|
||||||
executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"),
|
|
||||||
'hidden' => true,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"),
|
|
||||||
'hidden' => true,
|
|
||||||
'ignore_errors' => true,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function modify_dockerfile_for_secrets($dockerfile_path)
|
private function modify_dockerfile_for_secrets($dockerfile_path)
|
||||||
@@ -3503,6 +3644,13 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip ARG injection if disabled by user - preserves Docker build cache
|
||||||
|
if ($this->application->settings->inject_build_args_to_dockerfile === false) {
|
||||||
|
$this->application_deployment_queue->addLogEntry('Skipping Docker Compose Dockerfile ARG injection (disabled in settings).', hidden: true);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Generate env variables if not already done
|
// Generate env variables if not already done
|
||||||
// This populates $this->env_args with both user-defined and COOLIFY_* variables
|
// This populates $this->env_args with both user-defined and COOLIFY_* variables
|
||||||
if (! $this->env_args || $this->env_args->isEmpty()) {
|
if (! $this->env_args || $this->env_args->isEmpty()) {
|
||||||
@@ -3593,6 +3741,18 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Development logging to show what ARGs are being injected for Docker Compose
|
||||||
|
if (isDev()) {
|
||||||
|
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
|
||||||
|
$this->application_deployment_queue->addLogEntry("[DEBUG] Docker Compose ARG Injection - Service: {$serviceName}");
|
||||||
|
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
|
||||||
|
$this->application_deployment_queue->addLogEntry('[DEBUG] ARGs to inject: '.$argsToAdd->count());
|
||||||
|
foreach ($argsToAdd as $arg) {
|
||||||
|
$argKey = str($arg)->after('ARG ')->toString();
|
||||||
|
$this->application_deployment_queue->addLogEntry("[DEBUG] - {$argKey}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$totalAdded = 0;
|
$totalAdded = 0;
|
||||||
$offset = 0;
|
$offset = 0;
|
||||||
|
|
||||||
@@ -3797,13 +3957,17 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if deployment is in a terminal state (FAILED or CANCELLED).
|
* Check if deployment is in a terminal state (FINISHED, FAILED or CANCELLED).
|
||||||
* Terminal states cannot be changed.
|
* Terminal states cannot be changed.
|
||||||
*/
|
*/
|
||||||
private function isInTerminalState(): bool
|
private function isInTerminalState(): bool
|
||||||
{
|
{
|
||||||
$this->application_deployment_queue->refresh();
|
$this->application_deployment_queue->refresh();
|
||||||
|
|
||||||
|
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::FINISHED->value) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::FAILED->value) {
|
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::FAILED->value) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -3843,6 +4007,15 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
|
|||||||
*/
|
*/
|
||||||
private function handleSuccessfulDeployment(): void
|
private function handleSuccessfulDeployment(): void
|
||||||
{
|
{
|
||||||
|
// Reset restart count after successful deployment
|
||||||
|
// This is done here (not in Livewire) to avoid race conditions
|
||||||
|
// with GetContainersStatus reading old container restart counts
|
||||||
|
$this->application->update([
|
||||||
|
'restart_count' => 0,
|
||||||
|
'last_restart_at' => null,
|
||||||
|
'last_restart_type' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
event(new ApplicationConfigurationChanged($this->application->team()->id));
|
event(new ApplicationConfigurationChanged($this->application->team()->id));
|
||||||
|
|
||||||
if (! $this->only_this_server) {
|
if (! $this->only_this_server) {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use Illuminate\Queue\InteractsWithQueue;
|
|||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use Illuminate\Support\Facades\File;
|
use Illuminate\Support\Facades\File;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class CheckForUpdatesJob implements ShouldBeEncrypted, ShouldQueue
|
class CheckForUpdatesJob implements ShouldBeEncrypted, ShouldQueue
|
||||||
{
|
{
|
||||||
@@ -22,20 +23,60 @@ class CheckForUpdatesJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$settings = instanceSettings();
|
$settings = instanceSettings();
|
||||||
$response = Http::retry(3, 1000)->get('https://cdn.coollabs.io/coolify/versions.json');
|
$response = Http::retry(3, 1000)->get(config('constants.coolify.versions_url'));
|
||||||
if ($response->successful()) {
|
if ($response->successful()) {
|
||||||
$versions = $response->json();
|
$versions = $response->json();
|
||||||
|
|
||||||
$latest_version = data_get($versions, 'coolify.v4.version');
|
$latest_version = data_get($versions, 'coolify.v4.version');
|
||||||
$current_version = config('constants.coolify.version');
|
$current_version = config('constants.coolify.version');
|
||||||
|
|
||||||
|
// Read existing cached version
|
||||||
|
$existingVersions = null;
|
||||||
|
$existingCoolifyVersion = null;
|
||||||
|
if (File::exists(base_path('versions.json'))) {
|
||||||
|
$existingVersions = json_decode(File::get(base_path('versions.json')), true);
|
||||||
|
$existingCoolifyVersion = data_get($existingVersions, 'coolify.v4.version');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine the BEST version to use (CDN, cache, or current)
|
||||||
|
$bestVersion = $latest_version;
|
||||||
|
|
||||||
|
// Check if cache has newer version than CDN
|
||||||
|
if ($existingCoolifyVersion && version_compare($existingCoolifyVersion, $bestVersion, '>')) {
|
||||||
|
Log::warning('CDN served older Coolify version than cache', [
|
||||||
|
'cdn_version' => $latest_version,
|
||||||
|
'cached_version' => $existingCoolifyVersion,
|
||||||
|
'current_version' => $current_version,
|
||||||
|
]);
|
||||||
|
$bestVersion = $existingCoolifyVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL: Never allow bestVersion to be older than currently running version
|
||||||
|
if (version_compare($bestVersion, $current_version, '<')) {
|
||||||
|
Log::warning('Version downgrade prevented in CheckForUpdatesJob', [
|
||||||
|
'cdn_version' => $latest_version,
|
||||||
|
'cached_version' => $existingCoolifyVersion,
|
||||||
|
'current_version' => $current_version,
|
||||||
|
'attempted_best' => $bestVersion,
|
||||||
|
'using' => $current_version,
|
||||||
|
]);
|
||||||
|
$bestVersion = $current_version;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use data_set() for safe mutation (fixes #3)
|
||||||
|
data_set($versions, 'coolify.v4.version', $bestVersion);
|
||||||
|
$latest_version = $bestVersion;
|
||||||
|
|
||||||
|
// ALWAYS write versions.json (for Sentinel, Helper, Traefik updates)
|
||||||
|
File::put(base_path('versions.json'), json_encode($versions, JSON_PRETTY_PRINT));
|
||||||
|
|
||||||
|
// Invalidate cache to ensure fresh data is loaded
|
||||||
|
invalidate_versions_cache();
|
||||||
|
|
||||||
|
// Only mark new version available if Coolify version actually increased
|
||||||
if (version_compare($latest_version, $current_version, '>')) {
|
if (version_compare($latest_version, $current_version, '>')) {
|
||||||
// New version available
|
// New version available
|
||||||
$settings->update(['new_version_available' => true]);
|
$settings->update(['new_version_available' => true]);
|
||||||
File::put(base_path('versions.json'), json_encode($versions, JSON_PRETTY_PRINT));
|
|
||||||
|
|
||||||
// Invalidate cache to ensure fresh data is loaded
|
|
||||||
invalidate_versions_cache();
|
|
||||||
} else {
|
} else {
|
||||||
$settings->update(['new_version_available' => false]);
|
$settings->update(['new_version_available' => false]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class CheckHelperImageJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
public function handle(): void
|
public function handle(): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$response = Http::retry(3, 1000)->get('https://cdn.coollabs.io/coolify/versions.json');
|
$response = Http::retry(3, 1000)->get(config('constants.coolify.versions_url'));
|
||||||
if ($response->successful()) {
|
if ($response->successful()) {
|
||||||
$versions = $response->json();
|
$versions = $response->json();
|
||||||
$settings = instanceSettings();
|
$settings = instanceSettings();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Events\ProxyStatusChangedUI;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use App\Notifications\Server\TraefikVersionOutdated;
|
use App\Notifications\Server\TraefikVersionOutdated;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
@@ -38,6 +39,8 @@ class CheckTraefikVersionForServerJob implements ShouldQueue
|
|||||||
$this->server->update(['detected_traefik_version' => $currentVersion]);
|
$this->server->update(['detected_traefik_version' => $currentVersion]);
|
||||||
|
|
||||||
if (! $currentVersion) {
|
if (! $currentVersion) {
|
||||||
|
ProxyStatusChangedUI::dispatch($this->server->team_id);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,16 +51,22 @@ class CheckTraefikVersionForServerJob implements ShouldQueue
|
|||||||
|
|
||||||
// Handle empty/null response from SSH command
|
// Handle empty/null response from SSH command
|
||||||
if (empty(trim($imageTag))) {
|
if (empty(trim($imageTag))) {
|
||||||
|
ProxyStatusChangedUI::dispatch($this->server->team_id);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (str_contains(strtolower(trim($imageTag)), ':latest')) {
|
if (str_contains(strtolower(trim($imageTag)), ':latest')) {
|
||||||
|
ProxyStatusChangedUI::dispatch($this->server->team_id);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse current version to extract major.minor.patch
|
// Parse current version to extract major.minor.patch
|
||||||
$current = ltrim($currentVersion, 'v');
|
$current = ltrim($currentVersion, 'v');
|
||||||
if (! preg_match('/^(\d+\.\d+)\.(\d+)$/', $current, $matches)) {
|
if (! preg_match('/^(\d+\.\d+)\.(\d+)$/', $current, $matches)) {
|
||||||
|
ProxyStatusChangedUI::dispatch($this->server->team_id);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +86,8 @@ class CheckTraefikVersionForServerJob implements ShouldQueue
|
|||||||
$this->server->update(['traefik_outdated_info' => null]);
|
$this->server->update(['traefik_outdated_info' => null]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ProxyStatusChangedUI::dispatch($this->server->team_id);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +107,9 @@ class CheckTraefikVersionForServerJob implements ShouldQueue
|
|||||||
// Fully up to date
|
// Fully up to date
|
||||||
$this->server->update(['traefik_outdated_info' => null]);
|
$this->server->update(['traefik_outdated_info' => null]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dispatch UI update event so warning state refreshes in real-time
|
||||||
|
ProxyStatusChangedUI::dispatch($this->server->team_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
214
app/Jobs/CleanupOrphanedPreviewContainersJob.php
Normal file
214
app/Jobs/CleanupOrphanedPreviewContainersJob.php
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\ApplicationPreview;
|
||||||
|
use App\Models\Server;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scheduled job to clean up orphaned PR preview containers.
|
||||||
|
*
|
||||||
|
* This job acts as a safety net for containers that weren't properly cleaned up
|
||||||
|
* when a PR was closed (e.g., due to webhook failures, race conditions, etc.).
|
||||||
|
*
|
||||||
|
* It scans all functional servers for containers with the `coolify.pullRequestId` label
|
||||||
|
* and removes any where the corresponding ApplicationPreview record no longer exists.
|
||||||
|
*/
|
||||||
|
class CleanupOrphanedPreviewContainersJob implements ShouldBeEncrypted, ShouldBeUnique, ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public $timeout = 600; // 10 minutes max
|
||||||
|
|
||||||
|
public function __construct() {}
|
||||||
|
|
||||||
|
public function middleware(): array
|
||||||
|
{
|
||||||
|
return [(new WithoutOverlapping('cleanup-orphaned-preview-containers'))->expireAfter(600)->dontRelease()];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$servers = $this->getServersToCheck();
|
||||||
|
|
||||||
|
foreach ($servers as $server) {
|
||||||
|
$this->cleanupOrphanedContainersOnServer($server);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('CleanupOrphanedPreviewContainersJob failed: '.$e->getMessage());
|
||||||
|
send_internal_notification('CleanupOrphanedPreviewContainersJob failed with error: '.$e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all functional servers to check for orphaned containers.
|
||||||
|
*/
|
||||||
|
private function getServersToCheck(): \Illuminate\Support\Collection
|
||||||
|
{
|
||||||
|
$query = Server::whereRelation('settings', 'is_usable', true)
|
||||||
|
->whereRelation('settings', 'is_reachable', true)
|
||||||
|
->where('ip', '!=', '1.2.3.4');
|
||||||
|
|
||||||
|
if (isCloud()) {
|
||||||
|
$query = $query->whereRelation('team.subscription', 'stripe_invoice_paid', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->get()->filter(fn ($server) => $server->isFunctional());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find and clean up orphaned PR containers on a specific server.
|
||||||
|
*/
|
||||||
|
private function cleanupOrphanedContainersOnServer(Server $server): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$prContainers = $this->getPRContainersOnServer($server);
|
||||||
|
|
||||||
|
if ($prContainers->isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$orphanedCount = 0;
|
||||||
|
foreach ($prContainers as $container) {
|
||||||
|
if ($this->isOrphanedContainer($container)) {
|
||||||
|
$this->removeContainer($container, $server);
|
||||||
|
$orphanedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($orphanedCount > 0) {
|
||||||
|
Log::info("CleanupOrphanedPreviewContainersJob - Removed {$orphanedCount} orphaned PR containers", [
|
||||||
|
'server' => $server->name,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning("CleanupOrphanedPreviewContainersJob - Error on server {$server->name}: {$e->getMessage()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all PR containers on a server (containers with pullRequestId > 0).
|
||||||
|
*/
|
||||||
|
private function getPRContainersOnServer(Server $server): \Illuminate\Support\Collection
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$output = instant_remote_process([
|
||||||
|
"docker ps -a --filter 'label=coolify.pullRequestId' --format '{{json .}}'",
|
||||||
|
], $server, false);
|
||||||
|
|
||||||
|
if (empty($output)) {
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return format_docker_command_output_to_json($output)
|
||||||
|
->filter(function ($container) {
|
||||||
|
// Only include PR containers (pullRequestId > 0)
|
||||||
|
$prId = $this->extractPullRequestId($container);
|
||||||
|
|
||||||
|
return $prId !== null && $prId > 0;
|
||||||
|
});
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::debug("Failed to get PR containers on server {$server->name}: {$e->getMessage()}");
|
||||||
|
|
||||||
|
return collect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract pull request ID from container labels.
|
||||||
|
*/
|
||||||
|
private function extractPullRequestId($container): ?int
|
||||||
|
{
|
||||||
|
$labels = data_get($container, 'Labels', '');
|
||||||
|
if (preg_match('/coolify\.pullRequestId=(\d+)/', $labels, $matches)) {
|
||||||
|
return (int) $matches[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract application ID from container labels.
|
||||||
|
*/
|
||||||
|
private function extractApplicationId($container): ?int
|
||||||
|
{
|
||||||
|
$labels = data_get($container, 'Labels', '');
|
||||||
|
if (preg_match('/coolify\.applicationId=(\d+)/', $labels, $matches)) {
|
||||||
|
return (int) $matches[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a container is orphaned (no corresponding ApplicationPreview record).
|
||||||
|
*/
|
||||||
|
private function isOrphanedContainer($container): bool
|
||||||
|
{
|
||||||
|
$applicationId = $this->extractApplicationId($container);
|
||||||
|
$pullRequestId = $this->extractPullRequestId($container);
|
||||||
|
|
||||||
|
if ($applicationId === null || $pullRequestId === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if ApplicationPreview record exists (including soft-deleted)
|
||||||
|
$previewExists = ApplicationPreview::withTrashed()
|
||||||
|
->where('application_id', $applicationId)
|
||||||
|
->where('pull_request_id', $pullRequestId)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
// If preview exists (even soft-deleted), container should be handled by DeleteResourceJob
|
||||||
|
// If preview doesn't exist at all, it's truly orphaned
|
||||||
|
return ! $previewExists;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove an orphaned container from the server.
|
||||||
|
*/
|
||||||
|
private function removeContainer($container, Server $server): void
|
||||||
|
{
|
||||||
|
$containerName = data_get($container, 'Names');
|
||||||
|
|
||||||
|
if (empty($containerName)) {
|
||||||
|
Log::warning('CleanupOrphanedPreviewContainersJob - Cannot remove container: missing container name', [
|
||||||
|
'container_data' => $container,
|
||||||
|
'server' => $server->name,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$applicationId = $this->extractApplicationId($container);
|
||||||
|
$pullRequestId = $this->extractPullRequestId($container);
|
||||||
|
|
||||||
|
Log::info('CleanupOrphanedPreviewContainersJob - Removing orphaned container', [
|
||||||
|
'container' => $containerName,
|
||||||
|
'application_id' => $applicationId,
|
||||||
|
'pull_request_id' => $pullRequestId,
|
||||||
|
'server' => $server->name,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$escapedContainerName = escapeshellarg($containerName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
instant_remote_process(
|
||||||
|
["docker rm -f {$escapedContainerName}"],
|
||||||
|
$server,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::warning("Failed to remove orphaned container {$containerName}: {$e->getMessage()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -90,5 +90,22 @@ class CoolifyTask implements ShouldBeEncrypted, ShouldQueue
|
|||||||
'failed_at' => now()->toIso8601String(),
|
'failed_at' => now()->toIso8601String(),
|
||||||
]);
|
]);
|
||||||
$this->activity->save();
|
$this->activity->save();
|
||||||
|
|
||||||
|
// Dispatch cleanup event on failure (same as on success)
|
||||||
|
if ($this->call_event_on_finish) {
|
||||||
|
try {
|
||||||
|
$eventClass = "App\\Events\\$this->call_event_on_finish";
|
||||||
|
if (! is_null($this->call_event_data)) {
|
||||||
|
event(new $eventClass($this->call_event_data));
|
||||||
|
} else {
|
||||||
|
event(new $eventClass($this->activity->causer_id));
|
||||||
|
}
|
||||||
|
Log::info('Cleanup event dispatched after job failure', [
|
||||||
|
'event' => $this->call_event_on_finish,
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('Error dispatching cleanup event on failure: '.$e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
$this->container_name = "{$this->database->name}-$serviceUuid";
|
$this->container_name = "{$this->database->name}-$serviceUuid";
|
||||||
$this->directory_name = $serviceName.'-'.$this->container_name;
|
$this->directory_name = $serviceName.'-'.$this->container_name;
|
||||||
$commands[] = "docker exec $this->container_name env | grep POSTGRES_";
|
$commands[] = "docker exec $this->container_name env | grep POSTGRES_";
|
||||||
$envs = instant_remote_process($commands, $this->server);
|
$envs = instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true);
|
||||||
$envs = str($envs)->explode("\n");
|
$envs = str($envs)->explode("\n");
|
||||||
|
|
||||||
$user = $envs->filter(function ($env) {
|
$user = $envs->filter(function ($env) {
|
||||||
@@ -152,7 +152,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
$this->container_name = "{$this->database->name}-$serviceUuid";
|
$this->container_name = "{$this->database->name}-$serviceUuid";
|
||||||
$this->directory_name = $serviceName.'-'.$this->container_name;
|
$this->directory_name = $serviceName.'-'.$this->container_name;
|
||||||
$commands[] = "docker exec $this->container_name env | grep MYSQL_";
|
$commands[] = "docker exec $this->container_name env | grep MYSQL_";
|
||||||
$envs = instant_remote_process($commands, $this->server);
|
$envs = instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true);
|
||||||
$envs = str($envs)->explode("\n");
|
$envs = str($envs)->explode("\n");
|
||||||
|
|
||||||
$rootPassword = $envs->filter(function ($env) {
|
$rootPassword = $envs->filter(function ($env) {
|
||||||
@@ -175,7 +175,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
$this->container_name = "{$this->database->name}-$serviceUuid";
|
$this->container_name = "{$this->database->name}-$serviceUuid";
|
||||||
$this->directory_name = $serviceName.'-'.$this->container_name;
|
$this->directory_name = $serviceName.'-'.$this->container_name;
|
||||||
$commands[] = "docker exec $this->container_name env";
|
$commands[] = "docker exec $this->container_name env";
|
||||||
$envs = instant_remote_process($commands, $this->server);
|
$envs = instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true);
|
||||||
$envs = str($envs)->explode("\n");
|
$envs = str($envs)->explode("\n");
|
||||||
$rootPassword = $envs->filter(function ($env) {
|
$rootPassword = $envs->filter(function ($env) {
|
||||||
return str($env)->startsWith('MARIADB_ROOT_PASSWORD=');
|
return str($env)->startsWith('MARIADB_ROOT_PASSWORD=');
|
||||||
@@ -217,7 +217,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
try {
|
try {
|
||||||
$commands = [];
|
$commands = [];
|
||||||
$commands[] = "docker exec $this->container_name env | grep MONGO_INITDB_";
|
$commands[] = "docker exec $this->container_name env | grep MONGO_INITDB_";
|
||||||
$envs = instant_remote_process($commands, $this->server);
|
$envs = instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true);
|
||||||
|
|
||||||
if (filled($envs)) {
|
if (filled($envs)) {
|
||||||
$envs = str($envs)->explode("\n");
|
$envs = str($envs)->explode("\n");
|
||||||
@@ -489,21 +489,26 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
$collectionsToExclude = collect();
|
$collectionsToExclude = collect();
|
||||||
}
|
}
|
||||||
$commands[] = 'mkdir -p '.$this->backup_dir;
|
$commands[] = 'mkdir -p '.$this->backup_dir;
|
||||||
|
|
||||||
|
// Validate and escape database name to prevent command injection
|
||||||
|
validateShellSafePath($databaseName, 'database name');
|
||||||
|
$escapedDatabaseName = escapeshellarg($databaseName);
|
||||||
|
|
||||||
if ($collectionsToExclude->count() === 0) {
|
if ($collectionsToExclude->count() === 0) {
|
||||||
if (str($this->database->image)->startsWith('mongo:4')) {
|
if (str($this->database->image)->startsWith('mongo:4')) {
|
||||||
$commands[] = "docker exec $this->container_name mongodump --uri=\"$url\" --gzip --archive > $this->backup_location";
|
$commands[] = "docker exec $this->container_name mongodump --uri=\"$url\" --gzip --archive > $this->backup_location";
|
||||||
} else {
|
} else {
|
||||||
$commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $databaseName --gzip --archive > $this->backup_location";
|
$commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $escapedDatabaseName --gzip --archive > $this->backup_location";
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (str($this->database->image)->startsWith('mongo:4')) {
|
if (str($this->database->image)->startsWith('mongo:4')) {
|
||||||
$commands[] = "docker exec $this->container_name mongodump --uri=$url --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location";
|
$commands[] = "docker exec $this->container_name mongodump --uri=$url --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location";
|
||||||
} else {
|
} else {
|
||||||
$commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $databaseName --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location";
|
$commands[] = "docker exec $this->container_name mongodump --authenticationDatabase=admin --uri=\"$url\" --db $escapedDatabaseName --gzip --excludeCollection ".$collectionsToExclude->implode(' --excludeCollection ')." --archive > $this->backup_location";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$this->backup_output = instant_remote_process($commands, $this->server);
|
$this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true);
|
||||||
$this->backup_output = trim($this->backup_output);
|
$this->backup_output = trim($this->backup_output);
|
||||||
if ($this->backup_output === '') {
|
if ($this->backup_output === '') {
|
||||||
$this->backup_output = null;
|
$this->backup_output = null;
|
||||||
@@ -525,11 +530,14 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
if ($this->backup->dump_all) {
|
if ($this->backup->dump_all) {
|
||||||
$backupCommand .= " $this->container_name pg_dumpall --username {$this->database->postgres_user} | gzip > $this->backup_location";
|
$backupCommand .= " $this->container_name pg_dumpall --username {$this->database->postgres_user} | gzip > $this->backup_location";
|
||||||
} else {
|
} else {
|
||||||
$backupCommand .= " $this->container_name pg_dump --format=custom --no-acl --no-owner --username {$this->database->postgres_user} $database > $this->backup_location";
|
// Validate and escape database name to prevent command injection
|
||||||
|
validateShellSafePath($database, 'database name');
|
||||||
|
$escapedDatabase = escapeshellarg($database);
|
||||||
|
$backupCommand .= " $this->container_name pg_dump --format=custom --no-acl --no-owner --username {$this->database->postgres_user} $escapedDatabase > $this->backup_location";
|
||||||
}
|
}
|
||||||
|
|
||||||
$commands[] = $backupCommand;
|
$commands[] = $backupCommand;
|
||||||
$this->backup_output = instant_remote_process($commands, $this->server);
|
$this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true);
|
||||||
$this->backup_output = trim($this->backup_output);
|
$this->backup_output = trim($this->backup_output);
|
||||||
if ($this->backup_output === '') {
|
if ($this->backup_output === '') {
|
||||||
$this->backup_output = null;
|
$this->backup_output = null;
|
||||||
@@ -547,9 +555,12 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
if ($this->backup->dump_all) {
|
if ($this->backup->dump_all) {
|
||||||
$commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" --all-databases --single-transaction --quick --lock-tables=false --compress | gzip > $this->backup_location";
|
$commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" --all-databases --single-transaction --quick --lock-tables=false --compress | gzip > $this->backup_location";
|
||||||
} else {
|
} else {
|
||||||
$commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" $database > $this->backup_location";
|
// Validate and escape database name to prevent command injection
|
||||||
|
validateShellSafePath($database, 'database name');
|
||||||
|
$escapedDatabase = escapeshellarg($database);
|
||||||
|
$commands[] = "docker exec $this->container_name mysqldump -u root -p\"{$this->database->mysql_root_password}\" $escapedDatabase > $this->backup_location";
|
||||||
}
|
}
|
||||||
$this->backup_output = instant_remote_process($commands, $this->server);
|
$this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true);
|
||||||
$this->backup_output = trim($this->backup_output);
|
$this->backup_output = trim($this->backup_output);
|
||||||
if ($this->backup_output === '') {
|
if ($this->backup_output === '') {
|
||||||
$this->backup_output = null;
|
$this->backup_output = null;
|
||||||
@@ -567,9 +578,12 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
if ($this->backup->dump_all) {
|
if ($this->backup->dump_all) {
|
||||||
$commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" --all-databases --single-transaction --quick --lock-tables=false --compress > $this->backup_location";
|
$commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" --all-databases --single-transaction --quick --lock-tables=false --compress > $this->backup_location";
|
||||||
} else {
|
} else {
|
||||||
$commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" $database > $this->backup_location";
|
// Validate and escape database name to prevent command injection
|
||||||
|
validateShellSafePath($database, 'database name');
|
||||||
|
$escapedDatabase = escapeshellarg($database);
|
||||||
|
$commands[] = "docker exec $this->container_name mariadb-dump -u root -p\"{$this->database->mariadb_root_password}\" $escapedDatabase > $this->backup_location";
|
||||||
}
|
}
|
||||||
$this->backup_output = instant_remote_process($commands, $this->server);
|
$this->backup_output = instant_remote_process($commands, $this->server, true, false, $this->timeout, disableMultiplexing: true);
|
||||||
$this->backup_output = trim($this->backup_output);
|
$this->backup_output = trim($this->backup_output);
|
||||||
if ($this->backup_output === '') {
|
if ($this->backup_output === '') {
|
||||||
$this->backup_output = null;
|
$this->backup_output = null;
|
||||||
@@ -600,7 +614,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
|
|
||||||
private function calculate_size()
|
private function calculate_size()
|
||||||
{
|
{
|
||||||
return instant_remote_process(["du -b $this->backup_location | cut -f1"], $this->server, false);
|
return instant_remote_process(["du -b $this->backup_location | cut -f1"], $this->server, false, false, null, disableMultiplexing: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function upload_to_s3(): void
|
private function upload_to_s3(): void
|
||||||
@@ -623,9 +637,9 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
|
|
||||||
$fullImageName = $this->getFullImageName();
|
$fullImageName = $this->getFullImageName();
|
||||||
|
|
||||||
$containerExists = instant_remote_process(["docker ps -a -q -f name=backup-of-{$this->backup_log_uuid}"], $this->server, false);
|
$containerExists = instant_remote_process(["docker ps -a -q -f name=backup-of-{$this->backup_log_uuid}"], $this->server, false, false, null, disableMultiplexing: true);
|
||||||
if (filled($containerExists)) {
|
if (filled($containerExists)) {
|
||||||
instant_remote_process(["docker rm -f backup-of-{$this->backup_log_uuid}"], $this->server, false);
|
instant_remote_process(["docker rm -f backup-of-{$this->backup_log_uuid}"], $this->server, false, false, null, disableMultiplexing: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDev()) {
|
if (isDev()) {
|
||||||
@@ -639,9 +653,15 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
} else {
|
} else {
|
||||||
$commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $this->backup_location:$this->backup_location:ro {$fullImageName}";
|
$commands[] = "docker run -d --network {$network} --name backup-of-{$this->backup_log_uuid} --rm -v $this->backup_location:$this->backup_location:ro {$fullImageName}";
|
||||||
}
|
}
|
||||||
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set temporary {$endpoint} {$key} \"{$secret}\"";
|
|
||||||
|
// Escape S3 credentials to prevent command injection
|
||||||
|
$escapedEndpoint = escapeshellarg($endpoint);
|
||||||
|
$escapedKey = escapeshellarg($key);
|
||||||
|
$escapedSecret = escapeshellarg($secret);
|
||||||
|
|
||||||
|
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set temporary {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
|
||||||
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/";
|
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/";
|
||||||
instant_remote_process($commands, $this->server);
|
instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true);
|
||||||
|
|
||||||
$this->s3_uploaded = true;
|
$this->s3_uploaded = true;
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
@@ -650,7 +670,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
throw $e;
|
throw $e;
|
||||||
} finally {
|
} finally {
|
||||||
$command = "docker rm -f backup-of-{$this->backup_log_uuid}";
|
$command = "docker rm -f backup-of-{$this->backup_log_uuid}";
|
||||||
instant_remote_process([$command], $this->server);
|
instant_remote_process([$command], $this->server, true, false, null, disableMultiplexing: true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
|
|
||||||
$containerList = implode(' ', array_map('escapeshellarg', $containerNames));
|
$containerList = implode(' ', array_map('escapeshellarg', $containerNames));
|
||||||
$commands = [
|
$commands = [
|
||||||
"docker stop --time=$timeout $containerList",
|
"docker stop -t $timeout $containerList",
|
||||||
"docker rm -f $containerList",
|
"docker rm -f $containerList",
|
||||||
];
|
];
|
||||||
instant_remote_process(
|
instant_remote_process(
|
||||||
|
|||||||
@@ -300,8 +300,9 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Use ContainerStatusAggregator service for state machine logic
|
// Use ContainerStatusAggregator service for state machine logic
|
||||||
|
// Use preserveRestarting: true so applications show "Restarting" instead of "Degraded"
|
||||||
$aggregator = new ContainerStatusAggregator;
|
$aggregator = new ContainerStatusAggregator;
|
||||||
$aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0);
|
$aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0, preserveRestarting: true);
|
||||||
|
|
||||||
// Update application status with aggregated result
|
// Update application status with aggregated result
|
||||||
if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
|
if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
|
||||||
@@ -360,8 +361,9 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
|
|||||||
|
|
||||||
// Use ContainerStatusAggregator service for state machine logic
|
// Use ContainerStatusAggregator service for state machine logic
|
||||||
// NOTE: Sentinel does NOT provide restart count data, so maxRestartCount is always 0
|
// NOTE: Sentinel does NOT provide restart count data, so maxRestartCount is always 0
|
||||||
|
// Use preserveRestarting: true so individual sub-resources show "Restarting" instead of "Degraded"
|
||||||
$aggregator = new ContainerStatusAggregator;
|
$aggregator = new ContainerStatusAggregator;
|
||||||
$aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0);
|
$aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0, preserveRestarting: true);
|
||||||
|
|
||||||
// Update service sub-resource status with aggregated result
|
// Update service sub-resource status with aggregated result
|
||||||
if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) {
|
if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) {
|
||||||
|
|||||||
@@ -2,9 +2,12 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Actions\Proxy\StartProxy;
|
use App\Actions\Proxy\GetProxyConfiguration;
|
||||||
use App\Actions\Proxy\StopProxy;
|
use App\Actions\Proxy\SaveProxyConfiguration;
|
||||||
|
use App\Enums\ProxyTypes;
|
||||||
|
use App\Events\ProxyStatusChangedUI;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
|
use App\Services\ProxyDashboardCacheService;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
@@ -19,11 +22,13 @@ class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
|
|
||||||
public $tries = 1;
|
public $tries = 1;
|
||||||
|
|
||||||
public $timeout = 60;
|
public $timeout = 120;
|
||||||
|
|
||||||
|
public ?int $activity_id = null;
|
||||||
|
|
||||||
public function middleware(): array
|
public function middleware(): array
|
||||||
{
|
{
|
||||||
return [(new WithoutOverlapping('restart-proxy-'.$this->server->uuid))->expireAfter(60)->dontRelease()];
|
return [(new WithoutOverlapping('restart-proxy-'.$this->server->uuid))->expireAfter(120)->dontRelease()];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function __construct(public Server $server) {}
|
public function __construct(public Server $server) {}
|
||||||
@@ -31,15 +36,125 @@ class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue
|
|||||||
public function handle()
|
public function handle()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
StopProxy::run($this->server, restarting: true);
|
// Set status to restarting
|
||||||
|
$this->server->proxy->status = 'restarting';
|
||||||
$this->server->proxy->force_stop = false;
|
$this->server->proxy->force_stop = false;
|
||||||
$this->server->save();
|
$this->server->save();
|
||||||
|
|
||||||
StartProxy::run($this->server, force: true, restarting: true);
|
// Build combined stop + start commands for a single activity
|
||||||
|
$commands = $this->buildRestartCommands();
|
||||||
|
|
||||||
|
// Create activity and dispatch immediately - returns Activity right away
|
||||||
|
// The remote_process runs asynchronously, so UI gets activity ID instantly
|
||||||
|
$activity = remote_process(
|
||||||
|
$commands,
|
||||||
|
$this->server,
|
||||||
|
callEventOnFinish: 'ProxyStatusChanged',
|
||||||
|
callEventData: $this->server->id
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store activity ID and notify UI immediately with it
|
||||||
|
$this->activity_id = $activity->id;
|
||||||
|
ProxyStatusChangedUI::dispatch($this->server->team_id, $this->activity_id);
|
||||||
|
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
// Set error status
|
||||||
|
$this->server->proxy->status = 'error';
|
||||||
|
$this->server->save();
|
||||||
|
|
||||||
|
// Notify UI of error
|
||||||
|
ProxyStatusChangedUI::dispatch($this->server->team_id);
|
||||||
|
|
||||||
|
// Clear dashboard cache on error
|
||||||
|
ProxyDashboardCacheService::clearCache($this->server);
|
||||||
|
|
||||||
return handleError($e);
|
return handleError($e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build combined stop + start commands for proxy restart.
|
||||||
|
* This creates a single command sequence that shows all logs in one activity.
|
||||||
|
*/
|
||||||
|
private function buildRestartCommands(): array
|
||||||
|
{
|
||||||
|
$proxyType = $this->server->proxyType();
|
||||||
|
$containerName = $this->server->isSwarm() ? 'coolify-proxy_traefik' : 'coolify-proxy';
|
||||||
|
$proxy_path = $this->server->proxyPath();
|
||||||
|
$stopTimeout = 30;
|
||||||
|
|
||||||
|
// Get proxy configuration
|
||||||
|
$configuration = GetProxyConfiguration::run($this->server);
|
||||||
|
if (! $configuration) {
|
||||||
|
throw new \Exception('Configuration is not synced');
|
||||||
|
}
|
||||||
|
SaveProxyConfiguration::run($this->server, $configuration);
|
||||||
|
$docker_compose_yml_base64 = base64_encode($configuration);
|
||||||
|
$this->server->proxy->last_applied_settings = str($docker_compose_yml_base64)->pipe('md5')->value();
|
||||||
|
$this->server->save();
|
||||||
|
|
||||||
|
$commands = collect([]);
|
||||||
|
|
||||||
|
// === STOP PHASE ===
|
||||||
|
$commands = $commands->merge([
|
||||||
|
"echo 'Stopping proxy...'",
|
||||||
|
"docker stop -t=$stopTimeout $containerName 2>/dev/null || true",
|
||||||
|
"docker rm -f $containerName 2>/dev/null || true",
|
||||||
|
'# Wait for container to be fully removed',
|
||||||
|
'for i in {1..15}; do',
|
||||||
|
" if ! docker ps -a --format \"{{.Names}}\" | grep -q \"^$containerName$\"; then",
|
||||||
|
" echo 'Container removed successfully.'",
|
||||||
|
' break',
|
||||||
|
' fi',
|
||||||
|
' echo "Waiting for container to be removed... ($i/15)"',
|
||||||
|
' sleep 1',
|
||||||
|
' # Force remove on each iteration in case it got stuck',
|
||||||
|
" docker rm -f $containerName 2>/dev/null || true",
|
||||||
|
'done',
|
||||||
|
'# Final verification and force cleanup',
|
||||||
|
"if docker ps -a --format \"{{.Names}}\" | grep -q \"^$containerName$\"; then",
|
||||||
|
" echo 'Container still exists after wait, forcing removal...'",
|
||||||
|
" docker rm -f $containerName 2>/dev/null || true",
|
||||||
|
' sleep 2',
|
||||||
|
'fi',
|
||||||
|
"echo 'Proxy stopped successfully.'",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// === START PHASE ===
|
||||||
|
if ($this->server->isSwarmManager()) {
|
||||||
|
$commands = $commands->merge([
|
||||||
|
"echo 'Starting proxy (Swarm mode)...'",
|
||||||
|
"mkdir -p $proxy_path/dynamic",
|
||||||
|
"cd $proxy_path",
|
||||||
|
"echo 'Creating required Docker Compose file.'",
|
||||||
|
"echo 'Starting coolify-proxy.'",
|
||||||
|
'docker stack deploy --detach=true -c docker-compose.yml coolify-proxy',
|
||||||
|
"echo 'Successfully started coolify-proxy.'",
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
if (isDev() && $proxyType === ProxyTypes::CADDY->value) {
|
||||||
|
$proxy_path = '/data/coolify/proxy/caddy';
|
||||||
|
}
|
||||||
|
$caddyfile = 'import /dynamic/*.caddy';
|
||||||
|
$commands = $commands->merge([
|
||||||
|
"echo 'Starting proxy...'",
|
||||||
|
"mkdir -p $proxy_path/dynamic",
|
||||||
|
"cd $proxy_path",
|
||||||
|
"echo '$caddyfile' > $proxy_path/dynamic/Caddyfile",
|
||||||
|
"echo 'Creating required Docker Compose file.'",
|
||||||
|
"echo 'Pulling docker image.'",
|
||||||
|
'docker compose pull',
|
||||||
|
]);
|
||||||
|
// Ensure required networks exist BEFORE docker compose up
|
||||||
|
$commands = $commands->merge(ensureProxyNetworksExist($this->server));
|
||||||
|
$commands = $commands->merge([
|
||||||
|
"echo 'Starting coolify-proxy.'",
|
||||||
|
'docker compose up -d --wait --remove-orphans',
|
||||||
|
"echo 'Successfully started coolify-proxy.'",
|
||||||
|
]);
|
||||||
|
$commands = $commands->merge(connectProxyToNetworks($this->server));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $commands->toArray();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,7 +139,9 @@ class ScheduledTaskJob implements ShouldQueue
|
|||||||
if (count($this->containers) == 1 || str_starts_with($containerName, $this->task->container.'-'.$this->resource->uuid)) {
|
if (count($this->containers) == 1 || str_starts_with($containerName, $this->task->container.'-'.$this->resource->uuid)) {
|
||||||
$cmd = "sh -c '".str_replace("'", "'\''", $this->task->command)."'";
|
$cmd = "sh -c '".str_replace("'", "'\''", $this->task->command)."'";
|
||||||
$exec = "docker exec {$containerName} {$cmd}";
|
$exec = "docker exec {$containerName} {$cmd}";
|
||||||
$this->task_output = instant_remote_process([$exec], $this->server, true);
|
// Disable SSH multiplexing to prevent race conditions when multiple tasks run concurrently
|
||||||
|
// See: https://github.com/coollabsio/coolify/issues/6736
|
||||||
|
$this->task_output = instant_remote_process([$exec], $this->server, true, false, $this->timeout, disableMultiplexing: true);
|
||||||
$this->task_log->update([
|
$this->task_log->update([
|
||||||
'status' => 'success',
|
'status' => 'success',
|
||||||
'message' => $this->task_output,
|
'message' => $this->task_output,
|
||||||
|
|||||||
@@ -111,34 +111,48 @@ class ServerManagerJob implements ShouldQueue
|
|||||||
|
|
||||||
private function processServerTasks(Server $server): void
|
private function processServerTasks(Server $server): void
|
||||||
{
|
{
|
||||||
|
// Get server timezone (used for all scheduled tasks)
|
||||||
|
$serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone);
|
||||||
|
if (validate_timezone($serverTimezone) === false) {
|
||||||
|
$serverTimezone = config('app.timezone');
|
||||||
|
}
|
||||||
|
|
||||||
// Check if we should run sentinel-based checks
|
// Check if we should run sentinel-based checks
|
||||||
$lastSentinelUpdate = $server->sentinel_updated_at;
|
$lastSentinelUpdate = $server->sentinel_updated_at;
|
||||||
$waitTime = $server->waitBeforeDoingSshCheck();
|
$waitTime = $server->waitBeforeDoingSshCheck();
|
||||||
$sentinelOutOfSync = Carbon::parse($lastSentinelUpdate)->isBefore($this->executionTime->subSeconds($waitTime));
|
$sentinelOutOfSync = Carbon::parse($lastSentinelUpdate)->isBefore($this->executionTime->copy()->subSeconds($waitTime));
|
||||||
|
|
||||||
if ($sentinelOutOfSync) {
|
if ($sentinelOutOfSync) {
|
||||||
// Dispatch jobs if Sentinel is out of sync
|
// Dispatch ServerCheckJob if Sentinel is out of sync
|
||||||
if ($this->shouldRunNow($this->checkFrequency)) {
|
if ($this->shouldRunNow($this->checkFrequency, $serverTimezone)) {
|
||||||
ServerCheckJob::dispatch($server);
|
ServerCheckJob::dispatch($server);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Dispatch ServerStorageCheckJob if due
|
$isSentinelEnabled = $server->isSentinelEnabled();
|
||||||
$serverDiskUsageCheckFrequency = data_get($server->settings, 'server_disk_usage_check_frequency', '0 * * * *');
|
$shouldRestartSentinel = $isSentinelEnabled && $this->shouldRunNow('0 0 * * *', $serverTimezone);
|
||||||
|
// Dispatch Sentinel restart if due (daily for Sentinel-enabled servers)
|
||||||
|
|
||||||
|
if ($shouldRestartSentinel) {
|
||||||
|
dispatch(function () use ($server) {
|
||||||
|
$server->restartContainer('coolify-sentinel');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch ServerStorageCheckJob if due (only when Sentinel is out of sync or disabled)
|
||||||
|
// When Sentinel is active, PushServerUpdateJob handles storage checks with real-time data
|
||||||
|
if ($sentinelOutOfSync) {
|
||||||
|
$serverDiskUsageCheckFrequency = data_get($server->settings, 'server_disk_usage_check_frequency', '0 23 * * *');
|
||||||
if (isset(VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency])) {
|
if (isset(VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency])) {
|
||||||
$serverDiskUsageCheckFrequency = VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency];
|
$serverDiskUsageCheckFrequency = VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency];
|
||||||
}
|
}
|
||||||
$shouldRunStorageCheck = $this->shouldRunNow($serverDiskUsageCheckFrequency);
|
$shouldRunStorageCheck = $this->shouldRunNow($serverDiskUsageCheckFrequency, $serverTimezone);
|
||||||
|
|
||||||
if ($shouldRunStorageCheck) {
|
if ($shouldRunStorageCheck) {
|
||||||
ServerStorageCheckJob::dispatch($server);
|
ServerStorageCheckJob::dispatch($server);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$serverTimezone = data_get($server->settings, 'server_timezone', $this->instanceTimezone);
|
|
||||||
if (validate_timezone($serverTimezone) === false) {
|
|
||||||
$serverTimezone = config('app.timezone');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dispatch ServerPatchCheckJob if due (weekly)
|
// Dispatch ServerPatchCheckJob if due (weekly)
|
||||||
$shouldRunPatchCheck = $this->shouldRunNow('0 0 * * 0', $serverTimezone);
|
$shouldRunPatchCheck = $this->shouldRunNow('0 0 * * 0', $serverTimezone);
|
||||||
|
|
||||||
@@ -146,14 +160,10 @@ class ServerManagerJob implements ShouldQueue
|
|||||||
ServerPatchCheckJob::dispatch($server);
|
ServerPatchCheckJob::dispatch($server);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dispatch Sentinel restart if due (daily for Sentinel-enabled servers)
|
// Sentinel update checks (hourly) - check for updates to Sentinel version
|
||||||
$isSentinelEnabled = $server->isSentinelEnabled();
|
// No timezone needed for hourly - runs at top of every hour
|
||||||
$shouldRestartSentinel = $isSentinelEnabled && $this->shouldRunNow('0 0 * * *', $serverTimezone);
|
if ($isSentinelEnabled && $this->shouldRunNow('0 * * * *')) {
|
||||||
|
CheckAndStartSentinelJob::dispatch($server);
|
||||||
if ($shouldRestartSentinel) {
|
|
||||||
dispatch(function () use ($server) {
|
|
||||||
$server->restartContainer('coolify-sentinel');
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -168,6 +168,9 @@ class ValidateAndInstallServerJob implements ShouldQueue
|
|||||||
if (! $this->server->isBuildServer()) {
|
if (! $this->server->isBuildServer()) {
|
||||||
$proxyShouldRun = CheckProxy::run($this->server, true);
|
$proxyShouldRun = CheckProxy::run($this->server, true);
|
||||||
if ($proxyShouldRun) {
|
if ($proxyShouldRun) {
|
||||||
|
// Ensure networks exist BEFORE dispatching async proxy startup
|
||||||
|
// This prevents race condition where proxy tries to start before networks are created
|
||||||
|
instant_remote_process(ensureProxyNetworksExist($this->server)->toArray(), $this->server, false);
|
||||||
StartProxy::dispatch($this->server);
|
StartProxy::dispatch($this->server);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Listeners;
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Events\MaintenanceModeDisabled as EventsMaintenanceModeDisabled;
|
|
||||||
use Illuminate\Support\Facades\Request;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
use Symfony\Component\HttpFoundation\Request as SymfonyRequest;
|
|
||||||
|
|
||||||
class MaintenanceModeDisabledNotification
|
|
||||||
{
|
|
||||||
public function __construct() {}
|
|
||||||
|
|
||||||
public function handle(EventsMaintenanceModeDisabled $event): void
|
|
||||||
{
|
|
||||||
$files = Storage::disk('webhooks-during-maintenance')->files();
|
|
||||||
$files = collect($files);
|
|
||||||
$files = $files->sort();
|
|
||||||
foreach ($files as $file) {
|
|
||||||
$content = Storage::disk('webhooks-during-maintenance')->get($file);
|
|
||||||
$data = json_decode($content, true);
|
|
||||||
$symfonyRequest = new SymfonyRequest(
|
|
||||||
$data['query'],
|
|
||||||
$data['request'],
|
|
||||||
$data['attributes'],
|
|
||||||
$data['cookies'],
|
|
||||||
$data['files'],
|
|
||||||
$data['server'],
|
|
||||||
$data['content']
|
|
||||||
);
|
|
||||||
|
|
||||||
foreach ($data['headers'] as $key => $value) {
|
|
||||||
$symfonyRequest->headers->set($key, $value);
|
|
||||||
}
|
|
||||||
$request = Request::createFromBase($symfonyRequest);
|
|
||||||
$endpoint = str($file)->after('_')->beforeLast('_')->value();
|
|
||||||
$class = "App\Http\Controllers\Webhook\\".ucfirst(str($endpoint)->before('::')->value());
|
|
||||||
$method = str($endpoint)->after('::')->value();
|
|
||||||
try {
|
|
||||||
$instance = new $class;
|
|
||||||
$instance->$method($request);
|
|
||||||
} catch (\Throwable $th) {
|
|
||||||
} finally {
|
|
||||||
Storage::disk('webhooks-during-maintenance')->delete($file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Listeners;
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Events\MaintenanceModeEnabled as EventsMaintenanceModeEnabled;
|
|
||||||
|
|
||||||
class MaintenanceModeEnabledNotification
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Create the event listener.
|
|
||||||
*/
|
|
||||||
public function __construct()
|
|
||||||
{
|
|
||||||
//
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle the event.
|
|
||||||
*/
|
|
||||||
public function handle(EventsMaintenanceModeEnabled $event): void {}
|
|
||||||
}
|
|
||||||
@@ -2,10 +2,13 @@
|
|||||||
|
|
||||||
namespace App\Listeners;
|
namespace App\Listeners;
|
||||||
|
|
||||||
|
use App\Enums\ProxyTypes;
|
||||||
use App\Events\ProxyStatusChanged;
|
use App\Events\ProxyStatusChanged;
|
||||||
use App\Events\ProxyStatusChangedUI;
|
use App\Events\ProxyStatusChangedUI;
|
||||||
|
use App\Jobs\CheckTraefikVersionForServerJob;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
|
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
class ProxyStatusChangedNotification implements ShouldQueueAfterCommit
|
class ProxyStatusChangedNotification implements ShouldQueueAfterCommit
|
||||||
{
|
{
|
||||||
@@ -32,6 +35,19 @@ class ProxyStatusChangedNotification implements ShouldQueueAfterCommit
|
|||||||
$server->setupDynamicProxyConfiguration();
|
$server->setupDynamicProxyConfiguration();
|
||||||
$server->proxy->force_stop = false;
|
$server->proxy->force_stop = false;
|
||||||
$server->save();
|
$server->save();
|
||||||
|
|
||||||
|
// Check Traefik version after proxy is running
|
||||||
|
if ($server->proxyType() === ProxyTypes::TRAEFIK->value) {
|
||||||
|
$traefikVersions = get_traefik_versions();
|
||||||
|
if ($traefikVersions !== null) {
|
||||||
|
CheckTraefikVersionForServerJob::dispatch($server, $traefikVersions);
|
||||||
|
} else {
|
||||||
|
Log::warning('Traefik version check skipped after proxy status change: versions.json data unavailable', [
|
||||||
|
'server_id' => $server->id,
|
||||||
|
'server_name' => $server->name,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if ($status === 'created') {
|
if ($status === 'created') {
|
||||||
instant_remote_process([
|
instant_remote_process([
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ class ActivityMonitor extends Component
|
|||||||
{
|
{
|
||||||
public ?string $header = null;
|
public ?string $header = null;
|
||||||
|
|
||||||
public $activityId;
|
public $activityId = null;
|
||||||
|
|
||||||
public $eventToDispatch = 'activityFinished';
|
public $eventToDispatch = 'activityFinished';
|
||||||
|
|
||||||
@@ -49,9 +49,24 @@ class ActivityMonitor extends Component
|
|||||||
|
|
||||||
public function hydrateActivity()
|
public function hydrateActivity()
|
||||||
{
|
{
|
||||||
|
if ($this->activityId === null) {
|
||||||
|
$this->activity = null;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$this->activity = Activity::find($this->activityId);
|
$this->activity = Activity::find($this->activityId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function updatedActivityId($value)
|
||||||
|
{
|
||||||
|
if ($value) {
|
||||||
|
$this->hydrateActivity();
|
||||||
|
$this->isPollingActive = true;
|
||||||
|
self::$eventDispatched = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function polling()
|
public function polling()
|
||||||
{
|
{
|
||||||
$this->hydrateActivity();
|
$this->hydrateActivity();
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ class Dashboard extends Component
|
|||||||
|
|
||||||
public function mount()
|
public function mount()
|
||||||
{
|
{
|
||||||
$this->privateKeys = PrivateKey::ownedByCurrentTeam()->get();
|
$this->privateKeys = PrivateKey::ownedByCurrentTeamCached();
|
||||||
$this->servers = Server::ownedByCurrentTeam()->get();
|
$this->servers = Server::ownedByCurrentTeamCached();
|
||||||
$this->projects = Project::ownedByCurrentTeam()->get();
|
$this->projects = Project::ownedByCurrentTeam()->with('environments')->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class DeploymentsIndicator extends Component
|
|||||||
#[Computed]
|
#[Computed]
|
||||||
public function deployments()
|
public function deployments()
|
||||||
{
|
{
|
||||||
$servers = Server::ownedByCurrentTeam()->get();
|
$servers = Server::ownedByCurrentTeamCached();
|
||||||
|
|
||||||
return ApplicationDeploymentQueue::with(['application.environment.project'])
|
return ApplicationDeploymentQueue::with(['application.environment.project'])
|
||||||
->whereIn('status', ['in_progress', 'queued'])
|
->whereIn('status', ['in_progress', 'queued'])
|
||||||
@@ -38,6 +38,12 @@ class DeploymentsIndicator extends Component
|
|||||||
return $this->deployments->count();
|
return $this->deployments->count();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function shouldReduceOpacity(): bool
|
||||||
|
{
|
||||||
|
return request()->routeIs('project.application.deployment.*');
|
||||||
|
}
|
||||||
|
|
||||||
public function toggleExpanded()
|
public function toggleExpanded()
|
||||||
{
|
{
|
||||||
$this->expanded = ! $this->expanded;
|
$this->expanded = ! $this->expanded;
|
||||||
|
|||||||
@@ -2,10 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Livewire;
|
namespace App\Livewire;
|
||||||
|
|
||||||
use App\Models\InstanceSettings;
|
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class NavbarDeleteTeam extends Component
|
class NavbarDeleteTeam extends Component
|
||||||
@@ -19,12 +17,8 @@ class NavbarDeleteTeam extends Component
|
|||||||
|
|
||||||
public function delete($password)
|
public function delete($password)
|
||||||
{
|
{
|
||||||
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
|
if (! verifyPasswordConfirmation($password, $this)) {
|
||||||
if (! Hash::check($password, Auth::user()->password)) {
|
return;
|
||||||
$this->addError('password', 'The provided password is incorrect.');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$currentTeam = currentTeam();
|
$currentTeam = currentTeam();
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ class Advanced extends Component
|
|||||||
#[Validate(['boolean'])]
|
#[Validate(['boolean'])]
|
||||||
public bool $disableBuildCache = false;
|
public bool $disableBuildCache = false;
|
||||||
|
|
||||||
|
#[Validate(['boolean'])]
|
||||||
|
public bool $injectBuildArgsToDockerfile = true;
|
||||||
|
|
||||||
|
#[Validate(['boolean'])]
|
||||||
|
public bool $includeSourceCommitInBuild = false;
|
||||||
|
|
||||||
#[Validate(['boolean'])]
|
#[Validate(['boolean'])]
|
||||||
public bool $isLogDrainEnabled = false;
|
public bool $isLogDrainEnabled = false;
|
||||||
|
|
||||||
@@ -110,6 +116,8 @@ class Advanced extends Component
|
|||||||
$this->application->settings->is_raw_compose_deployment_enabled = $this->isRawComposeDeploymentEnabled;
|
$this->application->settings->is_raw_compose_deployment_enabled = $this->isRawComposeDeploymentEnabled;
|
||||||
$this->application->settings->connect_to_docker_network = $this->isConnectToDockerNetworkEnabled;
|
$this->application->settings->connect_to_docker_network = $this->isConnectToDockerNetworkEnabled;
|
||||||
$this->application->settings->disable_build_cache = $this->disableBuildCache;
|
$this->application->settings->disable_build_cache = $this->disableBuildCache;
|
||||||
|
$this->application->settings->inject_build_args_to_dockerfile = $this->injectBuildArgsToDockerfile;
|
||||||
|
$this->application->settings->include_source_commit_in_build = $this->includeSourceCommitInBuild;
|
||||||
$this->application->settings->save();
|
$this->application->settings->save();
|
||||||
} else {
|
} else {
|
||||||
$this->isForceHttpsEnabled = $this->application->isForceHttpsEnabled();
|
$this->isForceHttpsEnabled = $this->application->isForceHttpsEnabled();
|
||||||
@@ -134,6 +142,8 @@ class Advanced extends Component
|
|||||||
$this->isRawComposeDeploymentEnabled = $this->application->settings->is_raw_compose_deployment_enabled;
|
$this->isRawComposeDeploymentEnabled = $this->application->settings->is_raw_compose_deployment_enabled;
|
||||||
$this->isConnectToDockerNetworkEnabled = $this->application->settings->connect_to_docker_network;
|
$this->isConnectToDockerNetworkEnabled = $this->application->settings->connect_to_docker_network;
|
||||||
$this->disableBuildCache = $this->application->settings->disable_build_cache;
|
$this->disableBuildCache = $this->application->settings->disable_build_cache;
|
||||||
|
$this->injectBuildArgsToDockerfile = $this->application->settings->inject_build_args_to_dockerfile ?? true;
|
||||||
|
$this->includeSourceCommitInBuild = $this->application->settings->include_source_commit_in_build ?? false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,12 +18,15 @@ class Show extends Component
|
|||||||
|
|
||||||
public $isKeepAliveOn = true;
|
public $isKeepAliveOn = true;
|
||||||
|
|
||||||
|
public bool $is_debug_enabled = false;
|
||||||
|
|
||||||
|
public bool $fullscreen = false;
|
||||||
|
|
||||||
|
private bool $deploymentFinishedDispatched = false;
|
||||||
|
|
||||||
public function getListeners()
|
public function getListeners()
|
||||||
{
|
{
|
||||||
$teamId = auth()->user()->currentTeam()->id;
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
"echo-private:team.{$teamId},ServiceChecked" => '$refresh',
|
|
||||||
'refreshQueue',
|
'refreshQueue',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -56,9 +59,23 @@ class Show extends Component
|
|||||||
$this->application_deployment_queue = $application_deployment_queue;
|
$this->application_deployment_queue = $application_deployment_queue;
|
||||||
$this->horizon_job_status = $this->application_deployment_queue->getHorizonJobStatus();
|
$this->horizon_job_status = $this->application_deployment_queue->getHorizonJobStatus();
|
||||||
$this->deployment_uuid = $deploymentUuid;
|
$this->deployment_uuid = $deploymentUuid;
|
||||||
|
$this->is_debug_enabled = $this->application->settings->is_debug_enabled;
|
||||||
$this->isKeepAliveOn();
|
$this->isKeepAliveOn();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function toggleDebug()
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->authorize('update', $this->application);
|
||||||
|
$this->application->settings->is_debug_enabled = ! $this->application->settings->is_debug_enabled;
|
||||||
|
$this->application->settings->save();
|
||||||
|
$this->is_debug_enabled = $this->application->settings->is_debug_enabled;
|
||||||
|
$this->application_deployment_queue->refresh();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return handleError($e, $this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function refreshQueue()
|
public function refreshQueue()
|
||||||
{
|
{
|
||||||
$this->application_deployment_queue->refresh();
|
$this->application_deployment_queue->refresh();
|
||||||
@@ -75,10 +92,15 @@ class Show extends Component
|
|||||||
|
|
||||||
public function polling()
|
public function polling()
|
||||||
{
|
{
|
||||||
$this->dispatch('deploymentFinished');
|
|
||||||
$this->application_deployment_queue->refresh();
|
$this->application_deployment_queue->refresh();
|
||||||
$this->horizon_job_status = $this->application_deployment_queue->getHorizonJobStatus();
|
$this->horizon_job_status = $this->application_deployment_queue->getHorizonJobStatus();
|
||||||
$this->isKeepAliveOn();
|
$this->isKeepAliveOn();
|
||||||
|
|
||||||
|
// Dispatch event when deployment finishes to stop auto-scroll (only once)
|
||||||
|
if (! $this->isKeepAliveOn && ! $this->deploymentFinishedDispatched) {
|
||||||
|
$this->deploymentFinishedDispatched = true;
|
||||||
|
$this->dispatch('deploymentFinished');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getLogLinesProperty()
|
public function getLogLinesProperty()
|
||||||
|
|||||||
@@ -521,7 +521,7 @@ class General extends Component
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function loadComposeFile($isInit = false, $showToast = true)
|
public function loadComposeFile($isInit = false, $showToast = true, ?string $restoreBaseDirectory = null, ?string $restoreDockerComposeLocation = null)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$this->authorize('update', $this->application);
|
$this->authorize('update', $this->application);
|
||||||
@@ -530,7 +530,7 @@ class General extends Component
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
['parsedServices' => $this->parsedServices, 'initialDockerComposeLocation' => $this->initialDockerComposeLocation] = $this->application->loadComposeFile($isInit);
|
['parsedServices' => $this->parsedServices, 'initialDockerComposeLocation' => $this->initialDockerComposeLocation] = $this->application->loadComposeFile($isInit, $restoreBaseDirectory, $restoreDockerComposeLocation);
|
||||||
if (is_null($this->parsedServices)) {
|
if (is_null($this->parsedServices)) {
|
||||||
$showToast && $this->dispatch('error', 'Failed to parse your docker-compose file. Please check the syntax and try again.');
|
$showToast && $this->dispatch('error', 'Failed to parse your docker-compose file. Please check the syntax and try again.');
|
||||||
|
|
||||||
@@ -606,13 +606,6 @@ class General extends Component
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updatedBaseDirectory()
|
|
||||||
{
|
|
||||||
if ($this->buildPack === 'dockercompose') {
|
|
||||||
$this->loadComposeFile();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updatedIsStatic($value)
|
public function updatedIsStatic($value)
|
||||||
{
|
{
|
||||||
if ($value) {
|
if ($value) {
|
||||||
@@ -786,11 +779,13 @@ class General extends Component
|
|||||||
try {
|
try {
|
||||||
$this->authorize('update', $this->application);
|
$this->authorize('update', $this->application);
|
||||||
|
|
||||||
|
$this->resetErrorBag();
|
||||||
$this->validate();
|
$this->validate();
|
||||||
|
|
||||||
$oldPortsExposes = $this->application->ports_exposes;
|
$oldPortsExposes = $this->application->ports_exposes;
|
||||||
$oldIsContainerLabelEscapeEnabled = $this->application->settings->is_container_label_escape_enabled;
|
$oldIsContainerLabelEscapeEnabled = $this->application->settings->is_container_label_escape_enabled;
|
||||||
$oldDockerComposeLocation = $this->initialDockerComposeLocation;
|
$oldDockerComposeLocation = $this->initialDockerComposeLocation;
|
||||||
|
$oldBaseDirectory = $this->application->base_directory;
|
||||||
|
|
||||||
// Process FQDN with intermediate variable to avoid Collection/string confusion
|
// Process FQDN with intermediate variable to avoid Collection/string confusion
|
||||||
$this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString();
|
$this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString();
|
||||||
@@ -821,6 +816,42 @@ class General extends Component
|
|||||||
return; // Stop if there are conflicts and user hasn't confirmed
|
return; // Stop if there are conflicts and user hasn't confirmed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Normalize paths BEFORE validation
|
||||||
|
if ($this->baseDirectory && $this->baseDirectory !== '/') {
|
||||||
|
$this->baseDirectory = rtrim($this->baseDirectory, '/');
|
||||||
|
$this->application->base_directory = $this->baseDirectory;
|
||||||
|
}
|
||||||
|
if ($this->publishDirectory && $this->publishDirectory !== '/') {
|
||||||
|
$this->publishDirectory = rtrim($this->publishDirectory, '/');
|
||||||
|
$this->application->publish_directory = $this->publishDirectory;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate docker compose file path BEFORE saving to database
|
||||||
|
// This prevents invalid paths from being persisted when validation fails
|
||||||
|
if ($this->buildPack === 'dockercompose' &&
|
||||||
|
($oldDockerComposeLocation !== $this->dockerComposeLocation ||
|
||||||
|
$oldBaseDirectory !== $this->baseDirectory)) {
|
||||||
|
// Pass original values to loadComposeFile so it can restore them on failure
|
||||||
|
// The finally block in Application::loadComposeFile will save these original
|
||||||
|
// values if validation fails, preventing invalid paths from being persisted
|
||||||
|
$compose_return = $this->loadComposeFile(
|
||||||
|
isInit: false,
|
||||||
|
showToast: false,
|
||||||
|
restoreBaseDirectory: $oldBaseDirectory,
|
||||||
|
restoreDockerComposeLocation: $oldDockerComposeLocation
|
||||||
|
);
|
||||||
|
if ($compose_return instanceof \Livewire\Features\SupportEvents\Event) {
|
||||||
|
// Validation failed - restore original values to component properties
|
||||||
|
$this->baseDirectory = $oldBaseDirectory;
|
||||||
|
$this->dockerComposeLocation = $oldDockerComposeLocation;
|
||||||
|
// The model was saved by loadComposeFile's finally block with original values
|
||||||
|
// Refresh to sync component with database state
|
||||||
|
$this->application->refresh();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$this->application->save();
|
$this->application->save();
|
||||||
if (! $this->customLabels && $this->application->destination->server->proxyType() !== 'NONE' && ! $this->application->settings->is_container_label_readonly_enabled) {
|
if (! $this->customLabels && $this->application->destination->server->proxyType() !== 'NONE' && ! $this->application->settings->is_container_label_readonly_enabled) {
|
||||||
$this->customLabels = str(implode('|coolify|', generateLabelsApplication($this->application)))->replace('|coolify|', "\n");
|
$this->customLabels = str(implode('|coolify|', generateLabelsApplication($this->application)))->replace('|coolify|', "\n");
|
||||||
@@ -828,13 +859,6 @@ class General extends Component
|
|||||||
$this->application->save();
|
$this->application->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->buildPack === 'dockercompose' && $oldDockerComposeLocation !== $this->dockerComposeLocation) {
|
|
||||||
$compose_return = $this->loadComposeFile(showToast: false);
|
|
||||||
if ($compose_return instanceof \Livewire\Features\SupportEvents\Event) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($oldPortsExposes !== $this->portsExposes || $oldIsContainerLabelEscapeEnabled !== $this->isContainerLabelEscapeEnabled) {
|
if ($oldPortsExposes !== $this->portsExposes || $oldIsContainerLabelEscapeEnabled !== $this->isContainerLabelEscapeEnabled) {
|
||||||
$this->resetDefaultLabels();
|
$this->resetDefaultLabels();
|
||||||
}
|
}
|
||||||
@@ -855,14 +879,6 @@ class General extends Component
|
|||||||
$this->application->ports_exposes = $port;
|
$this->application->ports_exposes = $port;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($this->baseDirectory && $this->baseDirectory !== '/') {
|
|
||||||
$this->baseDirectory = rtrim($this->baseDirectory, '/');
|
|
||||||
$this->application->base_directory = $this->baseDirectory;
|
|
||||||
}
|
|
||||||
if ($this->publishDirectory && $this->publishDirectory !== '/') {
|
|
||||||
$this->publishDirectory = rtrim($this->publishDirectory, '/');
|
|
||||||
$this->application->publish_directory = $this->publishDirectory;
|
|
||||||
}
|
|
||||||
if ($this->buildPack === 'dockercompose') {
|
if ($this->buildPack === 'dockercompose') {
|
||||||
$this->application->docker_compose_domains = json_encode($this->parsedServiceDomains);
|
$this->application->docker_compose_domains = json_encode($this->parsedServiceDomains);
|
||||||
if ($this->application->isDirty('docker_compose_domains')) {
|
if ($this->application->isDirty('docker_compose_domains')) {
|
||||||
@@ -1018,11 +1034,27 @@ class General extends Component
|
|||||||
// Use relative path for clarity in preview (e.g., ./backend/docker-compose.yaml)
|
// Use relative path for clarity in preview (e.g., ./backend/docker-compose.yaml)
|
||||||
// Actual deployment uses absolute path: /artifacts/{deployment_uuid}{base_directory}{docker_compose_location}
|
// Actual deployment uses absolute path: /artifacts/{deployment_uuid}{base_directory}{docker_compose_location}
|
||||||
// Build-time env path references ApplicationDeploymentJob::BUILD_TIME_ENV_PATH as source of truth
|
// Build-time env path references ApplicationDeploymentJob::BUILD_TIME_ENV_PATH as source of truth
|
||||||
return injectDockerComposeFlags(
|
$command = injectDockerComposeFlags(
|
||||||
$this->dockerComposeCustomBuildCommand,
|
$this->dockerComposeCustomBuildCommand,
|
||||||
".{$normalizedBase}{$this->dockerComposeLocation}",
|
".{$normalizedBase}{$this->dockerComposeLocation}",
|
||||||
\App\Jobs\ApplicationDeploymentJob::BUILD_TIME_ENV_PATH
|
\App\Jobs\ApplicationDeploymentJob::BUILD_TIME_ENV_PATH
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Inject build args if not using build secrets
|
||||||
|
if (! $this->application->settings->use_build_secrets) {
|
||||||
|
$buildTimeEnvs = $this->application->environment_variables()
|
||||||
|
->where('is_buildtime', true)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($buildTimeEnvs->isNotEmpty()) {
|
||||||
|
$buildArgs = generateDockerBuildArgs($buildTimeEnvs);
|
||||||
|
$buildArgsString = $buildArgs->implode(' ');
|
||||||
|
|
||||||
|
$command = injectDockerComposeBuildArgs($command, $buildArgsString);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $command;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getDockerComposeStartCommandPreviewProperty(): string
|
public function getDockerComposeStartCommandPreviewProperty(): string
|
||||||
|
|||||||
@@ -100,19 +100,17 @@ class Heading extends Component
|
|||||||
deployment_uuid: $this->deploymentUuid,
|
deployment_uuid: $this->deploymentUuid,
|
||||||
force_rebuild: $force_rebuild,
|
force_rebuild: $force_rebuild,
|
||||||
);
|
);
|
||||||
|
if ($result['status'] === 'queue_full') {
|
||||||
|
$this->dispatch('error', 'Deployment queue full', $result['message']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
if ($result['status'] === 'skipped') {
|
if ($result['status'] === 'skipped') {
|
||||||
$this->dispatch('error', 'Deployment skipped', $result['message']);
|
$this->dispatch('error', 'Deployment skipped', $result['message']);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset restart count on successful deployment
|
|
||||||
$this->application->update([
|
|
||||||
'restart_count' => 0,
|
|
||||||
'last_restart_at' => null,
|
|
||||||
'last_restart_type' => null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $this->redirectRoute('project.application.deployment.show', [
|
return $this->redirectRoute('project.application.deployment.show', [
|
||||||
'project_uuid' => $this->parameters['project_uuid'],
|
'project_uuid' => $this->parameters['project_uuid'],
|
||||||
'application_uuid' => $this->parameters['application_uuid'],
|
'application_uuid' => $this->parameters['application_uuid'],
|
||||||
@@ -151,19 +149,17 @@ class Heading extends Component
|
|||||||
deployment_uuid: $this->deploymentUuid,
|
deployment_uuid: $this->deploymentUuid,
|
||||||
restart_only: true,
|
restart_only: true,
|
||||||
);
|
);
|
||||||
|
if ($result['status'] === 'queue_full') {
|
||||||
|
$this->dispatch('error', 'Deployment queue full', $result['message']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
if ($result['status'] === 'skipped') {
|
if ($result['status'] === 'skipped') {
|
||||||
$this->dispatch('success', 'Deployment skipped', $result['message']);
|
$this->dispatch('success', 'Deployment skipped', $result['message']);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset restart count on manual restart
|
|
||||||
$this->application->update([
|
|
||||||
'restart_count' => 0,
|
|
||||||
'last_restart_at' => now(),
|
|
||||||
'last_restart_type' => 'manual',
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $this->redirectRoute('project.application.deployment.show', [
|
return $this->redirectRoute('project.application.deployment.show', [
|
||||||
'project_uuid' => $this->parameters['project_uuid'],
|
'project_uuid' => $this->parameters['project_uuid'],
|
||||||
'application_uuid' => $this->parameters['application_uuid'],
|
'application_uuid' => $this->parameters['application_uuid'],
|
||||||
|
|||||||
@@ -249,6 +249,11 @@ class Previews extends Component
|
|||||||
pull_request_id: $pull_request_id,
|
pull_request_id: $pull_request_id,
|
||||||
git_type: $found->git_type ?? null,
|
git_type: $found->git_type ?? null,
|
||||||
);
|
);
|
||||||
|
if ($result['status'] === 'queue_full') {
|
||||||
|
$this->dispatch('error', 'Deployment queue full', $result['message']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
if ($result['status'] === 'skipped') {
|
if ($result['status'] === 'skipped') {
|
||||||
$this->dispatch('success', 'Deployment skipped', $result['message']);
|
$this->dispatch('success', 'Deployment skipped', $result['message']);
|
||||||
|
|
||||||
@@ -278,7 +283,7 @@ class Previews extends Component
|
|||||||
|
|
||||||
foreach ($containersToStop as $containerName) {
|
foreach ($containersToStop as $containerName) {
|
||||||
instant_remote_process(command: [
|
instant_remote_process(command: [
|
||||||
"docker stop --time=30 $containerName",
|
"docker stop -t 30 $containerName",
|
||||||
"docker rm -f $containerName",
|
"docker rm -f $containerName",
|
||||||
], server: $server, throwError: false);
|
], server: $server, throwError: false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,8 +96,7 @@ class PreviewsCompose extends Component
|
|||||||
$preview_fqdn = str_replace('{{random}}', $random, $template);
|
$preview_fqdn = str_replace('{{random}}', $random, $template);
|
||||||
$preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
|
$preview_fqdn = str_replace('{{domain}}', $host, $preview_fqdn);
|
||||||
$preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn);
|
$preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn);
|
||||||
$preview_fqdn = str_replace('{{port}}', $port, $preview_fqdn);
|
$preview_fqdns[] = "$schema://$preview_fqdn{$port}";
|
||||||
$preview_fqdns[] = "$schema://$preview_fqdn";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$preview_fqdn = implode(',', $preview_fqdns);
|
$preview_fqdn = implode(',', $preview_fqdns);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Livewire\Project\Application;
|
|||||||
|
|
||||||
use App\Models\Application;
|
use App\Models\Application;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
|
use Livewire\Attributes\Validate;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Visus\Cuid2\Cuid2;
|
use Visus\Cuid2\Cuid2;
|
||||||
|
|
||||||
@@ -19,9 +20,30 @@ class Rollback extends Component
|
|||||||
|
|
||||||
public array $parameters;
|
public array $parameters;
|
||||||
|
|
||||||
|
#[Validate(['integer', 'min:0', 'max:100'])]
|
||||||
|
public int $dockerImagesToKeep = 2;
|
||||||
|
|
||||||
|
public bool $serverRetentionDisabled = false;
|
||||||
|
|
||||||
public function mount()
|
public function mount()
|
||||||
{
|
{
|
||||||
$this->parameters = get_route_parameters();
|
$this->parameters = get_route_parameters();
|
||||||
|
$this->dockerImagesToKeep = $this->application->settings->docker_images_to_keep ?? 2;
|
||||||
|
$server = $this->application->destination->server;
|
||||||
|
$this->serverRetentionDisabled = $server->settings->disable_application_image_retention ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveSettings()
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->authorize('update', $this->application);
|
||||||
|
$this->validate();
|
||||||
|
$this->application->settings->docker_images_to_keep = $this->dockerImagesToKeep;
|
||||||
|
$this->application->settings->save();
|
||||||
|
$this->dispatch('success', 'Settings saved.');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return handleError($e, $this);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function rollbackImage($commit)
|
public function rollbackImage($commit)
|
||||||
@@ -30,7 +52,7 @@ class Rollback extends Component
|
|||||||
|
|
||||||
$deployment_uuid = new Cuid2;
|
$deployment_uuid = new Cuid2;
|
||||||
|
|
||||||
queue_application_deployment(
|
$result = queue_application_deployment(
|
||||||
application: $this->application,
|
application: $this->application,
|
||||||
deployment_uuid: $deployment_uuid,
|
deployment_uuid: $deployment_uuid,
|
||||||
commit: $commit,
|
commit: $commit,
|
||||||
@@ -38,6 +60,12 @@ class Rollback extends Component
|
|||||||
force_rebuild: false,
|
force_rebuild: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if ($result['status'] === 'queue_full') {
|
||||||
|
$this->dispatch('error', 'Deployment queue full', $result['message']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
return redirect()->route('project.application.deployment.show', [
|
return redirect()->route('project.application.deployment.show', [
|
||||||
'project_uuid' => $this->parameters['project_uuid'],
|
'project_uuid' => $this->parameters['project_uuid'],
|
||||||
'application_uuid' => $this->parameters['application_uuid'],
|
'application_uuid' => $this->parameters['application_uuid'],
|
||||||
@@ -66,14 +94,12 @@ class Rollback extends Component
|
|||||||
return str($item)->contains($image);
|
return str($item)->contains($image);
|
||||||
})->map(function ($item) {
|
})->map(function ($item) {
|
||||||
$item = str($item)->explode('#');
|
$item = str($item)->explode('#');
|
||||||
if ($item[1] === $this->current) {
|
$is_current = $item[1] === $this->current;
|
||||||
// $is_current = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'tag' => $item[1],
|
'tag' => $item[1],
|
||||||
'created_at' => $item[2],
|
'created_at' => $item[2],
|
||||||
'is_current' => $is_current ?? null,
|
'is_current' => $is_current,
|
||||||
];
|
];
|
||||||
})->toArray();
|
})->toArray();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Livewire\Project\Database;
|
namespace App\Livewire\Project\Database;
|
||||||
|
|
||||||
use App\Models\InstanceSettings;
|
|
||||||
use App\Models\ScheduledDatabaseBackup;
|
use App\Models\ScheduledDatabaseBackup;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Livewire\Attributes\Locked;
|
use Livewire\Attributes\Locked;
|
||||||
use Livewire\Attributes\Validate;
|
use Livewire\Attributes\Validate;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
@@ -107,6 +104,25 @@ class BackupEdit extends Component
|
|||||||
$this->backup->save_s3 = $this->saveS3;
|
$this->backup->save_s3 = $this->saveS3;
|
||||||
$this->backup->disable_local_backup = $this->disableLocalBackup;
|
$this->backup->disable_local_backup = $this->disableLocalBackup;
|
||||||
$this->backup->s3_storage_id = $this->s3StorageId;
|
$this->backup->s3_storage_id = $this->s3StorageId;
|
||||||
|
|
||||||
|
// Validate databases_to_backup to prevent command injection
|
||||||
|
if (filled($this->databasesToBackup)) {
|
||||||
|
$databases = str($this->databasesToBackup)->explode(',');
|
||||||
|
foreach ($databases as $index => $db) {
|
||||||
|
$dbName = trim($db);
|
||||||
|
try {
|
||||||
|
validateShellSafePath($dbName, 'database name');
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Provide specific error message indicating which database failed validation
|
||||||
|
$position = $index + 1;
|
||||||
|
throw new \Exception(
|
||||||
|
"Database #{$position} ('{$dbName}') validation failed: ".
|
||||||
|
$e->getMessage()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$this->backup->databases_to_backup = $this->databasesToBackup;
|
$this->backup->databases_to_backup = $this->databasesToBackup;
|
||||||
$this->backup->dump_all = $this->dumpAll;
|
$this->backup->dump_all = $this->dumpAll;
|
||||||
$this->backup->timeout = $this->timeout;
|
$this->backup->timeout = $this->timeout;
|
||||||
@@ -135,12 +151,8 @@ class BackupEdit extends Component
|
|||||||
{
|
{
|
||||||
$this->authorize('manageBackups', $this->backup->database);
|
$this->authorize('manageBackups', $this->backup->database);
|
||||||
|
|
||||||
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
|
if (! verifyPasswordConfirmation($password, $this)) {
|
||||||
if (! Hash::check($password, Auth::user()->password)) {
|
return;
|
||||||
$this->addError('password', 'The provided password is incorrect.');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -2,11 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Livewire\Project\Database;
|
namespace App\Livewire\Project\Database;
|
||||||
|
|
||||||
use App\Models\InstanceSettings;
|
|
||||||
use App\Models\ScheduledDatabaseBackup;
|
use App\Models\ScheduledDatabaseBackup;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class BackupExecutions extends Component
|
class BackupExecutions extends Component
|
||||||
@@ -69,12 +67,8 @@ class BackupExecutions extends Component
|
|||||||
|
|
||||||
public function deleteBackup($executionId, $password)
|
public function deleteBackup($executionId, $password)
|
||||||
{
|
{
|
||||||
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
|
if (! verifyPasswordConfirmation($password, $this)) {
|
||||||
if (! Hash::check($password, Auth::user()->password)) {
|
return;
|
||||||
$this->addError('password', 'The provided password is incorrect.');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$execution = $this->backup->executions()->where('id', $executionId)->first();
|
$execution = $this->backup->executions()->where('id', $executionId)->first();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Livewire\Project\Database;
|
namespace App\Livewire\Project\Database;
|
||||||
|
|
||||||
|
use App\Models\S3Storage;
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
@@ -12,6 +13,92 @@ class Import extends Component
|
|||||||
{
|
{
|
||||||
use AuthorizesRequests;
|
use AuthorizesRequests;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a string is safe for use as an S3 bucket name.
|
||||||
|
* Allows alphanumerics, dots, dashes, and underscores.
|
||||||
|
*/
|
||||||
|
private function validateBucketName(string $bucket): bool
|
||||||
|
{
|
||||||
|
return preg_match('/^[a-zA-Z0-9.\-_]+$/', $bucket) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a string is safe for use as an S3 path.
|
||||||
|
* Allows alphanumerics, dots, dashes, underscores, slashes, and common file characters.
|
||||||
|
*/
|
||||||
|
private function validateS3Path(string $path): bool
|
||||||
|
{
|
||||||
|
// Must not be empty
|
||||||
|
if (empty($path)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must not contain dangerous shell metacharacters or command injection patterns
|
||||||
|
$dangerousPatterns = [
|
||||||
|
'..', // Directory traversal
|
||||||
|
'$(', // Command substitution
|
||||||
|
'`', // Backtick command substitution
|
||||||
|
'|', // Pipe
|
||||||
|
';', // Command separator
|
||||||
|
'&', // Background/AND
|
||||||
|
'>', // Redirect
|
||||||
|
'<', // Redirect
|
||||||
|
"\n", // Newline
|
||||||
|
"\r", // Carriage return
|
||||||
|
"\0", // Null byte
|
||||||
|
"'", // Single quote
|
||||||
|
'"', // Double quote
|
||||||
|
'\\', // Backslash
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($dangerousPatterns as $pattern) {
|
||||||
|
if (str_contains($path, $pattern)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow alphanumerics, dots, dashes, underscores, slashes, spaces, plus, equals, at
|
||||||
|
return preg_match('/^[a-zA-Z0-9.\-_\/\s+@=]+$/', $path) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a string is safe for use as a file path on the server.
|
||||||
|
*/
|
||||||
|
private function validateServerPath(string $path): bool
|
||||||
|
{
|
||||||
|
// Must be an absolute path
|
||||||
|
if (! str_starts_with($path, '/')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must not contain dangerous shell metacharacters or command injection patterns
|
||||||
|
$dangerousPatterns = [
|
||||||
|
'..', // Directory traversal
|
||||||
|
'$(', // Command substitution
|
||||||
|
'`', // Backtick command substitution
|
||||||
|
'|', // Pipe
|
||||||
|
';', // Command separator
|
||||||
|
'&', // Background/AND
|
||||||
|
'>', // Redirect
|
||||||
|
'<', // Redirect
|
||||||
|
"\n", // Newline
|
||||||
|
"\r", // Carriage return
|
||||||
|
"\0", // Null byte
|
||||||
|
"'", // Single quote
|
||||||
|
'"', // Double quote
|
||||||
|
'\\', // Backslash
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($dangerousPatterns as $pattern) {
|
||||||
|
if (str_contains($path, $pattern)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow alphanumerics, dots, dashes, underscores, slashes, and spaces
|
||||||
|
return preg_match('/^[a-zA-Z0-9.\-_\/\s]+$/', $path) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
public bool $unsupported = false;
|
public bool $unsupported = false;
|
||||||
|
|
||||||
public $resource;
|
public $resource;
|
||||||
@@ -46,6 +133,8 @@ class Import extends Component
|
|||||||
|
|
||||||
public string $customLocation = '';
|
public string $customLocation = '';
|
||||||
|
|
||||||
|
public ?int $activityId = null;
|
||||||
|
|
||||||
public string $postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d $POSTGRES_DB';
|
public string $postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d $POSTGRES_DB';
|
||||||
|
|
||||||
public string $mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
|
public string $mysqlRestoreCommand = 'mysql -u $MYSQL_USER -p$MYSQL_PASSWORD $MYSQL_DATABASE';
|
||||||
@@ -54,22 +143,35 @@ class Import extends Component
|
|||||||
|
|
||||||
public string $mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive=';
|
public string $mongodbRestoreCommand = 'mongorestore --authenticationDatabase=admin --username $MONGO_INITDB_ROOT_USERNAME --password $MONGO_INITDB_ROOT_PASSWORD --uri mongodb://localhost:27017 --gzip --archive=';
|
||||||
|
|
||||||
|
// S3 Restore properties
|
||||||
|
public $availableS3Storages = [];
|
||||||
|
|
||||||
|
public ?int $s3StorageId = null;
|
||||||
|
|
||||||
|
public string $s3Path = '';
|
||||||
|
|
||||||
|
public ?int $s3FileSize = null;
|
||||||
|
|
||||||
public function getListeners()
|
public function getListeners()
|
||||||
{
|
{
|
||||||
$userId = Auth::id();
|
$userId = Auth::id();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
|
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
|
||||||
|
'slideOverClosed' => 'resetActivityId',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function resetActivityId()
|
||||||
|
{
|
||||||
|
$this->activityId = null;
|
||||||
|
}
|
||||||
|
|
||||||
public function mount()
|
public function mount()
|
||||||
{
|
{
|
||||||
if (isDev()) {
|
|
||||||
$this->customLocation = '/data/coolify/pg-dump-all-1736245863.gz';
|
|
||||||
}
|
|
||||||
$this->parameters = get_route_parameters();
|
$this->parameters = get_route_parameters();
|
||||||
$this->getContainers();
|
$this->getContainers();
|
||||||
|
$this->loadAvailableS3Storages();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updatedDumpAll($value)
|
public function updatedDumpAll($value)
|
||||||
@@ -152,8 +254,16 @@ EOD;
|
|||||||
public function checkFile()
|
public function checkFile()
|
||||||
{
|
{
|
||||||
if (filled($this->customLocation)) {
|
if (filled($this->customLocation)) {
|
||||||
|
// Validate the custom location to prevent command injection
|
||||||
|
if (! $this->validateServerPath($this->customLocation)) {
|
||||||
|
$this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$result = instant_remote_process(["ls -l {$this->customLocation}"], $this->server, throwError: false);
|
$escapedPath = escapeshellarg($this->customLocation);
|
||||||
|
$result = instant_remote_process(["ls -l {$escapedPath}"], $this->server, throwError: false);
|
||||||
if (blank($result)) {
|
if (blank($result)) {
|
||||||
$this->dispatch('error', 'The file does not exist or has been deleted.');
|
$this->dispatch('error', 'The file does not exist or has been deleted.');
|
||||||
|
|
||||||
@@ -179,59 +289,35 @@ EOD;
|
|||||||
try {
|
try {
|
||||||
$this->importRunning = true;
|
$this->importRunning = true;
|
||||||
$this->importCommands = [];
|
$this->importCommands = [];
|
||||||
if (filled($this->customLocation)) {
|
$backupFileName = "upload/{$this->resource->uuid}/restore";
|
||||||
$backupFileName = '/tmp/restore_'.$this->resource->uuid;
|
|
||||||
$this->importCommands[] = "docker cp {$this->customLocation} {$this->container}:{$backupFileName}";
|
|
||||||
$tmpPath = $backupFileName;
|
|
||||||
} else {
|
|
||||||
$backupFileName = "upload/{$this->resource->uuid}/restore";
|
|
||||||
$path = Storage::path($backupFileName);
|
|
||||||
if (! Storage::exists($backupFileName)) {
|
|
||||||
$this->dispatch('error', 'The file does not exist or has been deleted.');
|
|
||||||
|
|
||||||
return;
|
// Check if an uploaded file exists first (takes priority over custom location)
|
||||||
}
|
if (Storage::exists($backupFileName)) {
|
||||||
|
$path = Storage::path($backupFileName);
|
||||||
$tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resource->uuid;
|
$tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resource->uuid;
|
||||||
instant_scp($path, $tmpPath, $this->server);
|
instant_scp($path, $tmpPath, $this->server);
|
||||||
Storage::delete($backupFileName);
|
Storage::delete($backupFileName);
|
||||||
$this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}";
|
$this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}";
|
||||||
|
} elseif (filled($this->customLocation)) {
|
||||||
|
// Validate the custom location to prevent command injection
|
||||||
|
if (! $this->validateServerPath($this->customLocation)) {
|
||||||
|
$this->dispatch('error', 'Invalid file path. Path must be absolute and contain only safe characters.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$tmpPath = '/tmp/restore_'.$this->resource->uuid;
|
||||||
|
$escapedCustomLocation = escapeshellarg($this->customLocation);
|
||||||
|
$this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}";
|
||||||
|
} else {
|
||||||
|
$this->dispatch('error', 'The file does not exist or has been deleted.');
|
||||||
|
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy the restore command to a script file
|
// Copy the restore command to a script file
|
||||||
$scriptPath = "/tmp/restore_{$this->resource->uuid}.sh";
|
$scriptPath = "/tmp/restore_{$this->resource->uuid}.sh";
|
||||||
|
|
||||||
switch ($this->resource->getMorphClass()) {
|
$restoreCommand = $this->buildRestoreCommand($tmpPath);
|
||||||
case \App\Models\StandaloneMariadb::class:
|
|
||||||
$restoreCommand = $this->mariadbRestoreCommand;
|
|
||||||
if ($this->dumpAll) {
|
|
||||||
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD";
|
|
||||||
} else {
|
|
||||||
$restoreCommand .= " < {$tmpPath}";
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case \App\Models\StandaloneMysql::class:
|
|
||||||
$restoreCommand = $this->mysqlRestoreCommand;
|
|
||||||
if ($this->dumpAll) {
|
|
||||||
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD";
|
|
||||||
} else {
|
|
||||||
$restoreCommand .= " < {$tmpPath}";
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case \App\Models\StandalonePostgresql::class:
|
|
||||||
$restoreCommand = $this->postgresqlRestoreCommand;
|
|
||||||
if ($this->dumpAll) {
|
|
||||||
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | psql -U \$POSTGRES_USER postgres";
|
|
||||||
} else {
|
|
||||||
$restoreCommand .= " {$tmpPath}";
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case \App\Models\StandaloneMongodb::class:
|
|
||||||
$restoreCommand = $this->mongodbRestoreCommand;
|
|
||||||
if ($this->dumpAll === false) {
|
|
||||||
$restoreCommand .= "{$tmpPath}";
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
$restoreCommandBase64 = base64_encode($restoreCommand);
|
$restoreCommandBase64 = base64_encode($restoreCommand);
|
||||||
$this->importCommands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
|
$this->importCommands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
|
||||||
@@ -248,7 +334,13 @@ EOD;
|
|||||||
'container' => $this->container,
|
'container' => $this->container,
|
||||||
'serverId' => $this->server->id,
|
'serverId' => $this->server->id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Track the activity ID
|
||||||
|
$this->activityId = $activity->id;
|
||||||
|
|
||||||
|
// Dispatch activity to the monitor and open slide-over
|
||||||
$this->dispatch('activityMonitor', $activity->id);
|
$this->dispatch('activityMonitor', $activity->id);
|
||||||
|
$this->dispatch('databaserestore');
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return handleError($e, $this);
|
return handleError($e, $this);
|
||||||
@@ -257,4 +349,267 @@ EOD;
|
|||||||
$this->importCommands = [];
|
$this->importCommands = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function loadAvailableS3Storages()
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->availableS3Storages = S3Storage::ownedByCurrentTeam(['id', 'name', 'description'])
|
||||||
|
->where('is_usable', true)
|
||||||
|
->get();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->availableS3Storages = collect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedS3Path($value)
|
||||||
|
{
|
||||||
|
// Reset validation state when path changes
|
||||||
|
$this->s3FileSize = null;
|
||||||
|
|
||||||
|
// Ensure path starts with a slash
|
||||||
|
if ($value !== null && $value !== '') {
|
||||||
|
$this->s3Path = str($value)->trim()->start('/')->value();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updatedS3StorageId()
|
||||||
|
{
|
||||||
|
// Reset validation state when storage changes
|
||||||
|
$this->s3FileSize = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function checkS3File()
|
||||||
|
{
|
||||||
|
if (! $this->s3StorageId) {
|
||||||
|
$this->dispatch('error', 'Please select an S3 storage.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blank($this->s3Path)) {
|
||||||
|
$this->dispatch('error', 'Please provide an S3 path.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean the path (remove leading slash if present)
|
||||||
|
$cleanPath = ltrim($this->s3Path, '/');
|
||||||
|
|
||||||
|
// Validate the S3 path early to prevent command injection in subsequent operations
|
||||||
|
if (! $this->validateS3Path($cleanPath)) {
|
||||||
|
$this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
|
||||||
|
|
||||||
|
// Validate bucket name early
|
||||||
|
if (! $this->validateBucketName($s3Storage->bucket)) {
|
||||||
|
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test connection
|
||||||
|
$s3Storage->testConnection();
|
||||||
|
|
||||||
|
// Build S3 disk configuration
|
||||||
|
$disk = Storage::build([
|
||||||
|
'driver' => 's3',
|
||||||
|
'region' => $s3Storage->region,
|
||||||
|
'key' => $s3Storage->key,
|
||||||
|
'secret' => $s3Storage->secret,
|
||||||
|
'bucket' => $s3Storage->bucket,
|
||||||
|
'endpoint' => $s3Storage->endpoint,
|
||||||
|
'use_path_style_endpoint' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Check if file exists
|
||||||
|
if (! $disk->exists($cleanPath)) {
|
||||||
|
$this->dispatch('error', 'File not found in S3. Please check the path.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get file size
|
||||||
|
$this->s3FileSize = $disk->size($cleanPath);
|
||||||
|
|
||||||
|
$this->dispatch('success', 'File found in S3. Size: '.formatBytes($this->s3FileSize));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->s3FileSize = null;
|
||||||
|
|
||||||
|
return handleError($e, $this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function restoreFromS3()
|
||||||
|
{
|
||||||
|
$this->authorize('update', $this->resource);
|
||||||
|
|
||||||
|
if (! $this->s3StorageId || blank($this->s3Path)) {
|
||||||
|
$this->dispatch('error', 'Please select S3 storage and provide a path first.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_null($this->s3FileSize)) {
|
||||||
|
$this->dispatch('error', 'Please check the file first by clicking "Check File".');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->importRunning = true;
|
||||||
|
|
||||||
|
$s3Storage = S3Storage::ownedByCurrentTeam()->findOrFail($this->s3StorageId);
|
||||||
|
|
||||||
|
$key = $s3Storage->key;
|
||||||
|
$secret = $s3Storage->secret;
|
||||||
|
$bucket = $s3Storage->bucket;
|
||||||
|
$endpoint = $s3Storage->endpoint;
|
||||||
|
|
||||||
|
// Validate bucket name to prevent command injection
|
||||||
|
if (! $this->validateBucketName($bucket)) {
|
||||||
|
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean the S3 path
|
||||||
|
$cleanPath = ltrim($this->s3Path, '/');
|
||||||
|
|
||||||
|
// Validate the S3 path to prevent command injection
|
||||||
|
if (! $this->validateS3Path($cleanPath)) {
|
||||||
|
$this->dispatch('error', 'Invalid S3 path. Path must contain only safe characters (alphanumerics, dots, dashes, underscores, slashes).');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get helper image
|
||||||
|
$helperImage = config('constants.coolify.helper_image');
|
||||||
|
$latestVersion = getHelperVersion();
|
||||||
|
$fullImageName = "{$helperImage}:{$latestVersion}";
|
||||||
|
|
||||||
|
// Get the database destination network
|
||||||
|
$destinationNetwork = $this->resource->destination->network ?? 'coolify';
|
||||||
|
|
||||||
|
// Generate unique names for this operation
|
||||||
|
$containerName = "s3-restore-{$this->resource->uuid}";
|
||||||
|
$helperTmpPath = '/tmp/'.basename($cleanPath);
|
||||||
|
$serverTmpPath = "/tmp/s3-restore-{$this->resource->uuid}-".basename($cleanPath);
|
||||||
|
$containerTmpPath = "/tmp/restore_{$this->resource->uuid}-".basename($cleanPath);
|
||||||
|
$scriptPath = "/tmp/restore_{$this->resource->uuid}.sh";
|
||||||
|
|
||||||
|
// Prepare all commands in sequence
|
||||||
|
$commands = [];
|
||||||
|
|
||||||
|
// 1. Clean up any existing helper container and temp files from previous runs
|
||||||
|
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
|
||||||
|
$commands[] = "rm -f {$serverTmpPath} 2>/dev/null || true";
|
||||||
|
$commands[] = "docker exec {$this->container} rm -f {$containerTmpPath} {$scriptPath} 2>/dev/null || true";
|
||||||
|
|
||||||
|
// 2. Start helper container on the database network
|
||||||
|
$commands[] = "docker run -d --network {$destinationNetwork} --name {$containerName} {$fullImageName} sleep 3600";
|
||||||
|
|
||||||
|
// 3. Configure S3 access in helper container
|
||||||
|
$escapedEndpoint = escapeshellarg($endpoint);
|
||||||
|
$escapedKey = escapeshellarg($key);
|
||||||
|
$escapedSecret = escapeshellarg($secret);
|
||||||
|
$commands[] = "docker exec {$containerName} mc alias set s3temp {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
|
||||||
|
|
||||||
|
// 4. Check file exists in S3 (bucket and path already validated above)
|
||||||
|
$escapedBucket = escapeshellarg($bucket);
|
||||||
|
$escapedCleanPath = escapeshellarg($cleanPath);
|
||||||
|
$escapedS3Source = escapeshellarg("s3temp/{$bucket}/{$cleanPath}");
|
||||||
|
$commands[] = "docker exec {$containerName} mc stat {$escapedS3Source}";
|
||||||
|
|
||||||
|
// 5. Download from S3 to helper container (progress shown by default)
|
||||||
|
$escapedHelperTmpPath = escapeshellarg($helperTmpPath);
|
||||||
|
$commands[] = "docker exec {$containerName} mc cp {$escapedS3Source} {$escapedHelperTmpPath}";
|
||||||
|
|
||||||
|
// 6. Copy from helper to server, then immediately to database container
|
||||||
|
$commands[] = "docker cp {$containerName}:{$helperTmpPath} {$serverTmpPath}";
|
||||||
|
$commands[] = "docker cp {$serverTmpPath} {$this->container}:{$containerTmpPath}";
|
||||||
|
|
||||||
|
// 7. Cleanup helper container and server temp file immediately (no longer needed)
|
||||||
|
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
|
||||||
|
$commands[] = "rm -f {$serverTmpPath} 2>/dev/null || true";
|
||||||
|
|
||||||
|
// 8. Build and execute restore command inside database container
|
||||||
|
$restoreCommand = $this->buildRestoreCommand($containerTmpPath);
|
||||||
|
|
||||||
|
$restoreCommandBase64 = base64_encode($restoreCommand);
|
||||||
|
$commands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
|
||||||
|
$commands[] = "chmod +x {$scriptPath}";
|
||||||
|
$commands[] = "docker cp {$scriptPath} {$this->container}:{$scriptPath}";
|
||||||
|
|
||||||
|
// 9. Execute restore and cleanup temp files immediately after completion
|
||||||
|
$commands[] = "docker exec {$this->container} sh -c '{$scriptPath} && rm -f {$containerTmpPath} {$scriptPath}'";
|
||||||
|
$commands[] = "docker exec {$this->container} sh -c 'echo \"Import finished with exit code $?\"'";
|
||||||
|
|
||||||
|
// Execute all commands with cleanup event (as safety net for edge cases)
|
||||||
|
$activity = remote_process($commands, $this->server, ignore_errors: true, callEventOnFinish: 'S3RestoreJobFinished', callEventData: [
|
||||||
|
'containerName' => $containerName,
|
||||||
|
'serverTmpPath' => $serverTmpPath,
|
||||||
|
'scriptPath' => $scriptPath,
|
||||||
|
'containerTmpPath' => $containerTmpPath,
|
||||||
|
'container' => $this->container,
|
||||||
|
'serverId' => $this->server->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Track the activity ID
|
||||||
|
$this->activityId = $activity->id;
|
||||||
|
|
||||||
|
// Dispatch activity to the monitor and open slide-over
|
||||||
|
$this->dispatch('activityMonitor', $activity->id);
|
||||||
|
$this->dispatch('databaserestore');
|
||||||
|
$this->dispatch('info', 'Restoring database from S3. Progress will be shown in the activity monitor...');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->importRunning = false;
|
||||||
|
|
||||||
|
return handleError($e, $this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function buildRestoreCommand(string $tmpPath): string
|
||||||
|
{
|
||||||
|
switch ($this->resource->getMorphClass()) {
|
||||||
|
case \App\Models\StandaloneMariadb::class:
|
||||||
|
$restoreCommand = $this->mariadbRestoreCommand;
|
||||||
|
if ($this->dumpAll) {
|
||||||
|
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mariadb -u root -p\$MARIADB_ROOT_PASSWORD";
|
||||||
|
} else {
|
||||||
|
$restoreCommand .= " < {$tmpPath}";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case \App\Models\StandaloneMysql::class:
|
||||||
|
$restoreCommand = $this->mysqlRestoreCommand;
|
||||||
|
if ($this->dumpAll) {
|
||||||
|
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | mysql -u root -p\$MYSQL_ROOT_PASSWORD";
|
||||||
|
} else {
|
||||||
|
$restoreCommand .= " < {$tmpPath}";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case \App\Models\StandalonePostgresql::class:
|
||||||
|
$restoreCommand = $this->postgresqlRestoreCommand;
|
||||||
|
if ($this->dumpAll) {
|
||||||
|
$restoreCommand .= " && (gunzip -cf {$tmpPath} 2>/dev/null || cat {$tmpPath}) | psql -U \$POSTGRES_USER postgres";
|
||||||
|
} else {
|
||||||
|
$restoreCommand .= " {$tmpPath}";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case \App\Models\StandaloneMongodb::class:
|
||||||
|
$restoreCommand = $this->mongodbRestoreCommand;
|
||||||
|
if ($this->dumpAll === false) {
|
||||||
|
$restoreCommand .= "{$tmpPath}";
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
$restoreCommand = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $restoreCommand;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -328,12 +328,15 @@ class General extends Component
|
|||||||
$configuration_dir = database_configuration_dir().'/'.$container_name;
|
$configuration_dir = database_configuration_dir().'/'.$container_name;
|
||||||
|
|
||||||
if ($oldScript && $oldScript['filename'] !== $script['filename']) {
|
if ($oldScript && $oldScript['filename'] !== $script['filename']) {
|
||||||
$old_file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$oldScript['filename']}";
|
|
||||||
$delete_command = "rm -f $old_file_path";
|
|
||||||
try {
|
try {
|
||||||
|
// Validate and escape filename to prevent command injection
|
||||||
|
validateShellSafePath($oldScript['filename'], 'init script filename');
|
||||||
|
$old_file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$oldScript['filename']}";
|
||||||
|
$escapedOldPath = escapeshellarg($old_file_path);
|
||||||
|
$delete_command = "rm -f {$escapedOldPath}";
|
||||||
instant_remote_process([$delete_command], $this->server);
|
instant_remote_process([$delete_command], $this->server);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$this->dispatch('error', 'Failed to remove old init script from server: '.$e->getMessage());
|
$this->dispatch('error', $e->getMessage());
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -370,13 +373,17 @@ class General extends Component
|
|||||||
if ($found) {
|
if ($found) {
|
||||||
$container_name = $this->database->uuid;
|
$container_name = $this->database->uuid;
|
||||||
$configuration_dir = database_configuration_dir().'/'.$container_name;
|
$configuration_dir = database_configuration_dir().'/'.$container_name;
|
||||||
$file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$script['filename']}";
|
|
||||||
|
|
||||||
$command = "rm -f $file_path";
|
|
||||||
try {
|
try {
|
||||||
|
// Validate and escape filename to prevent command injection
|
||||||
|
validateShellSafePath($script['filename'], 'init script filename');
|
||||||
|
$file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$script['filename']}";
|
||||||
|
$escapedPath = escapeshellarg($file_path);
|
||||||
|
|
||||||
|
$command = "rm -f {$escapedPath}";
|
||||||
instant_remote_process([$command], $this->server);
|
instant_remote_process([$command], $this->server);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$this->dispatch('error', 'Failed to remove init script from server: '.$e->getMessage());
|
$this->dispatch('error', $e->getMessage());
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -405,6 +412,16 @@ class General extends Component
|
|||||||
'new_filename' => 'required|string',
|
'new_filename' => 'required|string',
|
||||||
'new_content' => 'required|string',
|
'new_content' => 'required|string',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate filename to prevent command injection
|
||||||
|
validateShellSafePath($this->new_filename, 'init script filename');
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$this->dispatch('error', $e->getMessage());
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$found = collect($this->initScripts)->firstWhere('filename', $this->new_filename);
|
$found = collect($this->initScripts)->firstWhere('filename', $this->new_filename);
|
||||||
if ($found) {
|
if ($found) {
|
||||||
$this->dispatch('error', 'Filename already exists.');
|
$this->dispatch('error', 'Filename already exists.');
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ class Index extends Component
|
|||||||
|
|
||||||
public function mount()
|
public function mount()
|
||||||
{
|
{
|
||||||
$this->private_keys = PrivateKey::ownedByCurrentTeam()->get();
|
$this->private_keys = PrivateKey::ownedByCurrentTeamCached();
|
||||||
$this->projects = Project::ownedByCurrentTeam()->get();
|
$this->projects = Project::ownedByCurrentTeamCached();
|
||||||
$this->servers = Server::ownedByCurrentTeam()->count();
|
$this->servers = Server::ownedByCurrentTeamCached();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
|
|||||||
@@ -74,6 +74,9 @@ class DockerCompose extends Component
|
|||||||
}
|
}
|
||||||
$service->parse(isNew: true);
|
$service->parse(isNew: true);
|
||||||
|
|
||||||
|
// Apply service-specific application prerequisites
|
||||||
|
applyServiceApplicationPrerequisites($service);
|
||||||
|
|
||||||
return redirect()->route('project.service.configuration', [
|
return redirect()->route('project.service.configuration', [
|
||||||
'service_uuid' => $service->uuid,
|
'service_uuid' => $service->uuid,
|
||||||
'environment_uuid' => $environment->uuid,
|
'environment_uuid' => $environment->uuid,
|
||||||
|
|||||||
@@ -75,16 +75,6 @@ class GithubPrivateRepository extends Component
|
|||||||
$this->github_apps = GithubApp::private();
|
$this->github_apps = GithubApp::private();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updatedBaseDirectory()
|
|
||||||
{
|
|
||||||
if ($this->base_directory) {
|
|
||||||
$this->base_directory = rtrim($this->base_directory, '/');
|
|
||||||
if (! str($this->base_directory)->startsWith('/')) {
|
|
||||||
$this->base_directory = '/'.$this->base_directory;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updatedBuildPack()
|
public function updatedBuildPack()
|
||||||
{
|
{
|
||||||
if ($this->build_pack === 'nixpacks') {
|
if ($this->build_pack === 'nixpacks') {
|
||||||
@@ -138,6 +128,7 @@ class GithubPrivateRepository extends Component
|
|||||||
$this->loadBranchByPage();
|
$this->loadBranchByPage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
$this->branches = sortBranchesByPriority($this->branches);
|
||||||
$this->selected_branch_name = data_get($this->branches, '0.name', 'main');
|
$this->selected_branch_name = data_get($this->branches, '0.name', 'main');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -107,26 +107,6 @@ class PublicGitRepository extends Component
|
|||||||
$this->query = request()->query();
|
$this->query = request()->query();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updatedBaseDirectory()
|
|
||||||
{
|
|
||||||
if ($this->base_directory) {
|
|
||||||
$this->base_directory = rtrim($this->base_directory, '/');
|
|
||||||
if (! str($this->base_directory)->startsWith('/')) {
|
|
||||||
$this->base_directory = '/'.$this->base_directory;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updatedDockerComposeLocation()
|
|
||||||
{
|
|
||||||
if ($this->docker_compose_location) {
|
|
||||||
$this->docker_compose_location = rtrim($this->docker_compose_location, '/');
|
|
||||||
if (! str($this->docker_compose_location)->startsWith('/')) {
|
|
||||||
$this->docker_compose_location = '/'.$this->docker_compose_location;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updatedBuildPack()
|
public function updatedBuildPack()
|
||||||
{
|
{
|
||||||
if ($this->build_pack === 'nixpacks') {
|
if ($this->build_pack === 'nixpacks') {
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ class Create extends Component
|
|||||||
'destination_id' => $destination->id,
|
'destination_id' => $destination->id,
|
||||||
'destination_type' => $destination->getMorphClass(),
|
'destination_type' => $destination->getMorphClass(),
|
||||||
];
|
];
|
||||||
if ($oneClickServiceName === 'cloudflared' || $oneClickServiceName === 'pgadmin') {
|
if (in_array($oneClickServiceName, NEEDS_TO_CONNECT_TO_PREDEFINED_NETWORK)) {
|
||||||
data_set($service_payload, 'connect_to_docker_network', true);
|
data_set($service_payload, 'connect_to_docker_network', true);
|
||||||
}
|
}
|
||||||
$service = Service::create($service_payload);
|
$service = Service::create($service_payload);
|
||||||
@@ -104,6 +104,9 @@ class Create extends Component
|
|||||||
}
|
}
|
||||||
$service->parse(isNew: true);
|
$service->parse(isNew: true);
|
||||||
|
|
||||||
|
// Apply service-specific application prerequisites
|
||||||
|
applyServiceApplicationPrerequisites($service);
|
||||||
|
|
||||||
return redirect()->route('project.service.configuration', [
|
return redirect()->route('project.service.configuration', [
|
||||||
'service_uuid' => $service->uuid,
|
'service_uuid' => $service->uuid,
|
||||||
'environment_uuid' => $environment->uuid,
|
'environment_uuid' => $environment->uuid,
|
||||||
|
|||||||
@@ -4,12 +4,9 @@ namespace App\Livewire\Project\Service;
|
|||||||
|
|
||||||
use App\Actions\Database\StartDatabaseProxy;
|
use App\Actions\Database\StartDatabaseProxy;
|
||||||
use App\Actions\Database\StopDatabaseProxy;
|
use App\Actions\Database\StopDatabaseProxy;
|
||||||
use App\Models\InstanceSettings;
|
|
||||||
use App\Models\ServiceDatabase;
|
use App\Models\ServiceDatabase;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class Database extends Component
|
class Database extends Component
|
||||||
@@ -96,12 +93,8 @@ class Database extends Component
|
|||||||
try {
|
try {
|
||||||
$this->authorize('delete', $this->database);
|
$this->authorize('delete', $this->database);
|
||||||
|
|
||||||
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
|
if (! verifyPasswordConfirmation($password, $this)) {
|
||||||
if (! Hash::check($password, Auth::user()->password)) {
|
return;
|
||||||
$this->addError('password', 'The provided password is incorrect.');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->database->delete();
|
$this->database->delete();
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
namespace App\Livewire\Project\Service;
|
namespace App\Livewire\Project\Service;
|
||||||
|
|
||||||
use App\Models\Application;
|
use App\Models\Application;
|
||||||
use App\Models\InstanceSettings;
|
|
||||||
use App\Models\LocalFileVolume;
|
use App\Models\LocalFileVolume;
|
||||||
use App\Models\ServiceApplication;
|
use App\Models\ServiceApplication;
|
||||||
use App\Models\ServiceDatabase;
|
use App\Models\ServiceDatabase;
|
||||||
@@ -16,8 +15,6 @@ use App\Models\StandaloneMysql;
|
|||||||
use App\Models\StandalonePostgresql;
|
use App\Models\StandalonePostgresql;
|
||||||
use App\Models\StandaloneRedis;
|
use App\Models\StandaloneRedis;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Livewire\Attributes\Validate;
|
use Livewire\Attributes\Validate;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
@@ -62,7 +59,7 @@ class FileStorage extends Component
|
|||||||
$this->fs_path = $this->fileStorage->fs_path;
|
$this->fs_path = $this->fileStorage->fs_path;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->isReadOnly = $this->fileStorage->isReadOnlyVolume();
|
$this->isReadOnly = $this->fileStorage->shouldBeReadOnlyInUI();
|
||||||
$this->syncData();
|
$this->syncData();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +101,8 @@ class FileStorage extends Component
|
|||||||
public function loadStorageOnServer()
|
public function loadStorageOnServer()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$this->authorize('update', $this->resource);
|
// Loading content is a read operation, so we use 'view' permission
|
||||||
|
$this->authorize('view', $this->resource);
|
||||||
|
|
||||||
$this->fileStorage->loadStorageOnServer();
|
$this->fileStorage->loadStorageOnServer();
|
||||||
$this->syncData();
|
$this->syncData();
|
||||||
@@ -140,12 +138,8 @@ class FileStorage extends Component
|
|||||||
{
|
{
|
||||||
$this->authorize('update', $this->resource);
|
$this->authorize('update', $this->resource);
|
||||||
|
|
||||||
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
|
if (! verifyPasswordConfirmation($password, $this)) {
|
||||||
if (! Hash::check($password, Auth::user()->password)) {
|
return;
|
||||||
$this->addError('password', 'The provided password is incorrect.');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -2,12 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Livewire\Project\Service;
|
namespace App\Livewire\Project\Service;
|
||||||
|
|
||||||
use App\Models\InstanceSettings;
|
|
||||||
use App\Models\ServiceApplication;
|
use App\Models\ServiceApplication;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Livewire\Attributes\Validate;
|
use Livewire\Attributes\Validate;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Spatie\Url\Url;
|
use Spatie\Url\Url;
|
||||||
@@ -82,6 +79,21 @@ class ServiceApplicationView extends Component
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function instantSaveSettings()
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->authorize('update', $this->application);
|
||||||
|
// Save checkbox states without port validation
|
||||||
|
$this->application->is_gzip_enabled = $this->isGzipEnabled;
|
||||||
|
$this->application->is_stripprefix_enabled = $this->isStripprefixEnabled;
|
||||||
|
$this->application->exclude_from_status = $this->excludeFromStatus;
|
||||||
|
$this->application->save();
|
||||||
|
$this->dispatch('success', 'Settings saved.');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return handleError($e, $this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function instantSaveAdvanced()
|
public function instantSaveAdvanced()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
@@ -113,12 +125,8 @@ class ServiceApplicationView extends Component
|
|||||||
try {
|
try {
|
||||||
$this->authorize('delete', $this->application);
|
$this->authorize('delete', $this->application);
|
||||||
|
|
||||||
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
|
if (! verifyPasswordConfirmation($password, $this)) {
|
||||||
if (! Hash::check($password, Auth::user()->password)) {
|
return;
|
||||||
$this->addError('password', 'The provided password is incorrect.');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->application->delete();
|
$this->application->delete();
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ class Storage extends Component
|
|||||||
public function refreshStorages()
|
public function refreshStorages()
|
||||||
{
|
{
|
||||||
$this->fileStorage = $this->resource->fileStorages()->get();
|
$this->fileStorage = $this->resource->fileStorages()->get();
|
||||||
$this->resource->refresh();
|
$this->resource->load('persistentStorages.resource');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getFilesProperty()
|
public function getFilesProperty()
|
||||||
@@ -179,6 +179,10 @@ class Storage extends Component
|
|||||||
$this->file_storage_directory_destination = trim($this->file_storage_directory_destination);
|
$this->file_storage_directory_destination = trim($this->file_storage_directory_destination);
|
||||||
$this->file_storage_directory_destination = str($this->file_storage_directory_destination)->start('/')->value();
|
$this->file_storage_directory_destination = str($this->file_storage_directory_destination)->start('/')->value();
|
||||||
|
|
||||||
|
// Validate paths to prevent command injection
|
||||||
|
validateShellSafePath($this->file_storage_directory_source, 'storage source path');
|
||||||
|
validateShellSafePath($this->file_storage_directory_destination, 'storage destination path');
|
||||||
|
|
||||||
\App\Models\LocalFileVolume::create([
|
\App\Models\LocalFileVolume::create([
|
||||||
'fs_path' => $this->file_storage_directory_source,
|
'fs_path' => $this->file_storage_directory_source,
|
||||||
'mount_path' => $this->file_storage_directory_destination,
|
'mount_path' => $this->file_storage_directory_destination,
|
||||||
|
|||||||
@@ -3,13 +3,10 @@
|
|||||||
namespace App\Livewire\Project\Shared;
|
namespace App\Livewire\Project\Shared;
|
||||||
|
|
||||||
use App\Jobs\DeleteResourceJob;
|
use App\Jobs\DeleteResourceJob;
|
||||||
use App\Models\InstanceSettings;
|
|
||||||
use App\Models\Service;
|
use App\Models\Service;
|
||||||
use App\Models\ServiceApplication;
|
use App\Models\ServiceApplication;
|
||||||
use App\Models\ServiceDatabase;
|
use App\Models\ServiceDatabase;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Visus\Cuid2\Cuid2;
|
use Visus\Cuid2\Cuid2;
|
||||||
|
|
||||||
@@ -93,12 +90,8 @@ class Danger extends Component
|
|||||||
|
|
||||||
public function delete($password)
|
public function delete($password)
|
||||||
{
|
{
|
||||||
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
|
if (! verifyPasswordConfirmation($password, $this)) {
|
||||||
if (! Hash::check($password, Auth::user()->password)) {
|
return;
|
||||||
$this->addError('password', 'The provided password is incorrect.');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $this->resource) {
|
if (! $this->resource) {
|
||||||
|
|||||||
@@ -5,12 +5,9 @@ namespace App\Livewire\Project\Shared;
|
|||||||
use App\Actions\Application\StopApplicationOneServer;
|
use App\Actions\Application\StopApplicationOneServer;
|
||||||
use App\Actions\Docker\GetContainersStatus;
|
use App\Actions\Docker\GetContainersStatus;
|
||||||
use App\Events\ApplicationStatusChanged;
|
use App\Events\ApplicationStatusChanged;
|
||||||
use App\Models\InstanceSettings;
|
|
||||||
use App\Models\Server;
|
use App\Models\Server;
|
||||||
use App\Models\StandaloneDocker;
|
use App\Models\StandaloneDocker;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Auth;
|
|
||||||
use Illuminate\Support\Facades\Hash;
|
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Visus\Cuid2\Cuid2;
|
use Visus\Cuid2\Cuid2;
|
||||||
|
|
||||||
@@ -89,6 +86,11 @@ class Destination extends Component
|
|||||||
only_this_server: true,
|
only_this_server: true,
|
||||||
no_questions_asked: true,
|
no_questions_asked: true,
|
||||||
);
|
);
|
||||||
|
if ($result['status'] === 'queue_full') {
|
||||||
|
$this->dispatch('error', 'Deployment queue full', $result['message']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
if ($result['status'] === 'skipped') {
|
if ($result['status'] === 'skipped') {
|
||||||
$this->dispatch('success', 'Deployment skipped', $result['message']);
|
$this->dispatch('success', 'Deployment skipped', $result['message']);
|
||||||
|
|
||||||
@@ -135,12 +137,8 @@ class Destination extends Component
|
|||||||
public function removeServer(int $network_id, int $server_id, $password)
|
public function removeServer(int $network_id, int $server_id, $password)
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
|
if (! verifyPasswordConfirmation($password, $this)) {
|
||||||
if (! Hash::check($password, Auth::user()->password)) {
|
return;
|
||||||
$this->addError('password', 'The provided password is incorrect.');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->resource->destination->server->id == $server_id && $this->resource->destination->id == $network_id) {
|
if ($this->resource->destination->server->id == $server_id && $this->resource->destination->id == $network_id) {
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
|
|
||||||
namespace App\Livewire\Project\Shared\EnvironmentVariable;
|
namespace App\Livewire\Project\Shared\EnvironmentVariable;
|
||||||
|
|
||||||
|
use App\Models\Environment;
|
||||||
|
use App\Models\Project;
|
||||||
use App\Traits\EnvironmentVariableAnalyzer;
|
use App\Traits\EnvironmentVariableAnalyzer;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
|
use Livewire\Attributes\Computed;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class Add extends Component
|
class Add extends Component
|
||||||
@@ -56,6 +59,72 @@ class Add extends Component
|
|||||||
$this->problematicVariables = self::getProblematicVariablesForFrontend();
|
$this->problematicVariables = self::getProblematicVariablesForFrontend();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function availableSharedVariables(): array
|
||||||
|
{
|
||||||
|
$team = currentTeam();
|
||||||
|
$result = [
|
||||||
|
'team' => [],
|
||||||
|
'project' => [],
|
||||||
|
'environment' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Early return if no team
|
||||||
|
if (! $team) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user can view team variables
|
||||||
|
try {
|
||||||
|
$this->authorize('view', $team);
|
||||||
|
$result['team'] = $team->environment_variables()
|
||||||
|
->pluck('key')
|
||||||
|
->toArray();
|
||||||
|
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||||
|
// User not authorized to view team variables
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get project variables if we have a project_uuid in route
|
||||||
|
$projectUuid = data_get($this->parameters, 'project_uuid');
|
||||||
|
if ($projectUuid) {
|
||||||
|
$project = Project::where('team_id', $team->id)
|
||||||
|
->where('uuid', $projectUuid)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($project) {
|
||||||
|
try {
|
||||||
|
$this->authorize('view', $project);
|
||||||
|
$result['project'] = $project->environment_variables()
|
||||||
|
->pluck('key')
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
// Get environment variables if we have an environment_uuid in route
|
||||||
|
$environmentUuid = data_get($this->parameters, 'environment_uuid');
|
||||||
|
if ($environmentUuid) {
|
||||||
|
$environment = $project->environments()
|
||||||
|
->where('uuid', $environmentUuid)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($environment) {
|
||||||
|
try {
|
||||||
|
$this->authorize('view', $environment);
|
||||||
|
$result['environment'] = $environment->environment_variables()
|
||||||
|
->pluck('key')
|
||||||
|
->toArray();
|
||||||
|
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||||
|
// User not authorized to view environment variables
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||||
|
// User not authorized to view project variables
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
public function submit()
|
public function submit()
|
||||||
{
|
{
|
||||||
$this->validate();
|
$this->validate();
|
||||||
|
|||||||
@@ -2,11 +2,14 @@
|
|||||||
|
|
||||||
namespace App\Livewire\Project\Shared\EnvironmentVariable;
|
namespace App\Livewire\Project\Shared\EnvironmentVariable;
|
||||||
|
|
||||||
|
use App\Models\Environment;
|
||||||
use App\Models\EnvironmentVariable as ModelsEnvironmentVariable;
|
use App\Models\EnvironmentVariable as ModelsEnvironmentVariable;
|
||||||
|
use App\Models\Project;
|
||||||
use App\Models\SharedEnvironmentVariable;
|
use App\Models\SharedEnvironmentVariable;
|
||||||
use App\Traits\EnvironmentVariableAnalyzer;
|
use App\Traits\EnvironmentVariableAnalyzer;
|
||||||
use App\Traits\EnvironmentVariableProtection;
|
use App\Traits\EnvironmentVariableProtection;
|
||||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
|
use Livewire\Attributes\Computed;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
|
|
||||||
class Show extends Component
|
class Show extends Component
|
||||||
@@ -184,6 +187,7 @@ class Show extends Component
|
|||||||
|
|
||||||
$this->serialize();
|
$this->serialize();
|
||||||
$this->syncData(true);
|
$this->syncData(true);
|
||||||
|
$this->syncData(false);
|
||||||
$this->dispatch('success', 'Environment variable updated.');
|
$this->dispatch('success', 'Environment variable updated.');
|
||||||
$this->dispatch('envsUpdated');
|
$this->dispatch('envsUpdated');
|
||||||
$this->dispatch('configurationChanged');
|
$this->dispatch('configurationChanged');
|
||||||
@@ -192,6 +196,72 @@ class Show extends Component
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[Computed]
|
||||||
|
public function availableSharedVariables(): array
|
||||||
|
{
|
||||||
|
$team = currentTeam();
|
||||||
|
$result = [
|
||||||
|
'team' => [],
|
||||||
|
'project' => [],
|
||||||
|
'environment' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Early return if no team
|
||||||
|
if (! $team) {
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user can view team variables
|
||||||
|
try {
|
||||||
|
$this->authorize('view', $team);
|
||||||
|
$result['team'] = $team->environment_variables()
|
||||||
|
->pluck('key')
|
||||||
|
->toArray();
|
||||||
|
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||||
|
// User not authorized to view team variables
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get project variables if we have a project_uuid in route
|
||||||
|
$projectUuid = data_get($this->parameters, 'project_uuid');
|
||||||
|
if ($projectUuid) {
|
||||||
|
$project = Project::where('team_id', $team->id)
|
||||||
|
->where('uuid', $projectUuid)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($project) {
|
||||||
|
try {
|
||||||
|
$this->authorize('view', $project);
|
||||||
|
$result['project'] = $project->environment_variables()
|
||||||
|
->pluck('key')
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
// Get environment variables if we have an environment_uuid in route
|
||||||
|
$environmentUuid = data_get($this->parameters, 'environment_uuid');
|
||||||
|
if ($environmentUuid) {
|
||||||
|
$environment = $project->environments()
|
||||||
|
->where('uuid', $environmentUuid)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($environment) {
|
||||||
|
try {
|
||||||
|
$this->authorize('view', $environment);
|
||||||
|
$result['environment'] = $environment->environment_variables()
|
||||||
|
->pluck('key')
|
||||||
|
->toArray();
|
||||||
|
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||||
|
// User not authorized to view environment variables
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Illuminate\Auth\Access\AuthorizationException $e) {
|
||||||
|
// User not authorized to view project variables
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
public function delete()
|
public function delete()
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user