Merge branch 'next' into v4.x
This commit is contained in:
commit
16b720bb16
381 changed files with 27944 additions and 2633 deletions
|
|
@ -283,14 +283,22 @@ ### **Polymorphic Relationships**
|
|||
|
||||
### **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
|
||||
// Automatic team filtering
|
||||
$applications = Application::ownedByCurrentTeam()->get();
|
||||
$servers = Server::ownedByCurrentTeam()->get();
|
||||
// ✅ CORRECT - Use cached methods (request-level cache via once())
|
||||
$applications = Application::ownedByCurrentTeamCached();
|
||||
$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**
|
||||
|
||||
Environment variables cascade from:
|
||||
|
|
|
|||
|
|
@ -270,6 +270,84 @@ ### Build Optimization
|
|||
- **Build artifact** reuse
|
||||
- **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
|
||||
- **Container resource** limits
|
||||
- **Auto-scaling** based on metrics
|
||||
|
|
@ -428,7 +506,7 @@ #### `content`
|
|||
- `templates/compose/chaskiq.yaml` - Entrypoint script
|
||||
|
||||
**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`
|
||||
- Validation: `tests/Unit/StripCoolifyCustomFieldsTest.php`
|
||||
|
||||
|
|
@ -481,7 +559,7 @@ #### `is_directory` / `isDirectory`
|
|||
- Pre-creating mount points before container starts
|
||||
|
||||
**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)
|
||||
- Validation: `tests/Unit/StripCoolifyCustomFieldsTest.php`
|
||||
|
||||
|
|
|
|||
|
|
@ -243,6 +243,59 @@ ### Database Indexes
|
|||
- **Composite indexes** for common queries
|
||||
- **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
|
||||
|
||||
### 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
|
||||
with:
|
||||
registry: ${{ env.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Get Version
|
||||
id: version
|
||||
|
|
@ -86,8 +86,8 @@ jobs:
|
|||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Get 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
|
||||
with:
|
||||
registry: ${{ env.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Get Version
|
||||
id: version
|
||||
|
|
@ -85,8 +85,8 @@ jobs:
|
|||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Get Version
|
||||
id: version
|
||||
|
|
|
|||
|
|
@ -51,8 +51,8 @@ jobs:
|
|||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Get Version
|
||||
id: version
|
||||
|
|
@ -91,8 +91,8 @@ jobs:
|
|||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Get 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
|
||||
with:
|
||||
registry: ${{ env.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Get Version
|
||||
id: version
|
||||
|
|
@ -90,8 +90,8 @@ jobs:
|
|||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Get 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
|
||||
with:
|
||||
registry: ${{ env.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Get Version
|
||||
id: version
|
||||
|
|
@ -90,8 +90,8 @@ jobs:
|
|||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Get 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
|
||||
with:
|
||||
registry: ${{ env.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and Push Image (${{ matrix.arch }})
|
||||
uses: docker/build-push-action@v6
|
||||
|
|
@ -110,8 +110,8 @@ jobs:
|
|||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
|
||||
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
|
||||
with:
|
||||
registry: ${{ env.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and Push Image (${{ matrix.arch }})
|
||||
uses: docker/build-push-action@v6
|
||||
|
|
@ -81,8 +81,8 @@ jobs:
|
|||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.DOCKER_REGISTRY }}
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Create & publish manifest on ${{ env.GITHUB_REGISTRY }}
|
||||
run: |
|
||||
|
|
|
|||
8446
CHANGELOG.md
8446
CHANGELOG.md
File diff suppressed because it is too large
Load diff
|
|
@ -222,6 +222,7 @@ ### Performance Considerations
|
|||
- Queue heavy operations
|
||||
- Optimize database queries with proper indexes
|
||||
- Use chunking for large data operations
|
||||
- **CRITICAL**: Use `ownedByCurrentTeamCached()` instead of `ownedByCurrentTeam()->get()`
|
||||
|
||||
### Code Style
|
||||
- Follow PSR-12 coding standards
|
||||
|
|
@ -317,4 +318,5 @@ ### Livewire & Frontend
|
|||
|
||||
|
||||
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 @@ public function handle(Application $application, bool $previewDeployments = fals
|
|||
|
||||
foreach ($containersToStop as $containerName) {
|
||||
instant_remote_process(command: [
|
||||
"docker stop --time=30 $containerName",
|
||||
"docker stop -t 30 $containerName",
|
||||
"docker rm -f $containerName",
|
||||
], server: $server, throwError: false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ public function handle(Application $application, Server $server)
|
|||
if ($containerName) {
|
||||
instant_remote_process(
|
||||
[
|
||||
"docker stop --time=30 $containerName",
|
||||
"docker stop -t 30 $containerName",
|
||||
"docker rm -f $containerName",
|
||||
],
|
||||
$server
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ public function handle(StandaloneClickhouse $database)
|
|||
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
|
||||
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
||||
$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 compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
|
|
|
|||
|
|
@ -192,7 +192,7 @@ public function handle(StandaloneDragonfly $database)
|
|||
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[] = "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 compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
|
|
|
|||
|
|
@ -208,7 +208,7 @@ public function handle(StandaloneKeydb $database)
|
|||
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[] = "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 compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
|
|
|
|||
|
|
@ -209,7 +209,7 @@ public function handle(StandaloneMariadb $database)
|
|||
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
|
||||
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
||||
$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 compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
|
|
|
|||
|
|
@ -260,7 +260,7 @@ public function handle(StandaloneMongodb $database)
|
|||
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
|
||||
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
||||
$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 compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
if ($this->database->enable_ssl) {
|
||||
|
|
|
|||
|
|
@ -210,7 +210,7 @@ public function handle(StandaloneMysql $database)
|
|||
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
|
||||
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
||||
$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 compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
|
||||
|
|
|
|||
|
|
@ -223,7 +223,7 @@ public function handle(StandalonePostgresql $database)
|
|||
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
|
||||
$this->commands[] = "echo 'Pulling {$database->image} image.'";
|
||||
$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 compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
if ($this->database->enable_ssl) {
|
||||
|
|
|
|||
|
|
@ -205,7 +205,7 @@ public function handle(StandaloneRedis $database)
|
|||
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[] = "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 compose -f $this->configuration_dir/docker-compose.yml up -d";
|
||||
$this->commands[] = "echo 'Database started.'";
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ private function stopContainer($database, string $containerName, int $timeout =
|
|||
{
|
||||
$server = $database->destination->server;
|
||||
instant_remote_process(command: [
|
||||
"docker stop --time=$timeout $containerName",
|
||||
"docker stop -t $timeout $containerName",
|
||||
"docker rm -f $containerName",
|
||||
], server: $server, throwError: false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -461,9 +461,10 @@ private function aggregateApplicationStatus($application, Collection $containerS
|
|||
}
|
||||
|
||||
// Use ContainerStatusAggregator service for state machine logic
|
||||
// Use preserveRestarting: true so applications show "Restarting" instead of "Degraded"
|
||||
$aggregator = new ContainerStatusAggregator;
|
||||
|
||||
return $aggregator->aggregateFromStrings($relevantStatuses, $maxRestartCount);
|
||||
return $aggregator->aggregateFromStrings($relevantStatuses, $maxRestartCount, preserveRestarting: true);
|
||||
}
|
||||
|
||||
private function aggregateServiceContainerStatuses($services)
|
||||
|
|
@ -518,8 +519,9 @@ private function aggregateServiceContainerStatuses($services)
|
|||
}
|
||||
|
||||
// Use ContainerStatusAggregator service for state machine logic
|
||||
// Use preserveRestarting: true so individual sub-resources show "Restarting" instead of "Degraded"
|
||||
$aggregator = new ContainerStatusAggregator;
|
||||
$aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses);
|
||||
$aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, preserveRestarting: true);
|
||||
|
||||
// Update service sub-resource status with aggregated result
|
||||
if ($aggregatedStatus) {
|
||||
|
|
|
|||
|
|
@ -75,6 +75,10 @@ public function handle(Server $server, bool $async = true, bool $force = false,
|
|||
' done',
|
||||
" echo 'Successfully stopped and removed existing coolify-proxy.'",
|
||||
'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.'",
|
||||
'docker compose up -d --wait --remove-orphans',
|
||||
"echo 'Successfully started coolify-proxy.'",
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ public function handle(Server $server, bool $forceStop = true, int $timeout = 30
|
|||
}
|
||||
|
||||
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",
|
||||
'# Wait for container to be fully removed',
|
||||
'for i in {1..10}; do',
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ class CheckUpdates
|
|||
|
||||
public function handle(Server $server)
|
||||
{
|
||||
$osId = 'unknown';
|
||||
$packageManager = null;
|
||||
|
||||
try {
|
||||
if ($server->serverStatus() === false) {
|
||||
return [
|
||||
|
|
@ -93,6 +96,16 @@ public function handle(Server $server)
|
|||
$out['osId'] = $osId;
|
||||
$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;
|
||||
default:
|
||||
return [
|
||||
|
|
@ -219,4 +232,45 @@ private function parseAptOutput(string $output): array
|
|||
'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)
|
||||
{
|
||||
$settings = instanceSettings();
|
||||
$realtimeImage = config('constants.coolify.realtime_image');
|
||||
$realtimeImageVersion = config('constants.coolify.realtime_version');
|
||||
$realtimeImageWithVersion = "$realtimeImage:$realtimeImageVersion";
|
||||
|
|
@ -26,9 +25,31 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $
|
|||
$helperImageWithoutPrefix = 'coollabsio/coolify-helper';
|
||||
$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 = [
|
||||
'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 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",
|
||||
|
|
@ -44,7 +65,6 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $
|
|||
$commands[] = 'docker network prune -f';
|
||||
}
|
||||
|
||||
$cleanupLog = [];
|
||||
foreach ($commands as $command) {
|
||||
$commandOutput = instant_remote_process([$command], $server, false);
|
||||
if ($commandOutput !== null) {
|
||||
|
|
@ -57,4 +77,140 @@ public function handle(Server $server, bool $deleteUnusedVolumes = false, bool $
|
|||
|
||||
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 @@ public function handle(Server $server)
|
|||
$command = $command->merge([$this->getRhelDockerInstallCommand()]);
|
||||
} elseif ($supported_os_type->contains('sles')) {
|
||||
$command = $command->merge([$this->getSuseDockerInstallCommand()]);
|
||||
} elseif ($supported_os_type->contains('arch')) {
|
||||
$command = $command->merge([$this->getArchDockerInstallCommand()]);
|
||||
} else {
|
||||
$command = $command->merge([$this->getGenericDockerInstallCommand()]);
|
||||
}
|
||||
|
|
@ -146,8 +148,19 @@ private function getSuseDockerInstallCommand(): string
|
|||
')';
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
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 @@ public function handle(Server $server)
|
|||
'command -v git >/dev/null || zypper install -y git',
|
||||
'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 {
|
||||
throw new \Exception('Unsupported OS type for prerequisites installation');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
namespace App\Actions\Server;
|
||||
|
||||
use App\Models\Server;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Sleep;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
|
||||
|
|
@ -28,8 +30,59 @@ public function handle($manual_update = false)
|
|||
if (! $this->server) {
|
||||
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');
|
||||
if (! $manual_update) {
|
||||
if (! $settings->is_auto_update_enabled) {
|
||||
|
|
@ -42,6 +95,20 @@ public function handle($manual_update = false)
|
|||
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();
|
||||
$settings->new_version_available = false;
|
||||
$settings->save();
|
||||
|
|
@ -49,16 +116,12 @@ public function handle($manual_update = false)
|
|||
|
||||
private function update()
|
||||
{
|
||||
$helperImage = config('constants.coolify.helper_image');
|
||||
$latest_version = getHelperVersion();
|
||||
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);
|
||||
$latestHelperImageVersion = getHelperVersion();
|
||||
$upgradeScriptUrl = config('constants.coolify.upgrade_script_url');
|
||||
|
||||
remote_process([
|
||||
'curl -fsSL https://cdn.coollabs.io/coolify/upgrade.sh -o /data/coolify/source/upgrade.sh',
|
||||
"bash /data/coolify/source/upgrade.sh $this->latestVersion",
|
||||
"curl -fsSL {$upgradeScriptUrl} -o /data/coolify/source/upgrade.sh",
|
||||
"bash /data/coolify/source/upgrade.sh $this->latestVersion $latestHelperImageVersion",
|
||||
], $this->server);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,18 +20,43 @@ public function handle(Server $server, string $osId, ?string $package = null, ?s
|
|||
'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) {
|
||||
case 'zypper':
|
||||
$commandAll = 'zypper update -y';
|
||||
$commandInstall = 'zypper install -y '.$package;
|
||||
$commandInstall = 'zypper install -y '.$sanitizedPackage;
|
||||
break;
|
||||
case 'dnf':
|
||||
$commandAll = 'dnf update -y';
|
||||
$commandInstall = 'dnf update -y '.$package;
|
||||
$commandInstall = 'dnf update -y '.$sanitizedPackage;
|
||||
break;
|
||||
case 'apt':
|
||||
$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;
|
||||
default:
|
||||
return [
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@
|
|||
namespace App\Actions\Service;
|
||||
|
||||
use App\Actions\Server\CleanupDocker;
|
||||
use App\Enums\ProcessStatus;
|
||||
use App\Events\ServiceStatusChanged;
|
||||
use App\Models\Server;
|
||||
use App\Models\Service;
|
||||
use Lorisleiva\Actions\Concerns\AsAction;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
class StopService
|
||||
{
|
||||
|
|
@ -17,6 +19,17 @@ class StopService
|
|||
public function handle(Service $service, bool $deleteConnectedNetworks = false, bool $dockerCleanup = true)
|
||||
{
|
||||
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;
|
||||
if (! $server->isFunctional()) {
|
||||
return 'Server is not functional';
|
||||
|
|
@ -54,7 +67,7 @@ private function stopContainersInParallel(array $containersToStop, Server $serve
|
|||
$timeout = count($containersToStop) > 5 ? 10 : 30;
|
||||
$commands = [];
|
||||
$containerList = implode(' ', $containersToStop);
|
||||
$commands[] = "docker stop --time=$timeout $containerList";
|
||||
$commands[] = "docker stop -t $timeout $containerList";
|
||||
$commands[] = "docker rm -f $containerList";
|
||||
instant_remote_process(
|
||||
command: $commands,
|
||||
|
|
|
|||
|
|
@ -63,8 +63,6 @@ class CleanupNames extends Command
|
|||
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('🔍 Scanning for invalid characters in name fields...');
|
||||
|
||||
if ($this->option('backup') && ! $this->option('dry-run')) {
|
||||
$this->createBackup();
|
||||
}
|
||||
|
|
@ -75,7 +73,7 @@ public function handle(): int
|
|||
: $this->modelsToClean;
|
||||
|
||||
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)));
|
||||
|
||||
return self::FAILURE;
|
||||
|
|
@ -88,19 +86,21 @@ public function handle(): int
|
|||
$this->processModel($modelName, $modelClass);
|
||||
}
|
||||
|
||||
$this->displaySummary();
|
||||
|
||||
if (! $this->option('dry-run') && $this->totalCleaned > 0) {
|
||||
$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;
|
||||
}
|
||||
|
||||
protected function processModel(string $modelName, string $modelClass): void
|
||||
{
|
||||
$this->info("\n📋 Processing {$modelName}...");
|
||||
|
||||
try {
|
||||
$records = $modelClass::all(['id', 'name']);
|
||||
$cleaned = 0;
|
||||
|
|
@ -128,21 +128,17 @@ protected function processModel(string $modelName, string $modelClass): void
|
|||
$cleaned++;
|
||||
$this->totalCleaned++;
|
||||
|
||||
$this->warn(" 🧹 {$modelName} #{$record->id}:");
|
||||
$this->line(' From: '.$this->truncate($originalName, 80));
|
||||
$this->line(' To: '.$this->truncate($sanitizedName, 80));
|
||||
// Only log in dry-run mode to preview changes
|
||||
if ($this->option('dry-run')) {
|
||||
$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) {
|
||||
$this->error(" ❌ Error processing {$modelName}: ".$e->getMessage());
|
||||
$this->error("Error processing {$modelName}: ".$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -165,28 +161,6 @@ protected function sanitizeName(string $name): string
|
|||
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
|
||||
{
|
||||
$logFile = storage_path('logs/name-cleanup.log');
|
||||
|
|
@ -208,8 +182,6 @@ protected function logChanges(): void
|
|||
|
||||
protected function createBackup(): void
|
||||
{
|
||||
$this->info('💾 Creating database backup...');
|
||||
|
||||
try {
|
||||
$backupFile = storage_path('backups/name-cleanup-backup-'.now()->format('Y-m-d-H-i-s').'.sql');
|
||||
|
||||
|
|
@ -229,15 +201,9 @@ protected function createBackup(): void
|
|||
);
|
||||
|
||||
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) {
|
||||
$this->warn('⚠️ Could not create backup: '.$e->getMessage());
|
||||
$this->warn('Proceeding without backup...');
|
||||
// Log failure but continue - backup is optional safeguard
|
||||
Log::warning('Name cleanup backup failed', ['error' => $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,10 +18,6 @@ public function handle()
|
|||
$dryRun = $this->option('dry-run');
|
||||
$skipOverlapping = $this->option('skip-overlapping');
|
||||
|
||||
if ($dryRun) {
|
||||
$this->info('DRY RUN MODE - No data will be deleted');
|
||||
}
|
||||
|
||||
$deletedCount = 0;
|
||||
$totalKeys = 0;
|
||||
|
||||
|
|
@ -29,8 +25,6 @@ public function handle()
|
|||
$keys = $redis->keys('*');
|
||||
$totalKeys = count($keys);
|
||||
|
||||
$this->info("Scanning {$totalKeys} keys for cleanup...");
|
||||
|
||||
foreach ($keys as $key) {
|
||||
$keyWithoutPrefix = str_replace($prefix, '', $key);
|
||||
$type = $redis->command('type', [$keyWithoutPrefix]);
|
||||
|
|
@ -51,14 +45,12 @@ public function handle()
|
|||
|
||||
// Clean up overlapping queues if not skipped
|
||||
if (! $skipOverlapping) {
|
||||
$this->info('Cleaning up overlapping queues...');
|
||||
$overlappingCleaned = $this->cleanupOverlappingQueues($redis, $prefix, $dryRun);
|
||||
$deletedCount += $overlappingCleaned;
|
||||
}
|
||||
|
||||
// Clean up stale cache locks (WithoutOverlapping middleware)
|
||||
if ($this->option('clear-locks')) {
|
||||
$this->info('Cleaning up stale cache locks...');
|
||||
$locksCleaned = $this->cleanupCacheLocks($dryRun);
|
||||
$deletedCount += $locksCleaned;
|
||||
}
|
||||
|
|
@ -66,15 +58,14 @@ public function handle()
|
|||
// Clean up stuck jobs (restart mode = aggressive, runtime mode = conservative)
|
||||
$isRestart = $this->option('restart');
|
||||
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);
|
||||
$deletedCount += $jobsCleaned;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$this->info("DRY RUN: Would delete {$deletedCount} out of {$totalKeys} keys");
|
||||
$this->info("Redis cleanup: would delete {$deletedCount} items");
|
||||
} else {
|
||||
$this->info("Deleted {$deletedCount} out of {$totalKeys} keys");
|
||||
$this->info("Redis cleanup: deleted {$deletedCount} items");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -85,11 +76,8 @@ private function shouldDeleteHashKey($redis, $keyWithoutPrefix, $dryRun)
|
|||
|
||||
// Delete completed and failed jobs
|
||||
if (in_array($status, ['completed', 'failed'])) {
|
||||
if ($dryRun) {
|
||||
$this->line("Would delete job: {$keyWithoutPrefix} (status: {$status})");
|
||||
} else {
|
||||
if (! $dryRun) {
|
||||
$redis->command('del', [$keyWithoutPrefix]);
|
||||
$this->line("Deleted job: {$keyWithoutPrefix} (status: {$status})");
|
||||
}
|
||||
|
||||
return true;
|
||||
|
|
@ -115,11 +103,8 @@ private function shouldDeleteOtherKey($redis, $keyWithoutPrefix, $fullKey, $dryR
|
|||
|
||||
foreach ($patterns as $pattern => $description) {
|
||||
if (str_contains($keyWithoutPrefix, $pattern)) {
|
||||
if ($dryRun) {
|
||||
$this->line("Would delete {$description}: {$keyWithoutPrefix}");
|
||||
} else {
|
||||
if (! $dryRun) {
|
||||
$redis->command('del', [$keyWithoutPrefix]);
|
||||
$this->line("Deleted {$description}: {$keyWithoutPrefix}");
|
||||
}
|
||||
|
||||
return true;
|
||||
|
|
@ -132,11 +117,8 @@ private function shouldDeleteOtherKey($redis, $keyWithoutPrefix, $fullKey, $dryR
|
|||
$weekAgo = now()->subDays(7)->timestamp;
|
||||
|
||||
if ($timestamp < $weekAgo) {
|
||||
if ($dryRun) {
|
||||
$this->line("Would delete old timestamped data: {$keyWithoutPrefix}");
|
||||
} else {
|
||||
if (! $dryRun) {
|
||||
$redis->command('del', [$keyWithoutPrefix]);
|
||||
$this->line("Deleted old timestamped data: {$keyWithoutPrefix}");
|
||||
}
|
||||
|
||||
return true;
|
||||
|
|
@ -160,8 +142,6 @@ private function cleanupOverlappingQueues($redis, $prefix, $dryRun)
|
|||
}
|
||||
}
|
||||
|
||||
$this->info('Found '.count($queueKeys).' queue-related keys');
|
||||
|
||||
// Group queues by name pattern to find duplicates
|
||||
$queueGroups = [];
|
||||
foreach ($queueKeys as $queueKey) {
|
||||
|
|
@ -193,7 +173,6 @@ private function cleanupOverlappingQueues($redis, $prefix, $dryRun)
|
|||
private function deduplicateQueueGroup($redis, $baseName, $keys, $dryRun)
|
||||
{
|
||||
$cleanedCount = 0;
|
||||
$this->line("Processing queue group: {$baseName} (".count($keys).' keys)');
|
||||
|
||||
// Sort keys to keep the most recent one
|
||||
usort($keys, function ($a, $b) {
|
||||
|
|
@ -244,11 +223,8 @@ private function deduplicateQueueGroup($redis, $baseName, $keys, $dryRun)
|
|||
}
|
||||
|
||||
if ($shouldDelete) {
|
||||
if ($dryRun) {
|
||||
$this->line(" Would delete empty queue: {$redundantKey}");
|
||||
} else {
|
||||
if (! $dryRun) {
|
||||
$redis->command('del', [$redundantKey]);
|
||||
$this->line(" Deleted empty queue: {$redundantKey}");
|
||||
}
|
||||
$cleanedCount++;
|
||||
}
|
||||
|
|
@ -271,15 +247,12 @@ private function deduplicateQueueContents($redis, $queueKey, $dryRun)
|
|||
if (count($uniqueItems) < count($items)) {
|
||||
$duplicates = count($items) - count($uniqueItems);
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(" Would remove {$duplicates} duplicate jobs from queue: {$queueKey}");
|
||||
} else {
|
||||
if (! $dryRun) {
|
||||
// Rebuild the list with unique items
|
||||
$redis->command('del', [$queueKey]);
|
||||
foreach (array_reverse($uniqueItems) as $item) {
|
||||
$redis->command('lpush', [$queueKey, $item]);
|
||||
}
|
||||
$this->line(" Removed {$duplicates} duplicate jobs from queue: {$queueKey}");
|
||||
}
|
||||
$cleanedCount += $duplicates;
|
||||
}
|
||||
|
|
@ -307,13 +280,9 @@ private function cleanupCacheLocks(bool $dryRun): int
|
|||
}
|
||||
}
|
||||
if (empty($lockKeys)) {
|
||||
$this->info(' No cache locks found.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
$this->info(' Found '.count($lockKeys).' cache lock(s)');
|
||||
|
||||
foreach ($lockKeys as $lockKey) {
|
||||
// Check TTL to identify stale locks
|
||||
$ttl = $redis->ttl($lockKey);
|
||||
|
|
@ -326,18 +295,11 @@ private function cleanupCacheLocks(bool $dryRun): int
|
|||
$this->warn(" Would delete STALE lock (no expiration): {$lockKey}");
|
||||
} else {
|
||||
$redis->del($lockKey);
|
||||
$this->info(" ✓ Deleted STALE lock: {$lockKey}");
|
||||
}
|
||||
$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;
|
||||
}
|
||||
|
||||
|
|
@ -453,17 +415,11 @@ private function cleanupStuckJobs($redis, string $prefix, bool $dryRun, bool $is
|
|||
$redis->command('hset', [$keyWithoutPrefix, 'status', 'failed']);
|
||||
$redis->command('hset', [$keyWithoutPrefix, 'failed_at', $now]);
|
||||
$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++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($cleanedCount === 0) {
|
||||
$this->info($isRestart ? ' No jobs to clean up' : ' No stuck jobs found (all jobs running normally)');
|
||||
}
|
||||
|
||||
return $cleanedCount;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ private function showHelp()
|
|||
php artisan app:demo-notify {channel}
|
||||
</p>
|
||||
<div class="my-1">
|
||||
<div class="text-yellow-500"> Channels: </div>
|
||||
<div class="text-warning-500"> Channels: </div>
|
||||
<ul class="text-coolify">
|
||||
<li>email</li>
|
||||
<li>discord</li>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ class SyncBunny extends Command
|
|||
*
|
||||
* @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.
|
||||
|
|
@ -50,6 +50,7 @@ private function syncReleasesToGitHubRepo(): bool
|
|||
|
||||
// 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));
|
||||
|
|
@ -59,6 +60,7 @@ private function syncReleasesToGitHubRepo(): bool
|
|||
|
||||
// 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));
|
||||
|
|
@ -70,12 +72,25 @@ private function syncReleasesToGitHubRepo(): bool
|
|||
// Write releases.json
|
||||
$this->info('Writing 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);
|
||||
$bytesWritten = file_put_contents($releasesPath, $jsonContent);
|
||||
|
||||
if ($bytesWritten === false) {
|
||||
$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));
|
||||
|
||||
return false;
|
||||
|
|
@ -83,6 +98,7 @@ private function syncReleasesToGitHubRepo(): bool
|
|||
|
||||
// Stage and commit
|
||||
$this->info('Committing changes...');
|
||||
$output = [];
|
||||
exec('cd '.escapeshellarg($tmpDir).' && git add json/releases.json 2>&1', $output, $returnCode);
|
||||
if ($returnCode !== 0) {
|
||||
$this->error('Failed to stage changes: '.implode("\n", $output));
|
||||
|
|
@ -120,6 +136,7 @@ private function syncReleasesToGitHubRepo(): bool
|
|||
|
||||
// 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));
|
||||
|
|
@ -133,6 +150,7 @@ private function syncReleasesToGitHubRepo(): bool
|
|||
$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';
|
||||
$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);
|
||||
|
||||
// Clean up
|
||||
|
|
@ -158,6 +176,343 @@ private function syncReleasesToGitHubRepo(): bool
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
|
@ -167,6 +522,7 @@ public function handle()
|
|||
$only_template = $this->option('templates');
|
||||
$only_version = $this->option('release');
|
||||
$only_github_releases = $this->option('github-releases');
|
||||
$only_github_versions = $this->option('github-versions');
|
||||
$nightly = $this->option('nightly');
|
||||
$bunny_cdn = 'https://cdn.coollabs.io';
|
||||
$bunny_cdn_path = 'coolify';
|
||||
|
|
@ -224,7 +580,7 @@ public function handle()
|
|||
$install_script_location = "$parent_dir/other/nightly/$install_script";
|
||||
$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) {
|
||||
$this->info('About to sync files NIGHTLY (docker-compose.prod.yaml, upgrade.sh, install.sh, etc) to BunnyCDN.');
|
||||
} else {
|
||||
|
|
@ -250,25 +606,47 @@ public function handle()
|
|||
return;
|
||||
} elseif ($only_version) {
|
||||
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 {
|
||||
$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);
|
||||
$json = json_decode($file, true);
|
||||
$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) {
|
||||
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) => [
|
||||
$pool->storage(fileName: $versions_location)->put("/$bunny_cdn_storage_name/$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;
|
||||
} elseif ($only_github_releases) {
|
||||
|
|
@ -281,6 +659,22 @@ public function handle()
|
|||
// Sync releases to GitHub repository
|
||||
$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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@
|
|||
|
||||
namespace App\Console;
|
||||
|
||||
use App\Jobs\CheckAndStartSentinelJob;
|
||||
use App\Jobs\CheckForUpdatesJob;
|
||||
use App\Jobs\CheckHelperImageJob;
|
||||
use App\Jobs\CheckTraefikVersionJob;
|
||||
use App\Jobs\CleanupInstanceStuffsJob;
|
||||
use App\Jobs\CleanupOrphanedPreviewContainersJob;
|
||||
use App\Jobs\PullChangelog;
|
||||
use App\Jobs\PullTemplatesFromCDN;
|
||||
use App\Jobs\RegenerateSslCertJob;
|
||||
|
|
@ -14,16 +14,11 @@
|
|||
use App\Jobs\ServerManagerJob;
|
||||
use App\Jobs\UpdateCoolifyJob;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
use Illuminate\Foundation\Console\Kernel as ConsoleKernel;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class Kernel extends ConsoleKernel
|
||||
{
|
||||
private $allServers;
|
||||
|
||||
private Schedule $scheduleInstance;
|
||||
|
||||
private InstanceSettings $settings;
|
||||
|
|
@ -35,8 +30,6 @@ class Kernel extends ConsoleKernel
|
|||
protected function schedule(Schedule $schedule): void
|
||||
{
|
||||
$this->scheduleInstance = $schedule;
|
||||
$this->allServers = Server::where('ip', '!=', '1.2.3.4');
|
||||
|
||||
$this->settings = instanceSettings();
|
||||
$this->updateCheckFrequency = $this->settings->update_check_frequency ?: '0 * * * *';
|
||||
|
||||
|
|
@ -88,29 +81,14 @@ protected function schedule(Schedule $schedule): void
|
|||
|
||||
$this->scheduleInstance->command('cleanup:database --yes')->daily();
|
||||
$this->scheduleInstance->command('uploads:clear')->everyTwoMinutes();
|
||||
|
||||
// Cleanup orphaned PR preview containers daily
|
||||
$this->scheduleInstance->job(new CleanupOrphanedPreviewContainersJob)->daily()->onOneServer();
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
->cron($this->updateCheckFrequency)
|
||||
->timezone($this->instanceTimezone)
|
||||
|
|
|
|||
|
|
@ -14,12 +14,15 @@ class ProxyStatusChangedUI implements ShouldBroadcast
|
|||
|
||||
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()) {
|
||||
$teamId = auth()->user()->currentTeam()->id;
|
||||
}
|
||||
$this->teamId = $teamId;
|
||||
$this->activityId = $activityId;
|
||||
}
|
||||
|
||||
public function broadcastOn(): array
|
||||
|
|
|
|||
|
|
@ -17,17 +17,23 @@ public function __construct($data)
|
|||
$tmpPath = data_get($data, 'tmpPath');
|
||||
$container = data_get($data, 'container');
|
||||
$serverId = data_get($data, 'serverId');
|
||||
if (filled($scriptPath) && filled($tmpPath) && filled($container) && filled($serverId)) {
|
||||
if (str($tmpPath)->startsWith('/tmp/')
|
||||
&& str($scriptPath)->startsWith('/tmp/')
|
||||
&& ! str($tmpPath)->contains('..')
|
||||
&& ! str($scriptPath)->contains('..')
|
||||
&& strlen($tmpPath) > 5 // longer than just "/tmp/"
|
||||
&& strlen($scriptPath) > 5
|
||||
) {
|
||||
$commands[] = "docker exec {$container} sh -c 'rm {$scriptPath}'";
|
||||
$commands[] = "docker exec {$container} sh -c 'rm {$tmpPath}'";
|
||||
instant_remote_process($commands, Server::find($serverId), throwError: true);
|
||||
|
||||
if (filled($container) && filled($serverId)) {
|
||||
$commands = [];
|
||||
|
||||
if (isSafeTmpPath($scriptPath)) {
|
||||
$commands[] = 'docker exec '.escapeshellarg($container)." sh -c 'rm ".escapeshellarg($scriptPath)." 2>/dev/null || true'";
|
||||
}
|
||||
|
||||
if (isSafeTmpPath($tmpPath)) {
|
||||
$commands[] = 'docker exec '.escapeshellarg($container)." sh -c 'rm ".escapeshellarg($tmpPath)." 2>/dev/null || 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 @@ public static function generateScpCommand(Server $server, string $source, string
|
|||
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) {
|
||||
throw new \RuntimeException('Server is disabled.');
|
||||
|
|
@ -168,7 +168,7 @@ public static function generateSshCommand(Server $server, string $command)
|
|||
$ssh_command = "timeout $timeout ssh ";
|
||||
|
||||
$multiplexingSuccessful = false;
|
||||
if (self::isMultiplexingEnabled()) {
|
||||
if (! $disableMultiplexing && self::isMultiplexingEnabled()) {
|
||||
try {
|
||||
$multiplexingSuccessful = self::ensureMultiplexedConnection($server);
|
||||
if ($multiplexingSuccessful) {
|
||||
|
|
|
|||
|
|
@ -192,6 +192,7 @@ public function applications(Request $request)
|
|||
'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.'],
|
||||
'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 @@ public function create_public_application(Request $request)
|
|||
'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.'],
|
||||
'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 @@ public function create_private_gh_app_application(Request $request)
|
|||
'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.'],
|
||||
'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 @@ public function create_private_deploy_key_application(Request $request)
|
|||
'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.'],
|
||||
'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 @@ public function create_dockerfile_application(Request $request)
|
|||
'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.'],
|
||||
'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 @@ private function create_application(Request $request, $type)
|
|||
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
||||
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(), [
|
||||
'name' => 'string|max:255',
|
||||
|
|
@ -940,6 +945,7 @@ private function create_application(Request $request, $type)
|
|||
'is_http_basic_auth_enabled' => 'boolean',
|
||||
'http_basic_auth_username' => 'string|nullable',
|
||||
'http_basic_auth_password' => 'string|nullable',
|
||||
'autogenerate_domain' => 'boolean',
|
||||
]);
|
||||
|
||||
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
||||
|
|
@ -964,6 +970,7 @@ private function create_application(Request $request, $type)
|
|||
}
|
||||
$serverUuid = $request->server_uuid;
|
||||
$fqdn = $request->domains;
|
||||
$autogenerateDomain = $request->boolean('autogenerate_domain', true);
|
||||
$instantDeploy = $request->instant_deploy;
|
||||
$githubAppUuid = $request->github_app_uuid;
|
||||
$useBuildServer = $request->use_build_server;
|
||||
|
|
@ -1087,6 +1094,11 @@ private function create_application(Request $request, $type)
|
|||
$application->settings->save();
|
||||
}
|
||||
$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) {
|
||||
$application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n");
|
||||
$application->save();
|
||||
|
|
@ -1115,7 +1127,7 @@ private function create_application(Request $request, $type)
|
|||
|
||||
return response()->json(serializeApiResponse([
|
||||
'uuid' => data_get($application, 'uuid'),
|
||||
'domains' => data_get($application, 'domains'),
|
||||
'domains' => data_get($application, 'fqdn'),
|
||||
]))->setStatusCode(201);
|
||||
} elseif ($type === 'private-gh-app') {
|
||||
$validationRules = [
|
||||
|
|
@ -1238,6 +1250,11 @@ private function create_application(Request $request, $type)
|
|||
|
||||
$application->save();
|
||||
$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)) {
|
||||
$application->settings->is_build_server_enabled = $useBuildServer;
|
||||
$application->settings->save();
|
||||
|
|
@ -1270,7 +1287,7 @@ private function create_application(Request $request, $type)
|
|||
|
||||
return response()->json(serializeApiResponse([
|
||||
'uuid' => data_get($application, 'uuid'),
|
||||
'domains' => data_get($application, 'domains'),
|
||||
'domains' => data_get($application, 'fqdn'),
|
||||
]))->setStatusCode(201);
|
||||
} elseif ($type === 'private-deploy-key') {
|
||||
|
||||
|
|
@ -1367,6 +1384,11 @@ private function create_application(Request $request, $type)
|
|||
$application->environment_id = $environment->id;
|
||||
$application->save();
|
||||
$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)) {
|
||||
$application->settings->is_build_server_enabled = $useBuildServer;
|
||||
$application->settings->save();
|
||||
|
|
@ -1399,7 +1421,7 @@ private function create_application(Request $request, $type)
|
|||
|
||||
return response()->json(serializeApiResponse([
|
||||
'uuid' => data_get($application, 'uuid'),
|
||||
'domains' => data_get($application, 'domains'),
|
||||
'domains' => data_get($application, 'fqdn'),
|
||||
]))->setStatusCode(201);
|
||||
} elseif ($type === 'dockerfile') {
|
||||
$validationRules = [
|
||||
|
|
@ -1461,6 +1483,11 @@ private function create_application(Request $request, $type)
|
|||
$application->git_branch = 'main';
|
||||
$application->save();
|
||||
$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)) {
|
||||
$application->settings->is_build_server_enabled = $useBuildServer;
|
||||
$application->settings->save();
|
||||
|
|
@ -1489,7 +1516,7 @@ private function create_application(Request $request, $type)
|
|||
|
||||
return response()->json(serializeApiResponse([
|
||||
'uuid' => data_get($application, 'uuid'),
|
||||
'domains' => data_get($application, 'domains'),
|
||||
'domains' => data_get($application, 'fqdn'),
|
||||
]))->setStatusCode(201);
|
||||
} elseif ($type === 'dockerimage') {
|
||||
$validationRules = [
|
||||
|
|
@ -1554,6 +1581,11 @@ private function create_application(Request $request, $type)
|
|||
$application->git_branch = 'main';
|
||||
$application->save();
|
||||
$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)) {
|
||||
$application->settings->is_build_server_enabled = $useBuildServer;
|
||||
$application->settings->save();
|
||||
|
|
@ -1582,7 +1614,7 @@ private function create_application(Request $request, $type)
|
|||
|
||||
return response()->json(serializeApiResponse([
|
||||
'uuid' => data_get($application, 'uuid'),
|
||||
'domains' => data_get($application, 'domains'),
|
||||
'domains' => data_get($application, 'fqdn'),
|
||||
]))->setStatusCode(201);
|
||||
} 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'];
|
||||
|
|
@ -1652,6 +1684,10 @@ private function create_application(Request $request, $type)
|
|||
$service->save();
|
||||
|
||||
$service->parse(isNew: true);
|
||||
|
||||
// Apply service-specific application prerequisites
|
||||
applyServiceApplicationPrerequisites($service);
|
||||
|
||||
if ($instantDeploy) {
|
||||
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 @@ private function by_uuids(string $uuid, int $teamId, bool $force = false, int $p
|
|||
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) {
|
||||
$deployments->push(['message' => $return_message, 'resource_uuid' => $uuid, 'deployment_uuid' => $deployment_uuid->toString()]);
|
||||
} else {
|
||||
|
|
@ -430,7 +434,11 @@ public function by_tags(string $tags, int $team_id, bool $force = false)
|
|||
continue;
|
||||
}
|
||||
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) {
|
||||
$deployments->push(['resource_uuid' => $resource->uuid, 'deployment_uuid' => $deployment_uuid->toString()]);
|
||||
}
|
||||
|
|
@ -474,8 +482,11 @@ public function deploy_resource($resource, bool $force = false, int $pr = 0): ar
|
|||
deployment_uuid: $deployment_uuid,
|
||||
force_rebuild: $force,
|
||||
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'];
|
||||
} else {
|
||||
$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 @@
|
|||
),
|
||||
]
|
||||
)),
|
||||
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
|
||||
|
|
|
|||
|
|
@ -351,7 +351,7 @@ public function create_service(Request $request)
|
|||
'destination_id' => $destination->id,
|
||||
'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);
|
||||
}
|
||||
$service = Service::create($servicePayload);
|
||||
|
|
@ -376,6 +376,10 @@ public function create_service(Request $request)
|
|||
});
|
||||
}
|
||||
$service->parse(isNew: true);
|
||||
|
||||
// Apply service-specific application prerequisites
|
||||
applyServiceApplicationPrerequisites($service);
|
||||
|
||||
if ($instantDeploy) {
|
||||
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;
|
||||
|
||||
use App\Actions\Application\CleanupPreviewDeployment;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Livewire\Project\Service\Storage;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
use Exception;
|
||||
|
|
@ -15,23 +15,6 @@ class Bitbucket extends Controller
|
|||
public function manual(Request $request)
|
||||
{
|
||||
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([]);
|
||||
$payload = $request->collect();
|
||||
$headers = $request->headers->all();
|
||||
|
|
@ -107,7 +90,9 @@ public function manual(Request $request)
|
|||
force_rebuild: false,
|
||||
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([
|
||||
'application' => $application->name,
|
||||
'status' => 'skipped',
|
||||
|
|
@ -161,7 +146,9 @@ public function manual(Request $request)
|
|||
is_webhook: true,
|
||||
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([
|
||||
'application' => $application->name,
|
||||
'status' => 'skipped',
|
||||
|
|
@ -185,9 +172,10 @@ public function manual(Request $request)
|
|||
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();
|
||||
if ($found) {
|
||||
$found->delete();
|
||||
$container_name = generateApplicationContainerName($application, $pull_request_id);
|
||||
instant_remote_process(["docker rm -f $container_name"], $application->destination->server);
|
||||
// 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([
|
||||
'application' => $application->name,
|
||||
'status' => 'success',
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@
|
|||
|
||||
namespace App\Http\Controllers\Webhook;
|
||||
|
||||
use App\Actions\Application\CleanupPreviewDeployment;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
|
|
@ -18,30 +18,6 @@ public function manual(Request $request)
|
|||
try {
|
||||
$return_payloads = collect([]);
|
||||
$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_hub_signature_256 = Str::after($request->header('X-Hub-Signature-256'), 'sha256=');
|
||||
$content_type = $request->header('Content-Type');
|
||||
|
|
@ -123,7 +99,9 @@ public function manual(Request $request)
|
|||
commit: data_get($payload, 'after', 'HEAD'),
|
||||
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([
|
||||
'application' => $application->name,
|
||||
'status' => 'skipped',
|
||||
|
|
@ -193,7 +171,9 @@ public function manual(Request $request)
|
|||
is_webhook: true,
|
||||
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([
|
||||
'application' => $application->name,
|
||||
'status' => 'skipped',
|
||||
|
|
@ -217,9 +197,10 @@ public function manual(Request $request)
|
|||
if ($action === 'closed') {
|
||||
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||
if ($found) {
|
||||
$found->delete();
|
||||
$container_name = generateApplicationContainerName($application, $pull_request_id);
|
||||
instant_remote_process(["docker rm -f $container_name"], $application->destination->server);
|
||||
// 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([
|
||||
'application' => $application->name,
|
||||
'status' => 'success',
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@
|
|||
|
||||
namespace App\Http\Controllers\Webhook;
|
||||
|
||||
use App\Actions\Application\CleanupPreviewDeployment;
|
||||
use App\Enums\ProcessStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\ApplicationPullRequestUpdateJob;
|
||||
use App\Jobs\DeleteResourceJob;
|
||||
use App\Jobs\GithubAppPermissionJob;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
|
|
@ -14,7 +14,6 @@
|
|||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
|
|
@ -25,30 +24,6 @@ public function manual(Request $request)
|
|||
try {
|
||||
$return_payloads = collect([]);
|
||||
$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_hub_signature_256 = Str::after($request->header('X-Hub-Signature-256'), 'sha256=');
|
||||
$content_type = $request->header('Content-Type');
|
||||
|
|
@ -136,7 +111,9 @@ public function manual(Request $request)
|
|||
commit: data_get($payload, 'after', 'HEAD'),
|
||||
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([
|
||||
'application' => $application->name,
|
||||
'status' => 'skipped',
|
||||
|
|
@ -222,7 +199,9 @@ public function manual(Request $request)
|
|||
is_webhook: true,
|
||||
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([
|
||||
'application' => $application->name,
|
||||
'status' => 'skipped',
|
||||
|
|
@ -246,41 +225,10 @@ public function manual(Request $request)
|
|||
if ($action === 'closed') {
|
||||
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||
if ($found) {
|
||||
// Cancel any active deployments for this PR immediately
|
||||
$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();
|
||||
// Use comprehensive cleanup that cancels active deployments,
|
||||
// kills helper containers, and removes all PR containers
|
||||
CleanupPreviewDeployment::run($application, $pull_request_id, $found);
|
||||
|
||||
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([
|
||||
'application' => $application->name,
|
||||
'status' => 'success',
|
||||
|
|
@ -310,30 +258,6 @@ public function normal(Request $request)
|
|||
$return_payloads = collect([]);
|
||||
$id = null;
|
||||
$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_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=');
|
||||
|
|
@ -385,7 +309,9 @@ public function normal(Request $request)
|
|||
if (! $id || ! $branch) {
|
||||
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') {
|
||||
$applications = $applications->where('git_branch', $branch)->get();
|
||||
if ($applications->isEmpty()) {
|
||||
|
|
@ -427,12 +353,15 @@ public function normal(Request $request)
|
|||
force_rebuild: false,
|
||||
is_webhook: true,
|
||||
);
|
||||
if ($result['status'] === 'queue_full') {
|
||||
return response($result['message'], 429)->header('Retry-After', 60);
|
||||
}
|
||||
$return_payloads->push([
|
||||
'status' => $result['status'],
|
||||
'message' => $result['message'],
|
||||
'application_uuid' => $application->uuid,
|
||||
'application_name' => $application->name,
|
||||
'deployment_uuid' => $result['deployment_uuid'],
|
||||
'deployment_uuid' => $result['deployment_uuid'] ?? null,
|
||||
]);
|
||||
} else {
|
||||
$paths = str($application->watch_paths)->explode("\n");
|
||||
|
|
@ -491,7 +420,9 @@ public function normal(Request $request)
|
|||
is_webhook: true,
|
||||
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([
|
||||
'application' => $application->name,
|
||||
'status' => 'skipped',
|
||||
|
|
@ -515,53 +446,12 @@ public function normal(Request $request)
|
|||
if ($action === 'closed' || $action === 'close') {
|
||||
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||
if ($found) {
|
||||
// Cancel any active deployments for this PR immediately
|
||||
$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);
|
||||
});
|
||||
}
|
||||
|
||||
// Delete the PR comment on GitHub (GitHub-specific feature)
|
||||
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([
|
||||
'application' => $application->name,
|
||||
|
|
@ -624,23 +514,6 @@ public function install(Request $request)
|
|||
{
|
||||
try {
|
||||
$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');
|
||||
$setup_action = $request->get('setup_action');
|
||||
$github_app = GithubApp::where('uuid', $source)->firstOrFail();
|
||||
|
|
|
|||
|
|
@ -2,12 +2,12 @@
|
|||
|
||||
namespace App\Http\Controllers\Webhook;
|
||||
|
||||
use App\Actions\Application\CleanupPreviewDeployment;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationPreview;
|
||||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
|
|
@ -16,24 +16,6 @@ class Gitlab extends Controller
|
|||
public function manual(Request $request)
|
||||
{
|
||||
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([]);
|
||||
$payload = $request->collect();
|
||||
$headers = $request->headers->all();
|
||||
|
|
@ -149,7 +131,9 @@ public function manual(Request $request)
|
|||
force_rebuild: false,
|
||||
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([
|
||||
'status' => $result['status'],
|
||||
'message' => $result['message'],
|
||||
|
|
@ -220,7 +204,9 @@ public function manual(Request $request)
|
|||
is_webhook: true,
|
||||
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([
|
||||
'application' => $application->name,
|
||||
'status' => 'skipped',
|
||||
|
|
@ -243,22 +229,22 @@ public function manual(Request $request)
|
|||
} elseif ($action === 'closed' || $action === 'close' || $action === 'merge') {
|
||||
$found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first();
|
||||
if ($found) {
|
||||
$found->delete();
|
||||
$container_name = generateApplicationContainerName($application, $pull_request_id);
|
||||
instant_remote_process(["docker rm -f $container_name"], $application->destination->server);
|
||||
// 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([
|
||||
'application' => $application->name,
|
||||
'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 {
|
||||
$return_payloads->push([
|
||||
'application' => $application->name,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@
|
|||
use App\Jobs\StripeProcessJob;
|
||||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class Stripe extends Controller
|
||||
{
|
||||
|
|
@ -20,23 +19,6 @@ public function events(Request $request)
|
|||
$signature,
|
||||
$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);
|
||||
|
||||
return response('Webhook received. Cool cool cool cool cool.', 200);
|
||||
|
|
|
|||
|
|
@ -486,15 +486,38 @@ private function decide_what_to_do()
|
|||
|
||||
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();
|
||||
|
||||
// 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->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()
|
||||
|
|
@ -620,7 +643,7 @@ private function deploy_docker_compose_buildpack()
|
|||
$this->application_deployment_queue->addLogEntry('Build secrets are configured. Ensure your docker-compose file includes build.secrets configuration for services that need them.');
|
||||
}
|
||||
} 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
|
||||
$services = collect(data_get($composeFile, 'services', []));
|
||||
$services = $services->map(function ($service, $name) {
|
||||
|
|
@ -670,13 +693,20 @@ private function deploy_docker_compose_buildpack()
|
|||
$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()) {
|
||||
$build_args_string = $this->build_args->implode(' ');
|
||||
// Escape single quotes for bash -c context used by executeInDocker
|
||||
$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(
|
||||
|
|
@ -1363,7 +1393,7 @@ private function save_runtime_environment_variables()
|
|||
$envs_base64 = base64_encode($environment_variables->implode("\n"));
|
||||
|
||||
// Write .env file to workdir (for container runtime)
|
||||
$this->application_deployment_queue->addLogEntry('Creating .env file with runtime variables for build phase.', hidden: true);
|
||||
$this->application_deployment_queue->addLogEntry('Creating .env file with runtime variables for container.', hidden: true);
|
||||
$this->execute_remote_command(
|
||||
[
|
||||
executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/.env > /dev/null"),
|
||||
|
|
@ -1401,15 +1431,44 @@ private function generate_buildtime_environment_variables()
|
|||
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
|
||||
}
|
||||
|
||||
$envs = collect([]);
|
||||
$coolify_envs = $this->generate_coolify_env_variables();
|
||||
// Use associative array for automatic deduplication
|
||||
$envs_dict = [];
|
||||
|
||||
// Add COOLIFY variables
|
||||
$coolify_envs->each(function ($item, $key) use ($envs) {
|
||||
$envs->push($key.'='.escapeBashEnvValue($item));
|
||||
});
|
||||
// 1. Add nixpacks plan variables FIRST (lowest priority - can be overridden)
|
||||
if ($this->build_pack === 'nixpacks' &&
|
||||
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->pull_request_id === 0) {
|
||||
// Generate SERVICE_NAME for dockercompose services from processed compose
|
||||
|
|
@ -1420,7 +1479,7 @@ private function generate_buildtime_environment_variables()
|
|||
}
|
||||
$services = data_get($dockerCompose, 'services', []);
|
||||
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
|
||||
|
|
@ -1433,8 +1492,8 @@ private function generate_buildtime_environment_variables()
|
|||
$coolifyScheme = $coolifyUrl->getScheme();
|
||||
$coolifyFqdn = $coolifyUrl->getHost();
|
||||
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
|
||||
$envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyUrl->__toString()));
|
||||
$envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyFqdn));
|
||||
$envs_dict['SERVICE_URL_'.str($forServiceName)->upper()] = escapeBashEnvValue($coolifyUrl->__toString());
|
||||
$envs_dict['SERVICE_FQDN_'.str($forServiceName)->upper()] = escapeBashEnvValue($coolifyFqdn);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
@ -1442,7 +1501,7 @@ private function generate_buildtime_environment_variables()
|
|||
$rawDockerCompose = Yaml::parse($this->application->docker_compose_raw);
|
||||
$rawServices = data_get($rawDockerCompose, 'services', []);
|
||||
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
|
||||
|
|
@ -1455,17 +1514,16 @@ private function generate_buildtime_environment_variables()
|
|||
$coolifyScheme = $coolifyUrl->getScheme();
|
||||
$coolifyFqdn = $coolifyUrl->getHost();
|
||||
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
|
||||
$envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyUrl->__toString()));
|
||||
$envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.escapeBashEnvValue($coolifyFqdn));
|
||||
$envs_dict['SERVICE_URL_'.str($forServiceName)->upper()] = escapeBashEnvValue($coolifyUrl->__toString());
|
||||
$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) {
|
||||
$sorted_environment_variables = $this->application->environment_variables()
|
||||
->where('key', 'not like', 'NIXPACKS_%')
|
||||
->where('is_buildtime', true) // ONLY build-time variables
|
||||
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
|
||||
->get();
|
||||
|
|
@ -1483,7 +1541,12 @@ private function generate_buildtime_environment_variables()
|
|||
// Strip outer quotes from real_value and apply proper bash escaping
|
||||
$value = trim($env->real_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()) {
|
||||
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
|
||||
|
|
@ -1495,7 +1558,12 @@ private function generate_buildtime_environment_variables()
|
|||
} else {
|
||||
// For normal vars, use double quotes to allow $VAR expansion
|
||||
$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()) {
|
||||
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
|
||||
|
|
@ -1507,7 +1575,6 @@ private function generate_buildtime_environment_variables()
|
|||
}
|
||||
} else {
|
||||
$sorted_environment_variables = $this->application->environment_variables_preview()
|
||||
->where('key', 'not like', 'NIXPACKS_%')
|
||||
->where('is_buildtime', true) // ONLY build-time variables
|
||||
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
|
||||
->get();
|
||||
|
|
@ -1525,7 +1592,12 @@ private function generate_buildtime_environment_variables()
|
|||
// Strip outer quotes from real_value and apply proper bash escaping
|
||||
$value = trim($env->real_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()) {
|
||||
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
|
||||
|
|
@ -1537,7 +1609,12 @@ private function generate_buildtime_environment_variables()
|
|||
} else {
|
||||
// For normal vars, use double quotes to allow $VAR expansion
|
||||
$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()) {
|
||||
$this->application_deployment_queue->addLogEntry("[DEBUG] Build-time env: {$env->key}");
|
||||
|
|
@ -1549,6 +1626,12 @@ private function generate_buildtime_environment_variables()
|
|||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
if (isDev()) {
|
||||
$this->application_deployment_queue->addLogEntry('[DEBUG] ========================================');
|
||||
|
|
@ -1753,9 +1836,9 @@ private function health_check()
|
|||
$this->application->update(['status' => 'running']);
|
||||
$this->application_deployment_queue->addLogEntry('New container is healthy.');
|
||||
break;
|
||||
}
|
||||
if (str($this->saved_outputs->get('health_check'))->replace('"', '')->value() === 'unhealthy') {
|
||||
} elseif (str($this->saved_outputs->get('health_check'))->replace('"', '')->value() === 'unhealthy') {
|
||||
$this->newVersionIsHealthy = false;
|
||||
$this->application_deployment_queue->addLogEntry('New container is unhealthy.', type: 'error');
|
||||
$this->query_logs();
|
||||
break;
|
||||
}
|
||||
|
|
@ -1957,7 +2040,12 @@ private function deploy_to_additional_destinations()
|
|||
|
||||
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) {
|
||||
$fqdn = $this->application->fqdn;
|
||||
} else {
|
||||
|
|
@ -1979,7 +2067,6 @@ private function set_coolify_variables()
|
|||
$this->coolify_variables .= "COOLIFY_BRANCH={$this->application->git_branch} ";
|
||||
}
|
||||
$this->coolify_variables .= "COOLIFY_RESOURCE_UUID={$this->application->uuid} ";
|
||||
$this->coolify_variables .= "COOLIFY_CONTAINER_NAME={$this->container_name} ";
|
||||
}
|
||||
|
||||
private function check_git_if_build_needed()
|
||||
|
|
@ -2217,38 +2304,44 @@ private function generate_nixpacks_env_variables()
|
|||
$this->env_nixpacks_args = collect([]);
|
||||
if ($this->pull_request_id === 0) {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
$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(' ');
|
||||
}
|
||||
|
||||
private function generate_coolify_env_variables(): Collection
|
||||
private function generate_coolify_env_variables(bool $forBuildTime = false): Collection
|
||||
{
|
||||
$coolify_envs = collect([]);
|
||||
$local_branch = $this->branch;
|
||||
if ($this->pull_request_id !== 0) {
|
||||
// Add SOURCE_COMMIT if not exists
|
||||
if ($this->application->environment_variables_preview->where('key', 'SOURCE_COMMIT')->isEmpty()) {
|
||||
if (! is_null($this->commit)) {
|
||||
$coolify_envs->put('SOURCE_COMMIT', $this->commit);
|
||||
} else {
|
||||
$coolify_envs->put('SOURCE_COMMIT', 'unknown');
|
||||
// Only add SOURCE_COMMIT for runtime OR when explicitly enabled for build-time
|
||||
// SOURCE_COMMIT changes with each commit and breaks Docker cache if included in build
|
||||
if (! $forBuildTime || $this->application->settings->include_source_commit_in_build) {
|
||||
if ($this->application->environment_variables_preview->where('key', 'SOURCE_COMMIT')->isEmpty()) {
|
||||
if (! is_null($this->commit)) {
|
||||
$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()) {
|
||||
|
|
@ -2273,20 +2366,26 @@ private function generate_coolify_env_variables(): Collection
|
|||
if ($this->application->environment_variables_preview->where('key', 'COOLIFY_RESOURCE_UUID')->isEmpty()) {
|
||||
$coolify_envs->put('COOLIFY_RESOURCE_UUID', $this->application->uuid);
|
||||
}
|
||||
if ($this->application->environment_variables_preview->where('key', 'COOLIFY_CONTAINER_NAME')->isEmpty()) {
|
||||
$coolify_envs->put('COOLIFY_CONTAINER_NAME', $this->container_name);
|
||||
// Only add COOLIFY_CONTAINER_NAME for runtime (not build-time) - it changes every deployment and breaks Docker cache
|
||||
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);
|
||||
|
||||
} else {
|
||||
// Add SOURCE_COMMIT if not exists
|
||||
if ($this->application->environment_variables->where('key', 'SOURCE_COMMIT')->isEmpty()) {
|
||||
if (! is_null($this->commit)) {
|
||||
$coolify_envs->put('SOURCE_COMMIT', $this->commit);
|
||||
} else {
|
||||
$coolify_envs->put('SOURCE_COMMIT', 'unknown');
|
||||
// Only add SOURCE_COMMIT for runtime OR when explicitly enabled for build-time
|
||||
// SOURCE_COMMIT changes with each commit and breaks Docker cache if included in build
|
||||
if (! $forBuildTime || $this->application->settings->include_source_commit_in_build) {
|
||||
if ($this->application->environment_variables->where('key', 'SOURCE_COMMIT')->isEmpty()) {
|
||||
if (! is_null($this->commit)) {
|
||||
$coolify_envs->put('SOURCE_COMMIT', $this->commit);
|
||||
} else {
|
||||
$coolify_envs->put('SOURCE_COMMIT', 'unknown');
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($this->application->environment_variables->where('key', 'COOLIFY_FQDN')->isEmpty()) {
|
||||
|
|
@ -2311,8 +2410,11 @@ private function generate_coolify_env_variables(): Collection
|
|||
if ($this->application->environment_variables->where('key', 'COOLIFY_RESOURCE_UUID')->isEmpty()) {
|
||||
$coolify_envs->put('COOLIFY_RESOURCE_UUID', $this->application->uuid);
|
||||
}
|
||||
if ($this->application->environment_variables->where('key', 'COOLIFY_CONTAINER_NAME')->isEmpty()) {
|
||||
$coolify_envs->put('COOLIFY_CONTAINER_NAME', $this->container_name);
|
||||
// Only add COOLIFY_CONTAINER_NAME for runtime (not build-time) - it changes every deployment and breaks Docker cache
|
||||
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 @@ private function generate_coolify_env_variables(): Collection
|
|||
private function generate_env_variables()
|
||||
{
|
||||
$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) {
|
||||
$this->env_args->put($key, $value);
|
||||
});
|
||||
|
|
@ -2748,7 +2854,7 @@ private function build_image()
|
|||
} else {
|
||||
// Traditional build args approach - generate COOLIFY_ variables locally
|
||||
// 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) {
|
||||
$this->build_args->push("--build-arg '{$key}'");
|
||||
});
|
||||
|
|
@ -3070,7 +3176,7 @@ private function graceful_shutdown_container(string $containerName)
|
|||
try {
|
||||
$timeout = isDev() ? 1 : 30;
|
||||
$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]
|
||||
);
|
||||
} catch (Exception $error) {
|
||||
|
|
@ -3107,6 +3213,18 @@ private function stop_running_container(bool $force = false)
|
|||
$this->graceful_shutdown_container($this->container_name);
|
||||
}
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -3294,7 +3412,9 @@ private function generate_build_secrets(Collection $variables)
|
|||
private function generate_secrets_hash($variables)
|
||||
{
|
||||
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) {
|
||||
|
|
@ -3337,100 +3457,121 @@ private function add_build_env_variables_to_dockerfile()
|
|||
{
|
||||
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
|
||||
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 {
|
||||
$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}"),
|
||||
'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 {
|
||||
// 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)
|
||||
|
|
@ -3503,6 +3644,13 @@ private function modify_dockerfiles_for_compose($composeFile)
|
|||
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
|
||||
// This populates $this->env_args with both user-defined and COOLIFY_* variables
|
||||
if (! $this->env_args || $this->env_args->isEmpty()) {
|
||||
|
|
@ -3593,6 +3741,18 @@ private function modify_dockerfiles_for_compose($composeFile)
|
|||
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;
|
||||
$offset = 0;
|
||||
|
||||
|
|
@ -3797,13 +3957,17 @@ private function transitionToStatus(ApplicationDeploymentStatus $status): void
|
|||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
private function isInTerminalState(): bool
|
||||
{
|
||||
$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) {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -3843,6 +4007,15 @@ private function handleStatusTransition(ApplicationDeploymentStatus $status): vo
|
|||
*/
|
||||
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));
|
||||
|
||||
if (! $this->only_this_server) {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class CheckForUpdatesJob implements ShouldBeEncrypted, ShouldQueue
|
||||
{
|
||||
|
|
@ -22,20 +23,60 @@ public function handle(): void
|
|||
return;
|
||||
}
|
||||
$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()) {
|
||||
$versions = $response->json();
|
||||
|
||||
$latest_version = data_get($versions, 'coolify.v4.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, '>')) {
|
||||
// New version available
|
||||
$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 {
|
||||
$settings->update(['new_version_available' => false]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ public function __construct() {}
|
|||
public function handle(): void
|
||||
{
|
||||
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()) {
|
||||
$versions = $response->json();
|
||||
$settings = instanceSettings();
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Events\ProxyStatusChangedUI;
|
||||
use App\Models\Server;
|
||||
use App\Notifications\Server\TraefikVersionOutdated;
|
||||
use Illuminate\Bus\Queueable;
|
||||
|
|
@ -38,6 +39,8 @@ public function handle(): void
|
|||
$this->server->update(['detected_traefik_version' => $currentVersion]);
|
||||
|
||||
if (! $currentVersion) {
|
||||
ProxyStatusChangedUI::dispatch($this->server->team_id);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -48,16 +51,22 @@ public function handle(): void
|
|||
|
||||
// Handle empty/null response from SSH command
|
||||
if (empty(trim($imageTag))) {
|
||||
ProxyStatusChangedUI::dispatch($this->server->team_id);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (str_contains(strtolower(trim($imageTag)), ':latest')) {
|
||||
ProxyStatusChangedUI::dispatch($this->server->team_id);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse current version to extract major.minor.patch
|
||||
$current = ltrim($currentVersion, 'v');
|
||||
if (! preg_match('/^(\d+\.\d+)\.(\d+)$/', $current, $matches)) {
|
||||
ProxyStatusChangedUI::dispatch($this->server->team_id);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -77,6 +86,8 @@ public function handle(): void
|
|||
$this->server->update(['traefik_outdated_info' => null]);
|
||||
}
|
||||
|
||||
ProxyStatusChangedUI::dispatch($this->server->team_id);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -96,6 +107,9 @@ public function handle(): void
|
|||
// Fully up to date
|
||||
$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 @@ public function failed(?\Throwable $exception): void
|
|||
'failed_at' => now()->toIso8601String(),
|
||||
]);
|
||||
$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 @@ public function handle(): void
|
|||
$this->container_name = "{$this->database->name}-$serviceUuid";
|
||||
$this->directory_name = $serviceName.'-'.$this->container_name;
|
||||
$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");
|
||||
|
||||
$user = $envs->filter(function ($env) {
|
||||
|
|
@ -152,7 +152,7 @@ public function handle(): void
|
|||
$this->container_name = "{$this->database->name}-$serviceUuid";
|
||||
$this->directory_name = $serviceName.'-'.$this->container_name;
|
||||
$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");
|
||||
|
||||
$rootPassword = $envs->filter(function ($env) {
|
||||
|
|
@ -175,7 +175,7 @@ public function handle(): void
|
|||
$this->container_name = "{$this->database->name}-$serviceUuid";
|
||||
$this->directory_name = $serviceName.'-'.$this->container_name;
|
||||
$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");
|
||||
$rootPassword = $envs->filter(function ($env) {
|
||||
return str($env)->startsWith('MARIADB_ROOT_PASSWORD=');
|
||||
|
|
@ -217,7 +217,7 @@ public function handle(): void
|
|||
try {
|
||||
$commands = [];
|
||||
$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)) {
|
||||
$envs = str($envs)->explode("\n");
|
||||
|
|
@ -489,21 +489,26 @@ private function backup_standalone_mongodb(string $databaseWithCollections): voi
|
|||
$collectionsToExclude = collect();
|
||||
}
|
||||
$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 (str($this->database->image)->startsWith('mongo:4')) {
|
||||
$commands[] = "docker exec $this->container_name mongodump --uri=\"$url\" --gzip --archive > $this->backup_location";
|
||||
} 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 {
|
||||
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";
|
||||
} 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);
|
||||
if ($this->backup_output === '') {
|
||||
$this->backup_output = null;
|
||||
|
|
@ -525,11 +530,14 @@ private function backup_standalone_postgresql(string $database): void
|
|||
if ($this->backup->dump_all) {
|
||||
$backupCommand .= " $this->container_name pg_dumpall --username {$this->database->postgres_user} | gzip > $this->backup_location";
|
||||
} 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;
|
||||
$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);
|
||||
if ($this->backup_output === '') {
|
||||
$this->backup_output = null;
|
||||
|
|
@ -547,9 +555,12 @@ private function backup_standalone_mysql(string $database): void
|
|||
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";
|
||||
} 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);
|
||||
if ($this->backup_output === '') {
|
||||
$this->backup_output = null;
|
||||
|
|
@ -567,9 +578,12 @@ private function backup_standalone_mariadb(string $database): void
|
|||
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";
|
||||
} 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);
|
||||
if ($this->backup_output === '') {
|
||||
$this->backup_output = null;
|
||||
|
|
@ -600,7 +614,7 @@ private function add_to_error_output($output): void
|
|||
|
||||
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
|
||||
|
|
@ -623,9 +637,9 @@ private function upload_to_s3(): void
|
|||
|
||||
$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)) {
|
||||
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()) {
|
||||
|
|
@ -639,9 +653,15 @@ private function upload_to_s3(): void
|
|||
} 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 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}/";
|
||||
instant_remote_process($commands, $this->server);
|
||||
instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true);
|
||||
|
||||
$this->s3_uploaded = true;
|
||||
} catch (\Throwable $e) {
|
||||
|
|
@ -650,7 +670,7 @@ private function upload_to_s3(): void
|
|||
throw $e;
|
||||
} finally {
|
||||
$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 @@ private function stopPreviewContainers(array $containers, $server, int $timeout
|
|||
|
||||
$containerList = implode(' ', array_map('escapeshellarg', $containerNames));
|
||||
$commands = [
|
||||
"docker stop --time=$timeout $containerList",
|
||||
"docker stop -t $timeout $containerList",
|
||||
"docker rm -f $containerList",
|
||||
];
|
||||
instant_remote_process(
|
||||
|
|
|
|||
|
|
@ -300,8 +300,9 @@ private function aggregateMultiContainerStatuses()
|
|||
}
|
||||
|
||||
// Use ContainerStatusAggregator service for state machine logic
|
||||
// Use preserveRestarting: true so applications show "Restarting" instead of "Degraded"
|
||||
$aggregator = new ContainerStatusAggregator;
|
||||
$aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0);
|
||||
$aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0, preserveRestarting: true);
|
||||
|
||||
// Update application status with aggregated result
|
||||
if ($aggregatedStatus && $application->status !== $aggregatedStatus) {
|
||||
|
|
@ -360,8 +361,9 @@ private function aggregateServiceContainerStatuses()
|
|||
|
||||
// Use ContainerStatusAggregator service for state machine logic
|
||||
// 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;
|
||||
$aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0);
|
||||
$aggregatedStatus = $aggregator->aggregateFromStrings($relevantStatuses, 0, preserveRestarting: true);
|
||||
|
||||
// Update service sub-resource status with aggregated result
|
||||
if ($aggregatedStatus && $subResource->status !== $aggregatedStatus) {
|
||||
|
|
|
|||
|
|
@ -2,9 +2,12 @@
|
|||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Actions\Proxy\StartProxy;
|
||||
use App\Actions\Proxy\StopProxy;
|
||||
use App\Actions\Proxy\GetProxyConfiguration;
|
||||
use App\Actions\Proxy\SaveProxyConfiguration;
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Events\ProxyStatusChangedUI;
|
||||
use App\Models\Server;
|
||||
use App\Services\ProxyDashboardCacheService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
|
|
@ -19,11 +22,13 @@ class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue
|
|||
|
||||
public $tries = 1;
|
||||
|
||||
public $timeout = 60;
|
||||
public $timeout = 120;
|
||||
|
||||
public ?int $activity_id = null;
|
||||
|
||||
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) {}
|
||||
|
|
@ -31,15 +36,125 @@ public function __construct(public Server $server) {}
|
|||
public function handle()
|
||||
{
|
||||
try {
|
||||
StopProxy::run($this->server, restarting: true);
|
||||
|
||||
// Set status to restarting
|
||||
$this->server->proxy->status = 'restarting';
|
||||
$this->server->proxy->force_stop = false;
|
||||
$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) {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 @@ public function handle(): void
|
|||
if (count($this->containers) == 1 || str_starts_with($containerName, $this->task->container.'-'.$this->resource->uuid)) {
|
||||
$cmd = "sh -c '".str_replace("'", "'\''", $this->task->command)."'";
|
||||
$exec = "docker exec {$containerName} {$cmd}";
|
||||
$this->task_output = instant_remote_process([$exec], $this->server, true);
|
||||
// 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([
|
||||
'status' => 'success',
|
||||
'message' => $this->task_output,
|
||||
|
|
|
|||
|
|
@ -111,34 +111,48 @@ private function processScheduledTasks(Collection $servers): 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
|
||||
$lastSentinelUpdate = $server->sentinel_updated_at;
|
||||
$waitTime = $server->waitBeforeDoingSshCheck();
|
||||
$sentinelOutOfSync = Carbon::parse($lastSentinelUpdate)->isBefore($this->executionTime->subSeconds($waitTime));
|
||||
$sentinelOutOfSync = Carbon::parse($lastSentinelUpdate)->isBefore($this->executionTime->copy()->subSeconds($waitTime));
|
||||
|
||||
if ($sentinelOutOfSync) {
|
||||
// Dispatch jobs if Sentinel is out of sync
|
||||
if ($this->shouldRunNow($this->checkFrequency)) {
|
||||
// Dispatch ServerCheckJob if Sentinel is out of sync
|
||||
if ($this->shouldRunNow($this->checkFrequency, $serverTimezone)) {
|
||||
ServerCheckJob::dispatch($server);
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch ServerStorageCheckJob if due
|
||||
$serverDiskUsageCheckFrequency = data_get($server->settings, 'server_disk_usage_check_frequency', '0 * * * *');
|
||||
$isSentinelEnabled = $server->isSentinelEnabled();
|
||||
$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])) {
|
||||
$serverDiskUsageCheckFrequency = VALID_CRON_STRINGS[$serverDiskUsageCheckFrequency];
|
||||
}
|
||||
$shouldRunStorageCheck = $this->shouldRunNow($serverDiskUsageCheckFrequency);
|
||||
$shouldRunStorageCheck = $this->shouldRunNow($serverDiskUsageCheckFrequency, $serverTimezone);
|
||||
|
||||
if ($shouldRunStorageCheck) {
|
||||
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)
|
||||
$shouldRunPatchCheck = $this->shouldRunNow('0 0 * * 0', $serverTimezone);
|
||||
|
||||
|
|
@ -146,14 +160,10 @@ private function processServerTasks(Server $server): void
|
|||
ServerPatchCheckJob::dispatch($server);
|
||||
}
|
||||
|
||||
// Dispatch Sentinel restart if due (daily for Sentinel-enabled servers)
|
||||
$isSentinelEnabled = $server->isSentinelEnabled();
|
||||
$shouldRestartSentinel = $isSentinelEnabled && $this->shouldRunNow('0 0 * * *', $serverTimezone);
|
||||
|
||||
if ($shouldRestartSentinel) {
|
||||
dispatch(function () use ($server) {
|
||||
$server->restartContainer('coolify-sentinel');
|
||||
});
|
||||
// Sentinel update checks (hourly) - check for updates to Sentinel version
|
||||
// No timezone needed for hourly - runs at top of every hour
|
||||
if ($isSentinelEnabled && $this->shouldRunNow('0 * * * *')) {
|
||||
CheckAndStartSentinelJob::dispatch($server);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -168,6 +168,9 @@ public function handle(): void
|
|||
if (! $this->server->isBuildServer()) {
|
||||
$proxyShouldRun = CheckProxy::run($this->server, true);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
use App\Enums\ProxyTypes;
|
||||
use App\Events\ProxyStatusChanged;
|
||||
use App\Events\ProxyStatusChangedUI;
|
||||
use App\Jobs\CheckTraefikVersionForServerJob;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ProxyStatusChangedNotification implements ShouldQueueAfterCommit
|
||||
{
|
||||
|
|
@ -32,6 +35,19 @@ public function handle(ProxyStatusChanged $event)
|
|||
$server->setupDynamicProxyConfiguration();
|
||||
$server->proxy->force_stop = false;
|
||||
$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') {
|
||||
instant_remote_process([
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ class ActivityMonitor extends Component
|
|||
{
|
||||
public ?string $header = null;
|
||||
|
||||
public $activityId;
|
||||
public $activityId = null;
|
||||
|
||||
public $eventToDispatch = 'activityFinished';
|
||||
|
||||
|
|
@ -49,9 +49,24 @@ public function newMonitorActivity($activityId, $eventToDispatch = 'activityFini
|
|||
|
||||
public function hydrateActivity()
|
||||
{
|
||||
if ($this->activityId === null) {
|
||||
$this->activity = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->activity = Activity::find($this->activityId);
|
||||
}
|
||||
|
||||
public function updatedActivityId($value)
|
||||
{
|
||||
if ($value) {
|
||||
$this->hydrateActivity();
|
||||
$this->isPollingActive = true;
|
||||
self::$eventDispatched = false;
|
||||
}
|
||||
}
|
||||
|
||||
public function polling()
|
||||
{
|
||||
$this->hydrateActivity();
|
||||
|
|
|
|||
|
|
@ -18,9 +18,9 @@ class Dashboard extends Component
|
|||
|
||||
public function mount()
|
||||
{
|
||||
$this->privateKeys = PrivateKey::ownedByCurrentTeam()->get();
|
||||
$this->servers = Server::ownedByCurrentTeam()->get();
|
||||
$this->projects = Project::ownedByCurrentTeam()->get();
|
||||
$this->privateKeys = PrivateKey::ownedByCurrentTeamCached();
|
||||
$this->servers = Server::ownedByCurrentTeamCached();
|
||||
$this->projects = Project::ownedByCurrentTeam()->with('environments')->get();
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ class DeploymentsIndicator extends Component
|
|||
#[Computed]
|
||||
public function deployments()
|
||||
{
|
||||
$servers = Server::ownedByCurrentTeam()->get();
|
||||
$servers = Server::ownedByCurrentTeamCached();
|
||||
|
||||
return ApplicationDeploymentQueue::with(['application.environment.project'])
|
||||
->whereIn('status', ['in_progress', 'queued'])
|
||||
|
|
@ -38,6 +38,12 @@ public function deploymentCount()
|
|||
return $this->deployments->count();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function shouldReduceOpacity(): bool
|
||||
{
|
||||
return request()->routeIs('project.application.deployment.*');
|
||||
}
|
||||
|
||||
public function toggleExpanded()
|
||||
{
|
||||
$this->expanded = ! $this->expanded;
|
||||
|
|
|
|||
|
|
@ -2,10 +2,8 @@
|
|||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Livewire\Component;
|
||||
|
||||
class NavbarDeleteTeam extends Component
|
||||
|
|
@ -19,12 +17,8 @@ public function mount()
|
|||
|
||||
public function delete($password)
|
||||
{
|
||||
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
|
||||
if (! Hash::check($password, Auth::user()->password)) {
|
||||
$this->addError('password', 'The provided password is incorrect.');
|
||||
|
||||
return;
|
||||
}
|
||||
if (! verifyPasswordConfirmation($password, $this)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$currentTeam = currentTeam();
|
||||
|
|
|
|||
|
|
@ -37,6 +37,12 @@ class Advanced extends Component
|
|||
#[Validate(['boolean'])]
|
||||
public bool $disableBuildCache = false;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $injectBuildArgsToDockerfile = true;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $includeSourceCommitInBuild = false;
|
||||
|
||||
#[Validate(['boolean'])]
|
||||
public bool $isLogDrainEnabled = false;
|
||||
|
||||
|
|
@ -110,6 +116,8 @@ public function syncData(bool $toModel = false)
|
|||
$this->application->settings->is_raw_compose_deployment_enabled = $this->isRawComposeDeploymentEnabled;
|
||||
$this->application->settings->connect_to_docker_network = $this->isConnectToDockerNetworkEnabled;
|
||||
$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();
|
||||
} else {
|
||||
$this->isForceHttpsEnabled = $this->application->isForceHttpsEnabled();
|
||||
|
|
@ -134,6 +142,8 @@ public function syncData(bool $toModel = false)
|
|||
$this->isRawComposeDeploymentEnabled = $this->application->settings->is_raw_compose_deployment_enabled;
|
||||
$this->isConnectToDockerNetworkEnabled = $this->application->settings->connect_to_docker_network;
|
||||
$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 bool $is_debug_enabled = false;
|
||||
|
||||
public bool $fullscreen = false;
|
||||
|
||||
private bool $deploymentFinishedDispatched = false;
|
||||
|
||||
public function getListeners()
|
||||
{
|
||||
$teamId = auth()->user()->currentTeam()->id;
|
||||
|
||||
return [
|
||||
"echo-private:team.{$teamId},ServiceChecked" => '$refresh',
|
||||
'refreshQueue',
|
||||
];
|
||||
}
|
||||
|
|
@ -56,9 +59,23 @@ public function mount()
|
|||
$this->application_deployment_queue = $application_deployment_queue;
|
||||
$this->horizon_job_status = $this->application_deployment_queue->getHorizonJobStatus();
|
||||
$this->deployment_uuid = $deploymentUuid;
|
||||
$this->is_debug_enabled = $this->application->settings->is_debug_enabled;
|
||||
$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()
|
||||
{
|
||||
$this->application_deployment_queue->refresh();
|
||||
|
|
@ -75,10 +92,15 @@ private function isKeepAliveOn()
|
|||
|
||||
public function polling()
|
||||
{
|
||||
$this->dispatch('deploymentFinished');
|
||||
$this->application_deployment_queue->refresh();
|
||||
$this->horizon_job_status = $this->application_deployment_queue->getHorizonJobStatus();
|
||||
$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()
|
||||
|
|
|
|||
|
|
@ -521,7 +521,7 @@ public function instantSave()
|
|||
}
|
||||
}
|
||||
|
||||
public function loadComposeFile($isInit = false, $showToast = true)
|
||||
public function loadComposeFile($isInit = false, $showToast = true, ?string $restoreBaseDirectory = null, ?string $restoreDockerComposeLocation = null)
|
||||
{
|
||||
try {
|
||||
$this->authorize('update', $this->application);
|
||||
|
|
@ -530,7 +530,7 @@ public function loadComposeFile($isInit = false, $showToast = true)
|
|||
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)) {
|
||||
$showToast && $this->dispatch('error', 'Failed to parse your docker-compose file. Please check the syntax and try again.');
|
||||
|
||||
|
|
@ -606,13 +606,6 @@ public function generateDomain(string $serviceName)
|
|||
}
|
||||
}
|
||||
|
||||
public function updatedBaseDirectory()
|
||||
{
|
||||
if ($this->buildPack === 'dockercompose') {
|
||||
$this->loadComposeFile();
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedIsStatic($value)
|
||||
{
|
||||
if ($value) {
|
||||
|
|
@ -786,11 +779,13 @@ public function submit($showToaster = true)
|
|||
try {
|
||||
$this->authorize('update', $this->application);
|
||||
|
||||
$this->resetErrorBag();
|
||||
$this->validate();
|
||||
|
||||
$oldPortsExposes = $this->application->ports_exposes;
|
||||
$oldIsContainerLabelEscapeEnabled = $this->application->settings->is_container_label_escape_enabled;
|
||||
$oldDockerComposeLocation = $this->initialDockerComposeLocation;
|
||||
$oldBaseDirectory = $this->application->base_directory;
|
||||
|
||||
// Process FQDN with intermediate variable to avoid Collection/string confusion
|
||||
$this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString();
|
||||
|
|
@ -821,6 +816,42 @@ public function submit($showToaster = true)
|
|||
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();
|
||||
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");
|
||||
|
|
@ -828,13 +859,6 @@ public function submit($showToaster = true)
|
|||
$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) {
|
||||
$this->resetDefaultLabels();
|
||||
}
|
||||
|
|
@ -855,14 +879,6 @@ public function submit($showToaster = true)
|
|||
$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') {
|
||||
$this->application->docker_compose_domains = json_encode($this->parsedServiceDomains);
|
||||
if ($this->application->isDirty('docker_compose_domains')) {
|
||||
|
|
@ -1018,11 +1034,27 @@ public function getDockerComposeBuildCommandPreviewProperty(): string
|
|||
// 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}
|
||||
// Build-time env path references ApplicationDeploymentJob::BUILD_TIME_ENV_PATH as source of truth
|
||||
return injectDockerComposeFlags(
|
||||
$command = injectDockerComposeFlags(
|
||||
$this->dockerComposeCustomBuildCommand,
|
||||
".{$normalizedBase}{$this->dockerComposeLocation}",
|
||||
\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
|
||||
|
|
|
|||
|
|
@ -100,19 +100,17 @@ public function deploy(bool $force_rebuild = false)
|
|||
deployment_uuid: $this->deploymentUuid,
|
||||
force_rebuild: $force_rebuild,
|
||||
);
|
||||
if ($result['status'] === 'queue_full') {
|
||||
$this->dispatch('error', 'Deployment queue full', $result['message']);
|
||||
|
||||
return;
|
||||
}
|
||||
if ($result['status'] === 'skipped') {
|
||||
$this->dispatch('error', 'Deployment skipped', $result['message']);
|
||||
|
||||
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', [
|
||||
'project_uuid' => $this->parameters['project_uuid'],
|
||||
'application_uuid' => $this->parameters['application_uuid'],
|
||||
|
|
@ -151,19 +149,17 @@ public function restart()
|
|||
deployment_uuid: $this->deploymentUuid,
|
||||
restart_only: true,
|
||||
);
|
||||
if ($result['status'] === 'queue_full') {
|
||||
$this->dispatch('error', 'Deployment queue full', $result['message']);
|
||||
|
||||
return;
|
||||
}
|
||||
if ($result['status'] === 'skipped') {
|
||||
$this->dispatch('success', 'Deployment skipped', $result['message']);
|
||||
|
||||
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', [
|
||||
'project_uuid' => $this->parameters['project_uuid'],
|
||||
'application_uuid' => $this->parameters['application_uuid'],
|
||||
|
|
|
|||
|
|
@ -249,6 +249,11 @@ public function deploy(int $pull_request_id, ?string $pull_request_html_url = nu
|
|||
pull_request_id: $pull_request_id,
|
||||
git_type: $found->git_type ?? null,
|
||||
);
|
||||
if ($result['status'] === 'queue_full') {
|
||||
$this->dispatch('error', 'Deployment queue full', $result['message']);
|
||||
|
||||
return;
|
||||
}
|
||||
if ($result['status'] === 'skipped') {
|
||||
$this->dispatch('success', 'Deployment skipped', $result['message']);
|
||||
|
||||
|
|
@ -278,7 +283,7 @@ private function stopContainers(array $containers, $server)
|
|||
|
||||
foreach ($containersToStop as $containerName) {
|
||||
instant_remote_process(command: [
|
||||
"docker stop --time=30 $containerName",
|
||||
"docker stop -t 30 $containerName",
|
||||
"docker rm -f $containerName",
|
||||
], server: $server, throwError: false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,8 +96,7 @@ public function generate()
|
|||
$preview_fqdn = str_replace('{{random}}', $random, $template);
|
||||
$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('{{port}}', $port, $preview_fqdn);
|
||||
$preview_fqdns[] = "$schema://$preview_fqdn";
|
||||
$preview_fqdns[] = "$schema://$preview_fqdn{$port}";
|
||||
}
|
||||
|
||||
$preview_fqdn = implode(',', $preview_fqdns);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
use App\Models\Application;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
|
|
@ -19,9 +20,30 @@ class Rollback extends Component
|
|||
|
||||
public array $parameters;
|
||||
|
||||
#[Validate(['integer', 'min:0', 'max:100'])]
|
||||
public int $dockerImagesToKeep = 2;
|
||||
|
||||
public bool $serverRetentionDisabled = false;
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$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)
|
||||
|
|
@ -30,7 +52,7 @@ public function rollbackImage($commit)
|
|||
|
||||
$deployment_uuid = new Cuid2;
|
||||
|
||||
queue_application_deployment(
|
||||
$result = queue_application_deployment(
|
||||
application: $this->application,
|
||||
deployment_uuid: $deployment_uuid,
|
||||
commit: $commit,
|
||||
|
|
@ -38,6 +60,12 @@ public function rollbackImage($commit)
|
|||
force_rebuild: false,
|
||||
);
|
||||
|
||||
if ($result['status'] === 'queue_full') {
|
||||
$this->dispatch('error', 'Deployment queue full', $result['message']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return redirect()->route('project.application.deployment.show', [
|
||||
'project_uuid' => $this->parameters['project_uuid'],
|
||||
'application_uuid' => $this->parameters['application_uuid'],
|
||||
|
|
@ -66,14 +94,12 @@ public function loadImages($showToast = false)
|
|||
return str($item)->contains($image);
|
||||
})->map(function ($item) {
|
||||
$item = str($item)->explode('#');
|
||||
if ($item[1] === $this->current) {
|
||||
// $is_current = true;
|
||||
}
|
||||
$is_current = $item[1] === $this->current;
|
||||
|
||||
return [
|
||||
'tag' => $item[1],
|
||||
'created_at' => $item[2],
|
||||
'is_current' => $is_current ?? null,
|
||||
'is_current' => $is_current,
|
||||
];
|
||||
})->toArray();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,12 +2,9 @@
|
|||
|
||||
namespace App\Livewire\Project\Database;
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use Exception;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
|
|
@ -107,6 +104,25 @@ public function syncData(bool $toModel = false)
|
|||
$this->backup->save_s3 = $this->saveS3;
|
||||
$this->backup->disable_local_backup = $this->disableLocalBackup;
|
||||
$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->dump_all = $this->dumpAll;
|
||||
$this->backup->timeout = $this->timeout;
|
||||
|
|
@ -135,12 +151,8 @@ public function delete($password)
|
|||
{
|
||||
$this->authorize('manageBackups', $this->backup->database);
|
||||
|
||||
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
|
||||
if (! Hash::check($password, Auth::user()->password)) {
|
||||
$this->addError('password', 'The provided password is incorrect.');
|
||||
|
||||
return;
|
||||
}
|
||||
if (! verifyPasswordConfirmation($password, $this)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -2,11 +2,9 @@
|
|||
|
||||
namespace App\Livewire\Project\Database;
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\ScheduledDatabaseBackup;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Livewire\Component;
|
||||
|
||||
class BackupExecutions extends Component
|
||||
|
|
@ -69,12 +67,8 @@ public function cleanupDeleted()
|
|||
|
||||
public function deleteBackup($executionId, $password)
|
||||
{
|
||||
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
|
||||
if (! Hash::check($password, Auth::user()->password)) {
|
||||
$this->addError('password', 'The provided password is incorrect.');
|
||||
|
||||
return;
|
||||
}
|
||||
if (! verifyPasswordConfirmation($password, $this)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$execution = $this->backup->executions()->where('id', $executionId)->first();
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Livewire\Project\Database;
|
||||
|
||||
use App\Models\S3Storage;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
|
@ -12,6 +13,92 @@ class Import extends Component
|
|||
{
|
||||
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 $resource;
|
||||
|
|
@ -46,6 +133,8 @@ class Import extends Component
|
|||
|
||||
public string $customLocation = '';
|
||||
|
||||
public ?int $activityId = null;
|
||||
|
||||
public string $postgresqlRestoreCommand = 'pg_restore -U $POSTGRES_USER -d $POSTGRES_DB';
|
||||
|
||||
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=';
|
||||
|
||||
// S3 Restore properties
|
||||
public $availableS3Storages = [];
|
||||
|
||||
public ?int $s3StorageId = null;
|
||||
|
||||
public string $s3Path = '';
|
||||
|
||||
public ?int $s3FileSize = null;
|
||||
|
||||
public function getListeners()
|
||||
{
|
||||
$userId = Auth::id();
|
||||
|
||||
return [
|
||||
"echo-private:user.{$userId},DatabaseStatusChanged" => '$refresh',
|
||||
'slideOverClosed' => 'resetActivityId',
|
||||
];
|
||||
}
|
||||
|
||||
public function resetActivityId()
|
||||
{
|
||||
$this->activityId = null;
|
||||
}
|
||||
|
||||
public function mount()
|
||||
{
|
||||
if (isDev()) {
|
||||
$this->customLocation = '/data/coolify/pg-dump-all-1736245863.gz';
|
||||
}
|
||||
$this->parameters = get_route_parameters();
|
||||
$this->getContainers();
|
||||
$this->loadAvailableS3Storages();
|
||||
}
|
||||
|
||||
public function updatedDumpAll($value)
|
||||
|
|
@ -152,8 +254,16 @@ public function getContainers()
|
|||
public function checkFile()
|
||||
{
|
||||
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 {
|
||||
$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)) {
|
||||
$this->dispatch('error', 'The file does not exist or has been deleted.');
|
||||
|
||||
|
|
@ -179,59 +289,35 @@ public function runImport()
|
|||
try {
|
||||
$this->importRunning = true;
|
||||
$this->importCommands = [];
|
||||
if (filled($this->customLocation)) {
|
||||
$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.');
|
||||
$backupFileName = "upload/{$this->resource->uuid}/restore";
|
||||
|
||||
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;
|
||||
instant_scp($path, $tmpPath, $this->server);
|
||||
Storage::delete($backupFileName);
|
||||
$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
|
||||
$scriptPath = "/tmp/restore_{$this->resource->uuid}.sh";
|
||||
|
||||
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;
|
||||
}
|
||||
$restoreCommand = $this->buildRestoreCommand($tmpPath);
|
||||
|
||||
$restoreCommandBase64 = base64_encode($restoreCommand);
|
||||
$this->importCommands[] = "echo \"{$restoreCommandBase64}\" | base64 -d > {$scriptPath}";
|
||||
|
|
@ -248,7 +334,13 @@ public function runImport()
|
|||
'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');
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
|
|
@ -257,4 +349,267 @@ public function runImport()
|
|||
$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 @@ public function save_init_script($script)
|
|||
$configuration_dir = database_configuration_dir().'/'.$container_name;
|
||||
|
||||
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 {
|
||||
// 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);
|
||||
} catch (Exception $e) {
|
||||
$this->dispatch('error', 'Failed to remove old init script from server: '.$e->getMessage());
|
||||
$this->dispatch('error', $e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
@ -370,13 +373,17 @@ public function delete_init_script($script)
|
|||
if ($found) {
|
||||
$container_name = $this->database->uuid;
|
||||
$configuration_dir = database_configuration_dir().'/'.$container_name;
|
||||
$file_path = "$configuration_dir/docker-entrypoint-initdb.d/{$script['filename']}";
|
||||
|
||||
$command = "rm -f $file_path";
|
||||
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);
|
||||
} catch (Exception $e) {
|
||||
$this->dispatch('error', 'Failed to remove init script from server: '.$e->getMessage());
|
||||
$this->dispatch('error', $e->getMessage());
|
||||
|
||||
return;
|
||||
}
|
||||
|
|
@ -405,6 +412,16 @@ public function save_new_init_script()
|
|||
'new_filename' => '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);
|
||||
if ($found) {
|
||||
$this->dispatch('error', 'Filename already exists.');
|
||||
|
|
|
|||
|
|
@ -17,9 +17,9 @@ class Index extends Component
|
|||
|
||||
public function mount()
|
||||
{
|
||||
$this->private_keys = PrivateKey::ownedByCurrentTeam()->get();
|
||||
$this->projects = Project::ownedByCurrentTeam()->get();
|
||||
$this->servers = Server::ownedByCurrentTeam()->count();
|
||||
$this->private_keys = PrivateKey::ownedByCurrentTeamCached();
|
||||
$this->projects = Project::ownedByCurrentTeamCached();
|
||||
$this->servers = Server::ownedByCurrentTeamCached();
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
|
|
|||
|
|
@ -74,6 +74,9 @@ public function submit()
|
|||
}
|
||||
$service->parse(isNew: true);
|
||||
|
||||
// Apply service-specific application prerequisites
|
||||
applyServiceApplicationPrerequisites($service);
|
||||
|
||||
return redirect()->route('project.service.configuration', [
|
||||
'service_uuid' => $service->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
|
|
|
|||
|
|
@ -75,16 +75,6 @@ public function mount()
|
|||
$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()
|
||||
{
|
||||
if ($this->build_pack === 'nixpacks') {
|
||||
|
|
@ -138,6 +128,7 @@ public function loadBranches()
|
|||
$this->loadBranchByPage();
|
||||
}
|
||||
}
|
||||
$this->branches = sortBranchesByPriority($this->branches);
|
||||
$this->selected_branch_name = data_get($this->branches, '0.name', 'main');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -107,26 +107,6 @@ public function mount()
|
|||
$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()
|
||||
{
|
||||
if ($this->build_pack === 'nixpacks') {
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ public function mount()
|
|||
'destination_id' => $destination->id,
|
||||
'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);
|
||||
}
|
||||
$service = Service::create($service_payload);
|
||||
|
|
@ -104,6 +104,9 @@ public function mount()
|
|||
}
|
||||
$service->parse(isNew: true);
|
||||
|
||||
// Apply service-specific application prerequisites
|
||||
applyServiceApplicationPrerequisites($service);
|
||||
|
||||
return redirect()->route('project.service.configuration', [
|
||||
'service_uuid' => $service->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
|
|
|
|||
|
|
@ -4,12 +4,9 @@
|
|||
|
||||
use App\Actions\Database\StartDatabaseProxy;
|
||||
use App\Actions\Database\StopDatabaseProxy;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\ServiceDatabase;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Livewire\Component;
|
||||
|
||||
class Database extends Component
|
||||
|
|
@ -96,12 +93,8 @@ public function delete($password)
|
|||
try {
|
||||
$this->authorize('delete', $this->database);
|
||||
|
||||
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
|
||||
if (! Hash::check($password, Auth::user()->password)) {
|
||||
$this->addError('password', 'The provided password is incorrect.');
|
||||
|
||||
return;
|
||||
}
|
||||
if (! verifyPasswordConfirmation($password, $this)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->database->delete();
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
namespace App\Livewire\Project\Service;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\LocalFileVolume;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\ServiceDatabase;
|
||||
|
|
@ -16,8 +15,6 @@
|
|||
use App\Models\StandalonePostgresql;
|
||||
use App\Models\StandaloneRedis;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
|
||||
|
|
@ -62,7 +59,7 @@ public function mount()
|
|||
$this->fs_path = $this->fileStorage->fs_path;
|
||||
}
|
||||
|
||||
$this->isReadOnly = $this->fileStorage->isReadOnlyVolume();
|
||||
$this->isReadOnly = $this->fileStorage->shouldBeReadOnlyInUI();
|
||||
$this->syncData();
|
||||
}
|
||||
|
||||
|
|
@ -104,7 +101,8 @@ public function convertToDirectory()
|
|||
public function loadStorageOnServer()
|
||||
{
|
||||
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->syncData();
|
||||
|
|
@ -140,12 +138,8 @@ public function delete($password)
|
|||
{
|
||||
$this->authorize('update', $this->resource);
|
||||
|
||||
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
|
||||
if (! Hash::check($password, Auth::user()->password)) {
|
||||
$this->addError('password', 'The provided password is incorrect.');
|
||||
|
||||
return;
|
||||
}
|
||||
if (! verifyPasswordConfirmation($password, $this)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -2,12 +2,9 @@
|
|||
|
||||
namespace App\Livewire\Project\Service;
|
||||
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\ServiceApplication;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Livewire\Attributes\Validate;
|
||||
use Livewire\Component;
|
||||
use Spatie\Url\Url;
|
||||
|
|
@ -82,6 +79,21 @@ public function instantSave()
|
|||
}
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
try {
|
||||
|
|
@ -113,12 +125,8 @@ public function delete($password)
|
|||
try {
|
||||
$this->authorize('delete', $this->application);
|
||||
|
||||
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
|
||||
if (! Hash::check($password, Auth::user()->password)) {
|
||||
$this->addError('password', 'The provided password is incorrect.');
|
||||
|
||||
return;
|
||||
}
|
||||
if (! verifyPasswordConfirmation($password, $this)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->application->delete();
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ public function refreshStoragesFromEvent()
|
|||
public function refreshStorages()
|
||||
{
|
||||
$this->fileStorage = $this->resource->fileStorages()->get();
|
||||
$this->resource->refresh();
|
||||
$this->resource->load('persistentStorages.resource');
|
||||
}
|
||||
|
||||
public function getFilesProperty()
|
||||
|
|
@ -179,6 +179,10 @@ public function submitFileStorageDirectory()
|
|||
$this->file_storage_directory_destination = trim($this->file_storage_directory_destination);
|
||||
$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([
|
||||
'fs_path' => $this->file_storage_directory_source,
|
||||
'mount_path' => $this->file_storage_directory_destination,
|
||||
|
|
|
|||
|
|
@ -3,13 +3,10 @@
|
|||
namespace App\Livewire\Project\Shared;
|
||||
|
||||
use App\Jobs\DeleteResourceJob;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Service;
|
||||
use App\Models\ServiceApplication;
|
||||
use App\Models\ServiceDatabase;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Livewire\Component;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
|
|
@ -93,12 +90,8 @@ public function mount()
|
|||
|
||||
public function delete($password)
|
||||
{
|
||||
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
|
||||
if (! Hash::check($password, Auth::user()->password)) {
|
||||
$this->addError('password', 'The provided password is incorrect.');
|
||||
|
||||
return;
|
||||
}
|
||||
if (! verifyPasswordConfirmation($password, $this)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->resource) {
|
||||
|
|
|
|||
|
|
@ -5,12 +5,9 @@
|
|||
use App\Actions\Application\StopApplicationOneServer;
|
||||
use App\Actions\Docker\GetContainersStatus;
|
||||
use App\Events\ApplicationStatusChanged;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Livewire\Component;
|
||||
use Visus\Cuid2\Cuid2;
|
||||
|
||||
|
|
@ -89,6 +86,11 @@ public function redeploy(int $network_id, int $server_id)
|
|||
only_this_server: true,
|
||||
no_questions_asked: true,
|
||||
);
|
||||
if ($result['status'] === 'queue_full') {
|
||||
$this->dispatch('error', 'Deployment queue full', $result['message']);
|
||||
|
||||
return;
|
||||
}
|
||||
if ($result['status'] === 'skipped') {
|
||||
$this->dispatch('success', 'Deployment skipped', $result['message']);
|
||||
|
||||
|
|
@ -135,12 +137,8 @@ public function addServer(int $network_id, int $server_id)
|
|||
public function removeServer(int $network_id, int $server_id, $password)
|
||||
{
|
||||
try {
|
||||
if (! data_get(InstanceSettings::get(), 'disable_two_step_confirmation')) {
|
||||
if (! Hash::check($password, Auth::user()->password)) {
|
||||
$this->addError('password', 'The provided password is incorrect.');
|
||||
|
||||
return;
|
||||
}
|
||||
if (! verifyPasswordConfirmation($password, $this)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->resource->destination->server->id == $server_id && $this->resource->destination->id == $network_id) {
|
||||
|
|
|
|||
|
|
@ -2,8 +2,11 @@
|
|||
|
||||
namespace App\Livewire\Project\Shared\EnvironmentVariable;
|
||||
|
||||
use App\Models\Environment;
|
||||
use App\Models\Project;
|
||||
use App\Traits\EnvironmentVariableAnalyzer;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
|
||||
class Add extends Component
|
||||
|
|
@ -56,6 +59,72 @@ public function mount()
|
|||
$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()
|
||||
{
|
||||
$this->validate();
|
||||
|
|
|
|||
|
|
@ -2,11 +2,14 @@
|
|||
|
||||
namespace App\Livewire\Project\Shared\EnvironmentVariable;
|
||||
|
||||
use App\Models\Environment;
|
||||
use App\Models\EnvironmentVariable as ModelsEnvironmentVariable;
|
||||
use App\Models\Project;
|
||||
use App\Models\SharedEnvironmentVariable;
|
||||
use App\Traits\EnvironmentVariableAnalyzer;
|
||||
use App\Traits\EnvironmentVariableProtection;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
|
||||
class Show extends Component
|
||||
|
|
@ -184,6 +187,7 @@ public function submit()
|
|||
|
||||
$this->serialize();
|
||||
$this->syncData(true);
|
||||
$this->syncData(false);
|
||||
$this->dispatch('success', 'Environment variable updated.');
|
||||
$this->dispatch('envsUpdated');
|
||||
$this->dispatch('configurationChanged');
|
||||
|
|
@ -192,6 +196,72 @@ public function submit()
|
|||
}
|
||||
}
|
||||
|
||||
#[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()
|
||||
{
|
||||
try {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue